diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48655f3..981079e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,3 @@ -# This workflow will install Python dependencies, run tests and lint with a variety of Python versions -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python - name: CI on: @@ -11,12 +8,11 @@ on: jobs: tests: - runs-on: ubuntu-latest strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v4 @@ -24,14 +20,9 @@ jobs: uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} + - name: Install uv + uses: astral-sh/setup-uv@v3 - name: Install dependencies - run: | - python -m pip install --upgrade pip - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Install tox and any other packages - run: | - pip install tox - - name: Run tox - # Run tox using the version of Python in `PATH` - run: | - tox run -e py$(echo ${{ matrix.python-version }}) + run: uv sync --frozen + - name: Run tests + run: uv run pytest -xv tests/ diff --git a/.gitignore b/.gitignore index ec2de4a..4d795c7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ __pycache__/ dist/ *.egg-info/ +.venv/ +estudo/ +validacao-experimentos/ \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0aac9fa..308769f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,20 +6,16 @@ repos: - id: end-of-file-fixer exclude: '.*\.pth$' - id: debug-statements + - repo: https://github.com/astral-sh/uv-pre-commit + rev: v0.5.0 + hooks: + - id: uv-lock - repo: https://github.com/codespell-project/codespell rev: v2.2.6 hooks: - id: codespell - - repo: https://github.com/PyCQA/isort - rev: 5.12.0 - hooks: - - id: isort - - repo: https://github.com/asottile/pyupgrade - rev: v3.3.1 - hooks: - - id: pyupgrade - args: [--py37-plus] - - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.16.1 hooks: - - id: flake8 + - id: ruff + args: [--fix, --exit-non-zero-on-fix] diff --git a/CHANGELOG.md b/CHANGELOG.md index 656ef62..e665bfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,61 +1,124 @@ # Changelog -0.3.3 (2024-04-08) ----- - -* Add replay option -* Refine random order and test group level -* Code refactoring - -0.3.3 (2024-12-24) ----- - -* Support order dependency - -0.3.2 (2024-12-23) ----- - -* Refine test group level definition and extraction -* Use pytest test discovery order as default - -0.3.1 (2024-06-06) ----- - -* Support ranking tests at different granularity levels (PUT, method, file, folder) -* Add plugin summary via pytest_terminal_summary - -0.3.0 (2024-05-12) ----- - -* Fix attribute initialization in change tracker for Python 3.11 and lower versions - - -0.2.8 (2024-05-08) ----- - -* Make pytest-xdist compatible for random and change-related heuristics - - -0.2.7 (2024-03-18) ----- - -* Add option to run tests in random order -* Improve documentation - - - - -0.2.0 (2024-02-16) ----- - -* Add textual similarity between tests and changed files since last run as the third heuristic -* Weight normalization -* History length as an optional argument -* Rename plugin to `pytest-ranking` - - -0.1.0 (2023-12-04) ----- - -* First release on PyPI +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] pytest-regsmart + +## [0.6.3] - 2026-08-29 + +### Added +- Hotfix: Resolve base branch to the merge destination instead of current branch +- Add validation for args options in pytest.ini +- Add default values for diff-level, rank-level and no-rank flags in pytest.ini +## [0.6.2] - 2026-08-22 + +### Added +- New `--diff-level` flag to choose the diff granularity: `function`pr (default) or `file` +- Track deleted files in the diff result +## [0.6.1] - 2026-08-11 + +### Changed +- License changed from MIT to GPLv2+ +### Added +- Default branch information in summary log report +## [0.6.0] - 2026-08-10 + +### Security +- Bump pytest from version 7.4.3 to 9.0.3 ([CVE-2025-71176](https://github.com/leticosta4/pytest-regsmart/security/dependabot/1)) +- Bump gitpython from 3.1.57 to 3.1.58 ([CVEs](https://github.com/leticosta4/pytest-regsmart/security/dependabot?q=GitPython+is%3Aclosed)) +### Removed +- Support for Python 3.8 and 3.9 +## [0.5.0] - 2026-08-07 + +### Added +- Regression Test Selection (RTS): a new `selection` module that computes the test files affected by the current changes and runs only them under `--regsmart` + - Detect changed files with git (modified + untracked) against the resolved default branch (origin/HEAD → main/master → active branch); repositories without commits fall back to untracked files only + - Build a module-level dependency graph with pyan3 and propagate changes transitively (BFS) to affected test files + - Raise a `UsageError` when `--regsmart` is used outside a git repository +- Report the RTS configuration and timing in the terminal header/summary +- Integration tests for the selection module and improved test suite +### Changed +- Refine `--no-rank`: it now only disables RTP while keeping RTS active, and cannot be combined with other `--rank-*` flags (raises a `UsageError`) +## [0.4.0] - 2026-07-24 + +### Added +- `--no-rank` option to remove RTP, considering only the future RTS module +### Changed +- Rename activation flag from `--rank` to `--regsmart` +- Improve documentation +## [0.3.0] - 2026-07-20 + +### Changed +- Migrate package manager to uv +## [0.2.0] - 2026-07-19 + +### Changed +- Rename whole package name to `pytest-regsmart` +- Refactor ranking module internals (modularization) +### Removed +- Directory-level granularity (`--rank-level=dir`) +## [0.1.0] - 2026-06-25 + +### Added +- Fork of pytest-ranking (based on PyPI release 0.3.4) +### Changed +- Rename plugin to `pytest-regsmart` +### Removed +- Similar Changed Path method (`--rank-weight=0-0-1`) + +--- + +## Inherited history (pytest-ranking) + +## [0.3.4] - 2025-04-09 + +### Added +- Replay option +- Published version at [PyPI](https://pypi.org/project/pytest-ranking/0.3.4/#history) +### Changed +- Refine random order and test group level +- Code refactoring +## [0.3.3] - 2024-12-24 + +### Added +- Support order dependency +## [0.3.2] - 2024-12-23 + +### Changed +- Refine test group level definition and extraction +- Use pytest test discovery order as default +## [0.3.1] - 2024-06-06 + +### Added +- Support ranking tests at different granularity levels (PUT, method, file, folder) +- Plugin summary via `pytest_terminal_summary` +## [0.3.0] - 2024-05-12 + +### Fixed +- Attribute initialization in change tracker for Python 3.11 and lower versions +## [0.2.8] - 2024-05-08 + +### Fixed +- pytest-xdist compatibility for random and change-related heuristics +## [0.2.7] - 2024-03-18 + +### Added +- Option to run tests in random order +### Changed +- Improve documentation +## [0.2.0] - 2024-02-16 + +### Added +- Textual similarity between tests and changed files since last run as the third heuristic +- History length as an optional argument +### Changed +- Weight normalization +- Rename plugin to `pytest-ranking` +## [0.1.0] - 2023-12-04 + +### Added +- First release on PyPI diff --git a/docs/DEPLOYMENT.md b/DEPLOYMENT.md similarity index 60% rename from docs/DEPLOYMENT.md rename to DEPLOYMENT.md index 2b747c2..da48cbc 100644 --- a/docs/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -1,51 +1,63 @@ # Deployment -## Using pytest-ranking in fixed location +## Using pytest-regsmart in fixed location -If your CI runs different test builds in a fixed location, e.g., a project folder in specific machine, you can directly use `pytest-ranking` after installation without additional setup. +If your CI runs different test builds in a fixed location, e.g., a project folder in specific machine, you can directly use `pytest-regsmart` after installation without additional setup. -## Using pytest-ranking in Github Actions +## Using pytest-regsmart in Github Actions If your CI workflow always starts a new virtual machine to run a test build, you need to set up the CI to be able to pass `pytest` cache data across test builds. Here, we use GitHub Actions as an example. -### Add pytest-ranking to project dependency +### Check out the full history +`pytest-regsmart` computes the diff against the **base** (destination) branch — e.g. `main` for a PR or push. GitHub Actions' default checkout does a shallow, single-branch clone (`fetch-depth: 1`), which does not fetch that base branch, so the base cannot be resolved and `pytest-regsmart` raises a `UsageError`. Fetch the full history: -You can add `pytest-ranking` as a dependency by adding a installation job before the job that runs `pytest ...` (a job is often specified by `-name: `) in the workflow file: +```yml + - uses: actions/checkout@v4 + with: + fetch-depth: 0 +``` + +This only affects the clone metadata, not the working-tree files, so the added time is negligible even for large repositories. + +### Add pytest-regsmart to project dependency + + +You can add `pytest-regsmart` as a dependency by adding a installation job before the job that runs `pytest ...` (a job is often specified by `-name: `) in the workflow file: ```yml - - name: Install pytest-ranking related - run: pip install pytest-ranking + - name: Install pytest-regsmart related + run: pip install pytest-regsmart ``` -Alternatively, depending on where the forked project puts its dependency, e.g., can be in `setup.py`, `pyproejct.toml`, you can also add the `pytest-ranking` to the build/test dependency, but best not to specify version. +Alternatively, depending on where the forked project puts its dependency, e.g., can be in `setup.py`, `pyproejct.toml`, you can also add the `pytest-regsmart` to the build/test dependency, but best not to specify version. #### If the project uses `Tox` -Add `pytest-ranking` to the `deps` of `[testenv]` in `./tox.ini`: +Add `pytest-regsmart` to the `deps` of `[testenv]` in `./tox.ini`: ```ini [testenv] deps = ; ... - pytest-ranking + pytest-regsmart pytest-json-report ``` ### Setup pytest_cache -Before the job in the workflow file that runs the `pytest ...` but after the `pytest-ranking` installation job, add the job that restores cache from the latest run if such run exists: +Before the job in the workflow file that runs the `pytest ...` but after the `pytest-regsmart` installation job, add the job that restores cache from the latest run if such run exists: ```yml - - name: Restore pytest-ranking cache - id: restore-pytest-ranking-cache + - name: Restore pytest-regsmart cache + id: restore-pytest-regsmart-cache if: always() uses: actions/cache/restore@v4 with: path: ${{ github.workspace }}/.pytest_cache/v/pytest_ranking_data - key: pytest-ranking-cache-${{ github.workflow }}-${{ runner.os }}-${{ matrix.python }} + key: pytest-regsmart-cache-${{ github.workflow }}-${{ runner.os }}-${{ matrix.python }} # --------below is the job for running pytest -name: pytest ... @@ -57,13 +69,13 @@ And after the job that runs `pytest ...` command, add the job that caches result -name: pytest ... # --------above is the job for running pytest - - name: Save pytest-ranking cache - id: save-pytest-ranking-cache + - name: Save pytest-regsmart cache + id: save-pytest-regsmart-cache if: always() uses: actions/cache/save@v4 with: path: ${{ github.workspace }}/.pytest_cache/v/pytest_ranking_data - key: pytest-ranking-cache-${{ github.workflow }}-${{ runner.os }}-${{ matrix.python }}-${{ github.run_id }} + key: pytest-regsmart-cache-${{ github.workflow }}-${{ runner.os }}-${{ matrix.python }}-${{ github.run_id }} ``` #### If the project uses `Tox` diff --git a/LICENSE b/LICENSE index 90cb912..d159169 100644 --- a/LICENSE +++ b/LICENSE @@ -1,22 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 -The MIT License (MIT) + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. -Copyright (c) 2023 softwareTestingResearch + Preamble -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index f3155af..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,5 +0,0 @@ -include LICENSE -include README.md - -recursive-exclude * __pycache__ -recursive-exclude * *.py[co] diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000..41cb6da --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,49 @@ +# Third-Party Notices + +This project, pytest-regsmart, is distributed under the GNU General Public License v2.0 or later (see `LICENSE`). + +It builds on and incorporates third-party code under different licenses, as detailed below. + +## pytest-ranking (upstream project — MIT License) + +pytest-regsmart is a fork of pytest-ranking +(https://github.com/softwareTestingResearch/pytest-ranking), originally +distributed under the MIT License. + +The relevant MIT License notice from the upstream project is reproduced below: + +--- + +The MIT License (MIT) + +Copyright (c) 2023 softwareTestingResearch + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +--- + +## pyan3 (runtime dependency — GPL-2.0-or-later) + +pytest-regsmart imports and depends directly on pyan3 +(https://pypi.org/project/pyan3/) at runtime to build the dependency +graph used for regression test selection. + +pyan3 is licensed under the GNU General Public License, version 2 or later. + +See `LICENSE` for the full GPL-2.0-or-later license text. diff --git a/README.md b/README.md index a073ba6..955d47f 100644 --- a/README.md +++ b/README.md @@ -1,166 +1,256 @@ +# pytest-regsmart -# pytest-ranking - - - -[![CI](https://github.com/softwareTestingResearch/pytest-ranking/workflows/CI/badge.svg)](https://github.com/softwareTestingResearch/pytest-ranking/actions?workflow=CI) -[![PyPI](https://img.shields.io/pypi/v/pytest-ranking)](https://pypi.org/project/pytest-ranking) -[![pre-commit.ci status](https://results.pre-commit.ci/badge/github/softwareTestingResearch/pytest-ranking/main.svg)](https://results.pre-commit.ci/latest/github/softwareTestingResearch/pytest-ranking/main) - - -A Pytest plugin that implements Regression Test Prioritization (RTP) for faster regression fault detection. +A Pytest plugin that implements Regression Test Selection (RTS) and Regression Test Prioritization (RTP) for faster regression fault detection. This [pytest](https://github.com/pytest-dev/pytest) plugin allows you to find regression test failures faster and receive testing feedback sooner from CI build. -It speeds up the failure detection of your test suite by executing earlier the tests that are faster, recently failed, and more code-change-related. - +When enabled with `--regsmart`, it first **selects** only the test files affected by the changes since the last baseline (RTS), then **prioritizes** them so that the tests that are faster or recently failed run earlier (RTP). ## Installation -To install `pytest-ranking` via [pip](https://pypi.org/project/pip/) from [PyPI](https://pypi.org/project): - ```bash -pip install pytest-ranking +uv pip install pytest-regsmart ``` +Or using pip: + +```bash +pip install pytest-regsmart +``` ## Usage Pytest will automatically find the plugin and use it when you run ``pytest``. -You can run `pytest-ranking` with its default configuration, which runs faster tests first by passing the ``--rank`` option: +You can run `pytest-regsmart` with its default configuration, which selects only the affected tests and runs faster tests first, by passing the ``--regsmart`` option: ```bash -pytest --rank +pytest --regsmart ``` -Before the test run starts, if `--rank` is passed, the terminal header will report `pytest-ranking`'s configuration of this run, for example: -```text -Using --rank-weight=0-0-0 +Note that `--regsmart` requires the project to be a `git` repository (the regression test selection is computed from the changes since the last baseline). Running it outside a git repo raises a `UsageError`. + +The **base branch must exist locally** for the diff to be computed. On a shallow clone (e.g. `actions/checkout` with the default `fetch-depth: 1`), the destination branch (such as `main`) is not fetched, so the base cannot be resolved — `pytest-regsmart` raises a `UsageError` telling you what to fix instead of silently running the whole suite. In CI, fetch the full history (see [Deployment](./DEPLOYMENT.md)). + +Before the test run starts, if `--regsmart` is passed, the terminal header will report `pytest-regsmart`'s configuration of this run, for example: + +``` +Starting Smart Regression Test Management (RTS + RTP) +Using --diff-level=function +Using --rank-weight=1-0 Using --rank-level=put Using --rank-hist-len=50 -Using --rank-seed=1744140050 +Using --rank-seed=123 Using --rank-replay=None ``` +After the test run finishes, the terminal summary will show the overhead of `pytest-regsmart` in this run, for example: -After the test run finishes, the terminal summary will show the overhead of `pytest-ranking` in this run, for example: - - ```text -============================================= pytest-ranking summary info ============================================= -Number of changed Python files: 0 -Time to compute test-change similarity (s): 0.000865936279296875 -Time to reorder tests (s): 0.0003600120544433594 +``` +=================================== pytest-regsmart summary info ==================================== +Time to run the regression test selection (s): 0.0003604120544433594 +Time to run the regression test prioritization (s): 0.0004608631134033203 Time to collect test features (s): 0.0004608631134033203 ``` +### Disabling ranking (RTP) + +You can disable the regression test prioritization while keeping the plugin active with the `--no-rank` flag: + +```bash +pytest --regsmart --no-rank +``` + +This is useful when you want to run only the selected tests (RTS) without reordering them (RTP). +With `--no-rank`, the header instead reports: + +``` +Starting RTS (Regression Test Selection) +Using --no-rank (RTP disabled). +``` + +Note that `--no-rank` **cannot be combined with other `--rank-*` flags**: passing any of them together raises a `UsageError`. It only disables the prioritization step; the regression test selection still applies. + +### Choosing the diff granularity + +You can set at which level the changes are identified and tests are selected, by passing the optional `--diff-level` flag in one of these values: `function`, `file`. For example: + +```bash +pytest --regsmart --diff-level=file +``` + +- `function` (default): maps each changed line range from the git diff to the functions or methods that contain it, then selects only the test functions affected directly or transitively through the call graph. Diff hunks outside any function (e.g., imports or module constants) fall back to selecting the whole file. +- `file`: a change in one module selects every test file that transitively depends on it, which may include more tests than strictly necessary. + +This option can also be configured via the `diff_level` ini option (see [Setting configurable options via config file](#setting-configurable-options-via-config-file)). + ### Optimizing test prioritization heuristics You can set the weights of different test prioritization heuristics by passing the optional `--rank-weight` flag with formatted values: ```bash -pytest --rank --rank-weight=0-1-0 +pytest --regsmart --rank-weight=0-1 ``` - Weights are separated by ``-`` - - The first weight is for running faster tests - - The second weight is for running recently failed tests - - The third weight is for running tests more similar to the changed `*.py` files since the last run + - The first weight is for running faster tests + - The second weight is for running recently failed tests + - The third weight is for running tests more similar to the changed `*.py` files since the last run - All weights must be integers or floats, and their sum will be normalized to 1 - A higher weight means that a corresponding heuristic is favored. -The default value is ``1-0-0``, which only prioritizes faster tests. - +The default value is ``1-0``, which only prioritizes faster tests. ### Optimizing test prioritization levels -You can set at which level of your test suite will be reordered, by passing the optional `--rank-level` flag in one of these values: `put`, `function`, `module`, `dir`. For example: +You can set at which level of your test suite will be reordered, by passing the optional `--rank-level` flag in one of these values: `put`, `function`, `module`. For example: ```bash -pytest --rank --rank-level=function +pytest --regsmart --rank-level=function ``` - The smallest test item that can be reordered in pytest test suite is [parametrized unit test](https://docs.pytest.org/en/7.1.x/example/parametrize.html) (PUT) - This option allows you to set at which level the reordering takes place: - - `put` reorders the each PUT and re-arranges their order based on their assigned priority scores - - `function` reorders each test function, parametrized values of a test function follow their default order - - `module` reorders each test file, all tests in the test file follow their default order - - `dir` reorders each test directory, all tests within each directory follow their default order + - `put` reorders the each PUT and re-arranges their order based on their assigned priority scores + - `function` reorders each test function, parametrized values of a test function follow their default order + - `module` reorders each test file, all tests in the test file follow their default order The default value is `put`. - ### Replaying specified test order You can run/replay tests in a specific order by listing the to-be-run test IDs in a text file, where each line is a test ID, and pass the file path to the optional `--rank-replay` flag: ```bash -pytest --rank --rank-replay=replay_order.txt +pytest --regsmart --rank-replay=replay_order.txt ``` - ### Tracking data from historical runs You can also set the maximum value of *the number test runs since a test's last failure* that could be recorded for each test, by passing the optional `--rank-hist-len` flag: ```bash -pytest --rank --rank-hist-len=30 +pytest --regsmart --rank-hist-len=30 ``` The default value is 50. -Note that `pytest-ranking` does not store any historical test run logs, it merely updated its cached data from the previous run with data from the latest run. +Note that `pytest-regsmart` does not store any historical test run logs, it merely updates its cached data from the previous run with data from the latest run. ### Running tests in random order -You can prompt `pytest-ranking` to run tests in random order, by setting the sum of `--rank-weight` option to 0, e.g., `--rank-weight=0-0-0`. +You can prompt `pytest-regsmart` to run tests in random order, by setting the sum of `--rank-weight` option to 0, e.g., `--rank-weight=0-0`. You can also set the seed used when running tests in random order, via setting an integer to the option `--rank-seed`. For example, the command below runs tests randomly with seed `1234`: ```bash -pytest --rank --rank-weight=0-0-0 --rank-seed=1234 +pytest --regsmart --rank-weight=0-0 --rank-seed=1234 ``` -By default, `pytest-ranking` uses `0` as the seed. +By default, `pytest-regsmart` uses `0` as the seed. ### Setting configurable options via config file You can always apply available options by adding them to the ``addopts`` setting in your [pytest.ini](https://docs.pytest.org/en/latest/reference/customize.html#configuration). For example, create `pytest.ini` in your codebase root folder as such: + ```ini [pytest] -addopts = --rank --rank-weight=0-1-0 --rank-hist-len=30 +addopts = --regsmart --rank-weight=0-1 --rank-hist-len=30 ``` + and run `pytest` on the command line. Alternatively, you can also create `pytest.ini` in your codebase root folder as such: + ```ini [pytest] -rank_weight=0-1-0 +rank_weight=0-1 rank_hist_len=30 ``` -and run `pytest --rank` on the command line. +and run `pytest --regsmart` on the command line. + +## How Regression Test Selection (RTS) works + +When `--regsmart` is used, `pytest-regsmart` runs a Regression Test Selection step before the tests are executed: + +1. **Compute the changed files.** It uses git to inspect the working tree against the **base** branch — the branch the current commit is headed to (its destination), never the currently checked-out branch: + - the base is resolved, in order, from the `GITHUB_BASE_REF` environment variable (set by GitHub Actions on pull requests), `refs/remotes/origin/HEAD`, a local `main`/`master`, and finally the upstream tracking branch of the current branch; + - the diff is `HEAD` vs the merge-base with that base, so every commit on a PR/branch is compared against its destination (e.g. `main`), not against itself; + - it collects both modified (`staged` + `unstaged`) and untracked files. If the repository has no commits yet, only untracked files are considered. + +2. **Build a dependency graph when changes exist.** If a diff is detected, [`pyan3`](https://pypi.org/project/pyan3/) parses every `*.py` file in the repository (excluding `.venv`, `venv`, `.git`, `__pycache__`, `dist`, `build`) and builds a dependency graph whose granularity follows `--diff-level`: a function-level call graph when `function` (default), or a module-level import graph when `file`. The graph is then inverted so that, for each node, it knows *which* other nodes depend on it. +3. **Propagate changes transitively.** A BFS traversal starts from the changed units — changed files at the `file` level, or the functions containing the changed lines at the `function` level — and walks through their dependents, collecting every test unit affected directly or indirectly: test files (files named `test_*.py` or `*_test.py`) or individual test functions, respectively. +4. **Filter the test suite.** At the `file` level, test items whose file is not in the selected set are removed; at the `function` level, only collected items matching a selected pytest nodeid are kept. Only affected tests are actually executed. + +If there is no diff since the baseline, `pytest-regsmart` skips both dependency-graph generation and test selection, reports a warning, and runs the full test suite. RTP still runs unless `--no-rank` is set; with both no diff and `--no-rank`, the plugin reports that it has no work to do. + +Because selection granularity follows `--diff-level`, the default `function` level narrows the selection down to the affected test functions, while `file` selects whole files and is therefore intentionally conservative: a change in one module selects every test file that transitively depends on it, which may include more tests than strictly necessary. See [Choosing the diff granularity](#choosing-the-diff-granularity). +## How Regression Test Prioritization (RTP) works -## Deployment +After selection, `pytest-regsmart` reorders the remaining tests so that failures are exposed sooner. Each test receives a priority score computed as the weighted sum of two heuristics: -`pytest-ranking` is easy to deploy into CI workflow, please see [deployment](./docs/DEPLOYMENT.md). +- **Faster tests first**, based on their recorded execution durations from previous runs; +- **Recently failed tests first**, based on how many runs have passed since each test's last failure. + +Weights are set with `--rank-weight`, normalized to sum 1 (default `1-0`, speed only), and scores are aggregated per group according to `--rank-level` (`put`, `function`, or `module`). Two special modes replace these heuristics: replaying a fixed order listed in a text file (`--rank-replay`) and random order (`--rank-weight=0-0`, seeded by `--rank-seed`). Tests carrying an `order` or `dependency` marker always run first, in their declared order. + +See [Usage](#usage) for all available options. + +## Deployment (old) + +`pytest-regsmart` is easy to deploy into CI workflow, please see [deployment](./DEPLOYMENT.md). + +## Local development + +Install the package and dependencies with uv: + +```bash +uv sync +``` +### Running tests + +```bash +uv run pytest -xv tests/ +``` + +To test across all supported Python versions, use [tox](https://tox.readthedocs.io/en/latest/): + +```bash +tox +``` + +To run a specific environment only (e.g. Python 3.12): + +```bash +tox -e py312 +``` + +To pass extra arguments to pytest via tox: + +```bash +tox -- -k "test_name" +``` ## Compatibility -`pytest-ranking` works with [test selection](https://docs.pytest.org/en/6.2.x/usage.html#specifying-tests-selecting-tests) and [parallelization](https://pypi.org/project/pytest-xdist). +`pytest-regsmart` works with [_pytest_ test filtering](https://docs.pytest.org/en/6.2.x/usage.html#specifying-tests-selecting-tests) and [parallelization](https://pypi.org/project/pytest-xdist). It also works with plugins for ordering tests, e.g., [pytest-order](https://pypi.org/project/pytest-order), [pytest-dependency](https://pypi.org/project/pytest-dependency) by running ordered tests first in their declared order. -Pytest options that order tests generally (e.g., [`--ff`](https://docs.pytest.org/en/stable/how-to/cache.html#usage)), or plugins that randomly order tests (e.g., [pytest-randomly](https://github.com/pytest-dev/pytest-randomly), [pytest-random-order](https://github.com/pytest-dev/pytest-random-order), [pytest-reverse](https://github.com/adamchainz/pytest-reverse)), can interfere with `pytest-ranking` as they use the same reordering hook. +Pytest options that order tests generally (e.g., [`--ff`](https://docs.pytest.org/en/stable/how-to/cache.html#usage)), or plugins that randomly order tests (e.g., [pytest-randomly](https://github.com/pytest-dev/pytest-randomly), [pytest-random-order](https://github.com/pytest-dev/pytest-random-order), [pytest-reverse](https://github.com/adamchainz/pytest-reverse)), can interfere with `pytest-regsmart` as they use the same reordering hook. +`pytest-regsmart` supports Python 3.8+. ## Reference #### Demo video A 5-minute demo video with walkthrough of `pytest-ranking`: [YouTube link](https://youtu.be/SrnkgTs3uok?feature=shared) +(pytest-regsmart: TBA) #### Bibtex citation + ``` @inproceedings{cheng2025pytest, title={{pytest-ranking: A Regression Test Prioritization Tool for Python}}, @@ -170,16 +260,17 @@ A 5-minute demo video with walkthrough of `pytest-ranking`: [YouTube link](https } ``` +(pytest-regsmart: TBA) ## Contributing -Contributions are very welcome. Tests can be run with [tox](https://tox.readthedocs.io/en/latest/). - +Contributions are very welcome. ## License -Distributed under the terms of the [MIT](http://opensource.org/licenses/MIT) license, `pytest-ranking` is free and open-source software. +Distributed under the terms of the [GNU General Public License v2.0 or later](https://www.gnu.org/licenses/old-licenses/gpl-2.0.html), `pytest-regsmart` is free and open-source software. +See [NOTICE.md](./NOTICE.md) for attribution of the incorporated third-party code (pytest-ranking under the MIT License and pyan3 under the GPLv2+). ## Issues -If you encounter any problems, please [file an issue](https://github.com/softwareTestingResearch/pytest-ranking/issues) or [pull request](https://github.com/softwareTestingResearch/pytest-ranking/pulls) along with a detailed description. +If you encounter any problems, please [file an issue](https://github.com/leticosta4/pytest-regsmart/issues) or [pull request](https://github.com/leticosta4/pytest-regsmart/pulls) along with a detailed description. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..63218f1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,68 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "pytest-regsmart" +version = "0.6.3" +description = "A Pytest plugin for faster fault detection via regression test prioritization and selection." +readme = "README.md" +license = "GPL-2.0-or-later" +license-files = ["LICENSE", "NOTICE.md"] +authors = [ + { name = "leticosta4", email = "leticiacostaoa@gmail.com" }, +] +maintainers = [ + { name = "leticosta4", email = "leticiacostaoa@gmail.com" }, +] +requires-python = ">=3.10" +dependencies = [ + "pytest>=9.0.3", + "numpy", + "pyan3>=1.2.0", + "gitpython>=3.1.58", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Framework :: Pytest", + "Intended Audience :: Developers", + "Topic :: Software Development :: Testing", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "Operating System :: OS Independent", + "License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)", +] +keywords = [ + "software testing", + "regression testing", + "test prioritization", + "test selection", + "pytest", +] + +[project.urls] +Homepage = "https://github.com/leticosta4/pytest-regsmart" + +[project.entry-points."pytest11"] +pytest_regsmart = "pytest_regsmart.plugin" + +[dependency-groups] +dev = [ + "tox", + "tox-uv", + "pytest-xdist", + "ruff", +] + +[tool.ruff] +line-length = 88 +target-version = "py310" + +[tool.ruff.lint] +extend-select = ["UP", "B"] diff --git a/run_test_local.sh b/run_test_local.sh index 6429ceb..87070e2 100755 --- a/run_test_local.sh +++ b/run_test_local.sh @@ -1,6 +1,4 @@ #!/bin/bash -pip install . -pytest -xv - -pip uninstall pytest-ranking +uv sync +uv run pytest -xv "$@" diff --git a/setup.py b/setup.py deleted file mode 100644 index 37178f5..0000000 --- a/setup.py +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env python - -import codecs -import os -from glob import glob -from os.path import basename, splitext - -from setuptools import find_packages, setup - - -def read(fname): - file_path = os.path.join(os.path.dirname(__file__), fname) - return codecs.open(file_path, encoding='utf-8').read() - - -setup( - name='pytest-ranking', - version='0.3.4', - author='softwareTestingResearch', - author_email='testingresearch4all@gmail.com', - maintainer='softwareTestingResearch', - maintainer_email='testingresearch4all@gmail.com', - license='MIT', - url='https://github.com/softwareTestingResearch/pytest-ranking', - description='A Pytest plugin for faster fault detection' - + ' via regression test prioritization', - long_description=read('README.md'), - long_description_content_type='text/markdown', - packages=find_packages('src'), - package_dir={'': 'src'}, - py_modules=[splitext(basename(path))[0] for path in glob('src/*.py')], - python_requires='>=3.5', - install_requires=[ - 'pytest>=7.4.3', - 'numpy', - ], - classifiers=[ - 'Development Status :: 4 - Beta', - 'Framework :: Pytest', - 'Intended Audience :: Developers', - 'Topic :: Software Development :: Testing', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - 'Programming Language :: Python :: 3.12', - 'Programming Language :: Python :: 3 :: Only', - 'Programming Language :: Python :: Implementation :: CPython', - 'Programming Language :: Python :: Implementation :: PyPy', - 'Operating System :: OS Independent', - 'License :: OSI Approved :: MIT License', - ], - keywords=[ - 'software testing', - 'regression testing', - 'test prioritization', - 'pytest', - ], - entry_points={ - 'pytest11': [ - 'pytest_ranking = pytest_ranking.plugin', - ], - }, -) diff --git a/src/pytest_ranking/change_tracker.py b/src/pytest_ranking/change_tracker.py deleted file mode 100644 index d40bfd0..0000000 --- a/src/pytest_ranking/change_tracker.py +++ /dev/null @@ -1,78 +0,0 @@ -from __future__ import annotations - -import glob -import hashlib -import os -import re -import time - -from _pytest.config import Config -from _pytest.nodes import Item - -from .const import DATA_DIR - - -def tokenize(string: str) -> list[str]: - return re.findall(r'[a-zA-Z0-9]+', string.lower()) - - -class changeTracker: - def __init__(self, pytest_config: Config) -> None: - self.pytest_config = pytest_config - self.delta = set() - self.num_delta_files = 0 - self.runtime = 0 - # Get data of the changed file set. - self.get_delta() - - def get_all_file_paths(self): - """Get all file paths in the codebase.""" - pattern = os.path.join(self.pytest_config.rootpath, "**/*.py") - file_paths = glob.glob(pattern, recursive=True) - return file_paths - - def get_hash(self, file_path): - """Compute hash for the file.""" - with open(file_path, "rb") as f: - return hashlib.sha1(f.read()).hexdigest() - - def get_delta(self) -> None: - """Compute hashes for all files, - get token set for files whose hashes differ or have not been seen, - those are the changed or new files since last run (delta). - Save the newest hashes for all files. - Update the number of files that were re-computed hashes. - """ - start_time = time.time() - file_paths = self.get_all_file_paths() - hashes = {path: self.get_hash(path) for path in file_paths} - - key = os.path.join(DATA_DIR, "file_hashes") - # Load file hashes since last run. - old_hashes = self.pytest_config.cache.get(key, {}) - # Save newest hashes anyway. - self.pytest_config.cache.set(key, hashes) - - # If hashes are computed for the first time, - # No need to get delta. - if old_hashes == {}: - self.runtime += time.time() - start_time - return - - # Get files that have new hashes since last run. - for path, hash in hashes.items(): - if path not in old_hashes or old_hashes[path] != hash: - self.delta = self.delta.union(tokenize(path)) - self.num_delta_files += 1 - self.runtime += time.time() - start_time - - def compute_test_suite_similarity(self, items: list[Item]) -> None: - """Compute and save similarity to changed files per test.""" - start_time = time.time() - ret = {} - for item in items: - test_tokens = set(tokenize(item.nodeid)) - ret[item.nodeid] = len(self.delta.intersection(set(test_tokens))) - key = os.path.join(DATA_DIR, "change_similarity") - self.pytest_config.cache.set(key, ret) - self.runtime += time.time() - start_time diff --git a/src/pytest_ranking/const.py b/src/pytest_ranking/const.py deleted file mode 100644 index 1a0db1e..0000000 --- a/src/pytest_ranking/const.py +++ /dev/null @@ -1,28 +0,0 @@ -from enum import Enum - -# ------ Constants ------ - -DATA_DIR = "pytest_ranking_data" - -# Default amount of historical test run results to store per test. -DEFAULT_HIST_LEN = 50 - -DEFAULT_WEIGHT = "1-0-0" - -DEFAULT_SEED = 0 - -DEFAULT_REPLAY = None - - -class LEVEL(str, Enum): - """The test group level at which the test suites are reordered. - Tests within each group follows the pytest default order. - https://docs.pytest.org/en/stable/reference/fixtures.html#fixtures - """ - PUT = "put" - FUNCTION = "function" - MODULE = "module" - DIR = "dir" - - -DEFAULT_LEVEL = LEVEL.PUT diff --git a/src/pytest_ranking/plugin.py b/src/pytest_ranking/plugin.py deleted file mode 100644 index a5f7afe..0000000 --- a/src/pytest_ranking/plugin.py +++ /dev/null @@ -1,417 +0,0 @@ -from __future__ import annotations - -import argparse -import os -import random -import textwrap -import time -from enum import Enum - -import numpy as np -import pytest -from _pytest.config import Config -from _pytest.config.argparsing import Parser -from _pytest.main import Session -from _pytest.nodes import Item -from _pytest.reports import TestReport -from _pytest.terminal import TerminalReporter - -from .change_tracker import changeTracker -from .const import (DATA_DIR, DEFAULT_HIST_LEN, DEFAULT_LEVEL, DEFAULT_REPLAY, - DEFAULT_SEED, DEFAULT_WEIGHT, LEVEL) -from .rank import get_ranking - -PLUGIN_HELP = textwrap.dedent("""\ -Run regression test prioritization for pytest test suite. -It re-orders execution of tests to expose test failure sooner. -""") - - -WEIGHT_HELP = textwrap.dedent("""\ -Set weights on different prioritization heuristics, -separated by hyphens `-`. -The sum of weights will be normalized to 1. -Higher weight means that heuristic will be favored. -Default value is 1-0-0. -""") - -HIST_LEN_HELP = textwrap.dedent("""\ -The maximum number of previous test runs -that can be recorded for a test since the test has failed. -Default value is 50 (must be integer). -""") - -SEED_HELP = textwrap.dedent("""\ -Seed when running tests in random order. -You can run random order via setting `--rank-weight=0-0-0` -Default value is 0. -""") - -LEVEL_HELP = textwrap.dedent(""" -The test group level at which the prioritization takes place. -Test items below the configured level follow pytest default order. -Score of a test group is the mean score over all tests in that group. -Default value is PUT. -""") - -REPLAY_HELP = textwrap.dedent(""" -Provide a text file where each line is a test ID. -pytest-ranking will run tests with the order defined in the file. -Default value is None. -""") - - -def pytest_addoption(parser: Parser) -> None: - group = parser.getgroup("rank", "pytest-ranking") - group._addoption( - "--rank", - action="store_true", - help=PLUGIN_HELP) - - group._addoption( - "--rank-level", - action="store", - type=level_type, - default=DEFAULT_LEVEL, - dest="rank_level", - help=LEVEL_HELP) - - group._addoption( - "--rank-weight", - action="store", - type=weight_type, - default=DEFAULT_WEIGHT, - dest="rank_weight", - help=WEIGHT_HELP) - - group._addoption( - "--rank-replay", - action="store", - type=replay_type, - default=DEFAULT_REPLAY, - dest="rank_replay", - help=REPLAY_HELP) - - group._addoption( - "--rank-hist-len", - action="store", - type=int, - dest="rank_hist_len", - default=DEFAULT_HIST_LEN, - help=HIST_LEN_HELP) - - group._addoption( - "--rank-seed", - action="store", - type=int, - dest="rank_seed", - default=DEFAULT_SEED, - help=SEED_HELP) - - parser.addini("rank_weight", WEIGHT_HELP, default=DEFAULT_WEIGHT) - parser.addini("rank_replay", REPLAY_HELP, default=DEFAULT_REPLAY) - parser.addini("rank_level", LEVEL_HELP, default=DEFAULT_LEVEL) - parser.addini("rank_hist_len", HIST_LEN_HELP, default=DEFAULT_HIST_LEN) - parser.addini("rank_seed", SEED_HELP, default=DEFAULT_SEED) - - -def weight_type(string: str) -> str: - """Check weight format.""" - if string == DEFAULT_WEIGHT: - return string - try: - weights = string.split("-") - assert len(weights) == 3 - weights = [float(w) for w in weights] - return string - except (AssertionError, ValueError): - raise argparse.ArgumentTypeError( - "Cannot parse input for `--rank-weight`." - + "Valid examples: 1-0-0, 0.4-0.2-0.2, and 2-7-1." - ) - - -def level_type(string: str) -> str: - "Check level format." - if string == DEFAULT_LEVEL: - return string - try: - valid_levels = [i.value for i in LEVEL] - assert string in valid_levels - return string - except AssertionError: - raise argparse.ArgumentTypeError( - "Invalid input for `--rank-level`." - + " Please run `pytest --help` for instruction." - ) - - -def replay_type(string: str) -> str: - "Check replay file format." - if string == DEFAULT_REPLAY: - return string - try: - with open(string) as f: - _ = f.readlines() - return string - except Exception: - raise argparse.ArgumentTypeError( - "File provided to `--rank-replay` cannot be read." - + " Please run `pytest --help` for instruction." - ) - - -def min_max_normalization(x: list[float]) -> np.ndarray: - x = np.array(x) - x_range = (np.max(x) - np.min(x)) - x = (x - np.min(x)) / x_range if x_range else np.zeros(len(x)) - return x - - -class RTPRunner: - """Plugin class.""" - def __init__(self, config: Config) -> None: - self.config = config - self.test_reports = [] - self.log = {} - self.weights = self.parse_rtp_weights() - self.level = self.parse_rtp_level() - self.replay_file = self.parse_replay() - self.hist_len = self.parse_hist_len() - self.seed = self.parse_seed() - self.chgtracker = changeTracker(config) - - def parse_rtp_weights(self) -> list[float]: - """Get weights, non-default CLI overrides ini file input.""" - weights = self.config.getoption("--rank-weight") - if weights == DEFAULT_WEIGHT: - ini_val = self.config.getini("rank_weight") - weights = ini_val if ini_val else weights - - weights = weights.split("-") - weights = [float(w) for w in weights] - weight_sum = sum(weights) - if weight_sum == 0: - return [0, 0, 0] - weights = [w_i / weight_sum for w_i in weights] - return weights - - def parse_rtp_level(self) -> Enum: - """Get test group level, non-default CLI overrides ini file input.""" - level = self.config.getoption("--rank-level") - if level == DEFAULT_LEVEL: - ini_val = self.config.getini("rank_level") - level = ini_val if ini_val else level - return level - - def parse_replay(self) -> str: - """Get replay file, non-default CLI overrides ini file input.""" - replay_file = self.config.getoption("--rank-replay") - if replay_file == DEFAULT_REPLAY: - ini_val = self.config.getini("rank_replay") - replay_file = ini_val if ini_val else replay_file - return replay_file - - def parse_hist_len(self) -> int: - """Get history length, non-default CLI overrides ini file input.""" - hist_len = self.config.getoption("--rank-hist-len") - if hist_len == DEFAULT_HIST_LEN: - ini_val = self.config.getini("rank_hist_len") - hist_len = ini_val if ini_val else hist_len - return int(hist_len) - - def parse_seed(self) -> int: - """Get random seed, non-default CLI overrides ini file input.""" - rand_seed = self.config.getoption("--rank-seed") - if rand_seed == DEFAULT_SEED: - ini_val = self.config.getini("rank_seed") - rand_seed = ini_val if ini_val else rand_seed - return int(rand_seed) - - def load_feature( - self, - feature_name: str, - items: list[Item], - reverse: bool) -> list[float]: - """ - Load and normalize test-wise feature data for the current test suite, - - reverse: True if originally smaller value means higher priority. - """ - # Load original data. - key = os.path.join(DATA_DIR, feature_name) - values = self.config.cache.get(key, {}) - # 0 if not exist: prioritizes newly selected/created tests. - values = [values.get(item.nodeid, 0) for item in items] - # Normalize to [0, 1] range. - values = min_max_normalization(values) - # If smaller values is better, transform to larger is better. - if reverse: - values = 1 - values - return values.tolist() - - def run_rtp(self, items: list[Item]) -> None: - """Run test prioritization algorithm.""" - # Get pytest default order. - init_order = {item.nodeid: i for i, item in enumerate(items)} - # Load code change features. - self.chgtracker.compute_test_suite_similarity(items) - num_delta_file = self.chgtracker.num_delta_files - compute_time = self.chgtracker.runtime - self.log["Number of changed Python files"] = num_delta_file - self.log["Time to compute test-change similarity (s)"] = compute_time - - # Start reordering. - start_time = time.time() - - # Get priority score per test, prioritized tests have LOWER scores. - scores = {} - if self.replay_file and os.path.exists(self.replay_file): - # Run tests in the order specified in the replay file. - with open(self.replay_file) as f: - test_list = [x.strip() for x in f.readlines()] - scores = {x: i for i, x in enumerate(test_list)} - elif self.weights == [0, 0, 0]: - # Run tests in random order. - # Pre-sort so that all workers gets the same order in pytest-xdist. - # https://pytest-xdist.readthedocs.io/en/stable/known-limitations.html - items.sort(key=lambda item: item.nodeid) - random.seed(self.seed) - scores = {item.nodeid: random.random() for item in items} - else: - # Prioritize by test features. - w_time, w_fail, w_rel = self.weights - h_time = self.load_feature("last_durations", items, True) - h_fail = self.load_feature("num_runs_since_fail", items, True) - h_rel = self.load_feature("change_similarity", items, False) - - def hybrid(i): - # Linearly combine different heurisic values. - # The higher, the earlier the test will be run. - s = h_time[i] * w_time + h_fail[i] * w_fail + h_rel[i] * w_rel - return -s - - scores = {item.nodeid: hybrid(i) for i, item in enumerate(items)} - - rank = get_ranking(scores, self.level, init_order) - - # Respect tests with declared order dependency (OD). - od_items: list[Item] = [] - nod_items: list[Item] = [] - for item in items: - # For https://github.com/pytest-dev/pytest-order - # For https://github.com/RKrahl/pytest-dependency - if ( - item.get_closest_marker('order') - or item.get_closest_marker('dependency') - ): - od_items.append(item) - else: - nod_items.append(item) - # Only reorder tests with no declared OD. - nod_items.sort( - key=lambda item: ( - rank.get(item.nodeid, 0), init_order[item.nodeid] - ) - ) - # Run OD tests first. - items[:] = od_items + nod_items - - # Record reordering runtime. - self.log["Time to reorder tests (s)"] = time.time() - start_time - - def pytest_runtest_logreport(self, report: TestReport) -> None: - """Record test result of each executed test.""" - if not report.skipped and report.when == "call": - # No skipped: only look at the executed test. - # `call`: only look at called duration (ignore setup/teardown). - self.test_reports.append(report) - - def pytest_report_header(self, config: Config) -> str: - """Report plugin configurations before test session starts.""" - # Report nothing if the plugin is not enabled. - if not self.config.getoption("--rank"): - return None - weight = self.config.getoption("--rank-weight") - replay = self.config.getoption("--rank-replay") - level = self.config.getoption("--rank-level") - hist_len = self.config.getoption("--rank-hist-len") - random_seed = self.config.getoption("--rank-seed") - report = [ - f"Using --rank-weight={weight}", - f"Using --rank-level={level}", - f"Using --rank-hist-len={hist_len}", - f"Using --rank-seed={random_seed}", - f"Using --rank-replay={replay}", - ] - return "\n".join(report) - - @pytest.hookimpl(trylast=True) - def pytest_collection_modifyitems(self, items: list[Item]) -> None: - if self.config.getoption("--rank"): - if self.replay_file and self.weights == [0, 0, 0]: - raise argparse.ArgumentTypeError( - "--rank-replay cannot be used together with random order." - ) - self.run_rtp(items) - - def pytest_sessionfinish(self, session: Session, exitstatus: int) -> None: - start_time = time.time() - compute_test_features(self.config, self.test_reports, self.hist_len) - # Record feature collection runtime. - self.log["Time to collect test features (s)"] = ( - time.time() - start_time - ) - - def pytest_terminal_summary( - self, - terminalreporter: TerminalReporter, - exitstatus: int, - config: Config) -> None: - """Report plugin runtime when it is enabled.""" - if self.config.getoption("--rank"): - tr = terminalreporter - tr._tw.sep("=", "pytest-ranking summary info") - for k, v in self.log.items(): - tr._tw.line(f"{k}: {v}") - pass - - -def compute_test_features( - config: Config, - test_reports: list[TestReport], - hist_len: int) -> None: - # Get the most recent execution time per test. - key = os.path.join(DATA_DIR, "last_durations") - last_durations = config.cache.get(key, {}) - for report in test_reports: - nodeid = report.nodeid - duration = report.duration - last_durations[nodeid] = round(duration, 3) - config.cache.set(key, last_durations) - - # Get the number of runs since its last failure per test. - key = os.path.join(DATA_DIR, "num_runs_since_fail") - num_runs_since_fail = config.cache.get(key, {}) - for report in test_reports: - nodeid = report.nodeid - outcome = report.outcome - if outcome == "failed": - num_runs_since_fail[nodeid] = 0 - else: - # Cap within history limit. - num_runs_since_fail[nodeid] = min( - hist_len, - num_runs_since_fail.get(nodeid, 0) + 1 - ) - config.cache.set(key, num_runs_since_fail) - - -@pytest.hookimpl(trylast=True) -def pytest_configure(config: Config) -> None: - """ - Called when pytest is about to start: - - Create a cache folder (if not exist) to store test features for RTP - - Register this plugin - """ - runner = RTPRunner(config) - config.pluginmanager.register(runner) diff --git a/src/pytest_ranking/rank.py b/src/pytest_ranking/rank.py deleted file mode 100644 index df27efa..0000000 --- a/src/pytest_ranking/rank.py +++ /dev/null @@ -1,58 +0,0 @@ -import collections -import os -from enum import Enum - -import numpy as np - -from .const import LEVEL - - -def get_test_group(nodeid: str, level: Enum) -> str: - """Get test group of a PUT at different level. - - Given a put: folder/testfile.py::TestClass::testmethod[param1] - - function: folder/testfile.py::TestClass:testmethod - - module: folder/testfile.py - - dir: folder - - Otherwise, treat each PUT as a unique test group. - """ - test_without_param = nodeid.split("[")[0] - test_file_path = test_without_param.split("::")[0] - test_folder = os.path.dirname(test_file_path) - if level == LEVEL.FUNCTION: - return test_without_param - elif level == LEVEL.MODULE: - return test_file_path - elif level == LEVEL.DIR: - return test_folder - else: - return nodeid - - -def get_ranking(scores: dict, level: Enum, init_order: dict) -> dict: - """Return ranking of tests by test nodeid. - - scores: mapping between test nodeid to its score - """ - # Get test groups. - tests = [] - for nodeid, score in scores.items(): - group = get_test_group(nodeid, level) - tests.append([nodeid, score, group]) - # Get aggregated score (mean) per group. - group_scores = collections.defaultdict(list) - for _, score, group in tests: - group_scores[group].append(score) - agg_group_scores = { - group: np.mean(score_list) - for group, score_list in group_scores.items() - } - # Sort tests by its aggregated group score. - # Break tie by default order. - tests.sort( - key=lambda x: (agg_group_scores[x[2]], init_order[x[0]]), - ) - return { - nodeid: rank - for rank, (nodeid, score, group) in enumerate(tests) - } diff --git a/src/pytest_regsmart/__init__.py b/src/pytest_regsmart/__init__.py new file mode 100644 index 0000000..43c4ab0 --- /dev/null +++ b/src/pytest_regsmart/__init__.py @@ -0,0 +1 @@ +__version__ = "0.6.1" diff --git a/src/pytest_regsmart/const.py b/src/pytest_regsmart/const.py new file mode 100644 index 0000000..c81df76 --- /dev/null +++ b/src/pytest_regsmart/const.py @@ -0,0 +1,43 @@ +import re +from enum import Enum + +# ------ Constants ------ + +DATA_DIR = "pytest_ranked_selection_data" + +# Default amount of historical test run results to store per test. +DEFAULT_HIST_LEN = 50 + +DEFAULT_WEIGHT = "1-0" + +DEFAULT_SEED = 0 + +DEFAULT_REPLAY = None + + +class RANK_LEVEL(str, Enum): + """The test group level at which the test suites are reordered. + Tests within each group follows the pytest default order. + https://docs.pytest.org/en/stable/reference/fixtures.html#fixtures + """ + PUT = "put" + FUNCTION = "function" + MODULE = "module" + + def __str__(self) -> str: + return self.value + +DEFAULT_RANK_LEVEL = RANK_LEVEL.PUT + + +class DIFF_LEVEL(str, Enum): + """The level at which the diff is identified.""" + FUNCTION = "function" + FILE = "file" + + def __str__(self) -> str: + return self.value + +DEFAULT_DIFF_LEVEL = DIFF_LEVEL.FUNCTION + +DIFF_HUNK_HEADER = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@") diff --git a/src/pytest_regsmart/extractor.py b/src/pytest_regsmart/extractor.py new file mode 100644 index 0000000..a57e8f4 --- /dev/null +++ b/src/pytest_regsmart/extractor.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import os +import time + +from _pytest.config import Config +from _pytest.reports import TestReport + +from .const import DATA_DIR +from .ranking.rank_args import min_max_normalization + + +def load_feature( + config: Config, + feature_name: str, + items: list, + reverse: bool, +) -> list[float]: + """Load and normalize test-wise feature data from cache.""" + key = os.path.join(DATA_DIR, feature_name) + values = config.cache.get(key, {}) + values = [values.get(item.nodeid, 0) for item in items] + values = min_max_normalization(values) + if reverse: + values = 1 - values + return values.tolist() + + +def compute_test_features( + config: Config, + test_reports: list[TestReport], + hist_len: int, + log_dict: dict | None = None, +) -> None: + """Persist last_durations and num_runs_since_fail to cache.""" + start_time = time.time() + key = os.path.join(DATA_DIR, "last_durations") + last_durations = config.cache.get(key, {}) + for report in test_reports: + last_durations[report.nodeid] = round(report.duration, 3) + config.cache.set(key, last_durations) + + key = os.path.join(DATA_DIR, "num_runs_since_fail") + num_runs_since_fail = config.cache.get(key, {}) + for report in test_reports: + if report.outcome == "failed": + num_runs_since_fail[report.nodeid] = 0 + else: + num_runs_since_fail[report.nodeid] = min( + hist_len, + num_runs_since_fail.get(report.nodeid, 0) + 1, + ) + config.cache.set(key, num_runs_since_fail) + + if log_dict is not None: + log_dict["Time to collect test features (s)"] = time.time() - start_time diff --git a/src/pytest_regsmart/help_strings.py b/src/pytest_regsmart/help_strings.py new file mode 100644 index 0000000..cc48b6d --- /dev/null +++ b/src/pytest_regsmart/help_strings.py @@ -0,0 +1,49 @@ +import textwrap + +PLUGIN_HELP = textwrap.dedent("""\ +Run regression test prioritization for pytest test suite. +It re-orders execution of tests to expose test failure sooner. +""") + +WEIGHT_HELP = textwrap.dedent("""\ +Set weights on different prioritization heuristics, +separated by hyphens `-`. +The sum of weights will be normalized to 1. +Higher weight means that heuristic will be favored. +Default value is 1-0. +""") + +HIST_LEN_HELP = textwrap.dedent("""\ +The maximum number of previous test runs +that can be recorded for a test since the test has failed. +Default value is 50 (must be integer). +""") + +SEED_HELP = textwrap.dedent("""\ +Seed when running tests in random order. +You can run random order via setting `--rank-weight=0-0` +Default value is 0. +""") + +RANK_LEVEL_HELP = textwrap.dedent(""" +The test group level at which the prioritization takes place. +Test items below the configured level follow pytest default order. +Score of a test group is the mean score over all tests in that group. +Default value is PUT. +""") + +REPLAY_HELP = textwrap.dedent(""" +Provide a text file where each line is a test ID. +pytest-regsmart will run tests with the order defined in the file. +Default value is None. +""") ## gotta see what to do about this + +NO_RANK_HELP = textwrap.dedent(""" +Boolean flag to disable ranking option (RTP); cannot be used with other ranking flags. +When selected, tests will run in pytest default order. +""") #need to adapt this ending to use the RTS later + +DIFF_LEVEL_HELP = textwrap.dedent(""" +The levels at which the diff will be identified using Git and the level for the dependency graph. +Possible values are: file and function (default). +""") diff --git a/src/pytest_regsmart/monitor.py b/src/pytest_regsmart/monitor.py new file mode 100644 index 0000000..a355cce --- /dev/null +++ b/src/pytest_regsmart/monitor.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from _pytest.reports import TestReport + + +class Monitor: + """Collects test run results.""" + + def __init__(self) -> None: + self.test_reports: list[TestReport] = [] + + def pytest_runtest_logreport(self, report: TestReport) -> None: + if not report.skipped and report.when == "call": + self.test_reports.append(report) diff --git a/src/pytest_regsmart/plugin.py b/src/pytest_regsmart/plugin.py new file mode 100644 index 0000000..90b99d7 --- /dev/null +++ b/src/pytest_regsmart/plugin.py @@ -0,0 +1,230 @@ +from __future__ import annotations + +import pytest +from _pytest.config import Config +from _pytest.config.argparsing import Parser +from _pytest.main import Session +from _pytest.nodes import Item +from _pytest.reports import TestReport +from _pytest.terminal import TerminalReporter + +from . import extractor +from . import reporter as reporter_mod +from .const import ( + DEFAULT_DIFF_LEVEL, + DEFAULT_HIST_LEN, + DEFAULT_RANK_LEVEL, + DEFAULT_REPLAY, + DEFAULT_SEED, + DEFAULT_WEIGHT, + DIFF_LEVEL, +) +from .help_strings import ( + DIFF_LEVEL_HELP, + HIST_LEN_HELP, + NO_RANK_HELP, + PLUGIN_HELP, + RANK_LEVEL_HELP, + REPLAY_HELP, + SEED_HELP, + WEIGHT_HELP, +) +from .monitor import Monitor +from .ranking import rank_args, ranker +from .selection import git_manager, selection_args, selector + + +def _validate_options(config: Config) -> None: + if config.getoption("--regsmart") and config.getoption("--no-rank"): + for arg in config.invocation_params.args: + if arg.startswith("--rank-"): + raise pytest.UsageError( + "--no-rank cannot be used together with other ranking flags. It excludes RTP." + ) + + if config.getoption("--regsmart") and not git_manager.verify_git_repo(): + raise pytest.UsageError( + "--regsmart requires a git repository." + ) + + if config.getoption("--rank-replay") and config.getoption("--rank-weight") == "0-0": + raise pytest.UsageError( + "--rank-replay cannot be used together with random order." + ) + + +def _select_pytest_items_for_rtp(self, items, selected_nodes) -> None: + items[:] = ( + [item for item in items if item.nodeid.split("::")[0] in selected_nodes] if self.diff_level == DIFF_LEVEL.FILE + else [ + item + for item in items + if any( + item.nodeid == selected_node + or item.nodeid.startswith(f"{selected_node}[") + for selected_node in selected_nodes + ) + ] + ) + + +def pytest_addoption(parser: Parser) -> None: + group = parser.getgroup("regsmart", "pytest-regsmart") + group._addoption( + "--regsmart", #was the old --rank flag that activated the pytest-ranking default + action="store_true", + help=PLUGIN_HELP) + + group.addoption( + "--diff-level", + action="store", + type=selection_args.level_type, + default=DEFAULT_DIFF_LEVEL, + dest="diff_level", + help=DIFF_LEVEL_HELP) + + group._addoption( + "--no-rank", + action="store_true", + default=False, + help=NO_RANK_HELP) + + group._addoption( + "--rank-level", + action="store", + type=rank_args.level_type, + default=DEFAULT_RANK_LEVEL, + dest="rank_level", + help=RANK_LEVEL_HELP) + + group._addoption( + "--rank-weight", + action="store", + type=rank_args.weight_type, + default=DEFAULT_WEIGHT, + dest="rank_weight", + help=WEIGHT_HELP) + + group._addoption( + "--rank-replay", + action="store", + type=rank_args.replay_type, + default=DEFAULT_REPLAY, + dest="rank_replay", + help=REPLAY_HELP) + + group._addoption( + "--rank-hist-len", + action="store", + type=int, + dest="rank_hist_len", + default=DEFAULT_HIST_LEN, + help=HIST_LEN_HELP) + + group._addoption( + "--rank-seed", + action="store", + type=int, + dest="rank_seed", + default=DEFAULT_SEED, + help=SEED_HELP) + + parser.addini("diff_level", DIFF_LEVEL_HELP, default=DEFAULT_DIFF_LEVEL.value) + parser.addini("no_rank", NO_RANK_HELP, type="bool", default=False) + parser.addini("rank_weight", WEIGHT_HELP, default=DEFAULT_WEIGHT) + parser.addini("rank_replay", REPLAY_HELP, default=DEFAULT_REPLAY) + parser.addini("rank_level", RANK_LEVEL_HELP, default=DEFAULT_RANK_LEVEL.value) + parser.addini("rank_hist_len", HIST_LEN_HELP, default=DEFAULT_HIST_LEN) + parser.addini("rank_seed", SEED_HELP, default=DEFAULT_SEED) + + +class PluginRunner: + """Plugin class.""" + def __init__(self, config: Config) -> None: + self.config = config + self.log = {} + self.warnings: list[str] = [] + self.monitor = Monitor() + + self.branch = str | None + self.diff_level = selection_args.parse_diff_level(config) + self.no_rank = rank_args.parse_no_rank(config) + self.weights = rank_args.parse_rtp_weights(config) + self.level = rank_args.parse_rtp_level(config) + self.replay_file = rank_args.parse_replay(config) + self.hist_len = rank_args.parse_hist_len(config) + self.seed = rank_args.parse_seed(config) + + @pytest.hookimpl(trylast=True) + def pytest_collection_modifyitems(self, items: list[Item]) -> None: + if not self.config.getoption("--regsmart"): + return + + selection = selector.run_rts(level=self.diff_level, log_dict=self.log) + self.branch = selection.branch + + if selection.no_merge_base: + self.warnings.append( + f"No shared history with base branch '{selection.branch}': regression test selection was skipped. The full suite will run. The value set for '--diff-level' was ignored." + ) + elif not selection.has_diff: #maybe i should separate return items though + self.warnings.append( + "No diff detected: regression test selection was skipped. The value set for '--diff-level' was ignored." + ) + if self.no_rank: + self.warnings.append( #maybe should raise an usageError + "No diff detected and --no-rank enabled: pytest-regsmart is not doing anything." + ) + elif selection.full_run: + self.warnings.append( + "conftest.py changed: regression test selection was skipped. The full suite will run. The value set for '--diff-level' was ignored." + ) + if self.no_rank: + self.warnings.append( + "conftest.py changed and --no-rank enabled: pytest-regsmart is not doing anything." + ) + + if selection.affected_tests: + selected_nodes = set(selection.affected_tests) + + _select_pytest_items_for_rtp(self, items, selected_nodes) + + if not self.no_rank: + ranker.run_rtp( + items, self.level, self.weights, + self.replay_file, self.seed, self.log, + lambda feature_name, items, reverse: + extractor.load_feature(self.config, feature_name, items, reverse), + ) + + + def pytest_runtest_logreport(self, report: TestReport) -> None: + self.monitor.pytest_runtest_logreport(report) + + + def pytest_report_header(self, config: Config) -> str | None: + return reporter_mod.pytest_report_header(config) + + def pytest_sessionfinish(self, session: Session, exitstatus: int) -> None: + extractor.compute_test_features( + self.config, self.monitor.test_reports, self.hist_len, self.log, + ) + + + def pytest_terminal_summary( + self, + terminalreporter: TerminalReporter, + exitstatus: int, + config: Config) -> None: + if self.config.getoption("--regsmart"): + reporter_mod.pytest_terminal_summary( + terminalreporter, self.log, self.warnings, branch=self.branch, + ) + + +@pytest.hookimpl(trylast=True) +def pytest_configure(config: Config) -> None: + _validate_options(config) + + runner = PluginRunner(config) + config.pluginmanager.register(runner) diff --git a/src/pytest_ranking/__init__.py b/src/pytest_regsmart/ranking/__init__.py similarity index 100% rename from src/pytest_ranking/__init__.py rename to src/pytest_regsmart/ranking/__init__.py diff --git a/src/pytest_regsmart/ranking/rank_args.py b/src/pytest_regsmart/ranking/rank_args.py new file mode 100644 index 0000000..4445429 --- /dev/null +++ b/src/pytest_regsmart/ranking/rank_args.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import argparse + +import numpy as np + +from ..const import ( + DEFAULT_HIST_LEN, + DEFAULT_RANK_LEVEL, + DEFAULT_REPLAY, + DEFAULT_SEED, + DEFAULT_WEIGHT, + RANK_LEVEL, +) +from ..utils import _resolve_ini_value + + +def weight_type(string: str) -> str: + """Check weight format.""" + if string == DEFAULT_WEIGHT: + return string + try: + weights = string.split("-") + assert len(weights) == 2 + weights = [float(w) for w in weights] + return string + except (AssertionError, ValueError): + raise argparse.ArgumentTypeError( + "Cannot parse input for `--rank-weight`." + + "Valid examples: 1-0, 0.4-0.2, and 2-7." + ) from None + + +def level_type(string: str) -> str: + "Check level format." + if string == DEFAULT_RANK_LEVEL: + return string + try: + valid_levels = [i.value for i in RANK_LEVEL] + assert string in valid_levels + return string + except AssertionError: + valid = ", ".join(item.value for item in RANK_LEVEL) + raise argparse.ArgumentTypeError( + f"Invalid input for `--rank-level`: '{string}'. Valid values: {valid}." + ) from None + + +def replay_type(string: str) -> str: + "Check replay file format." + if string == DEFAULT_REPLAY: + return string + try: + with open(string) as f: + _ = f.readlines() + return string + except Exception: # noqa: BLE001 + raise argparse.ArgumentTypeError( + "File provided to `--rank-replay` cannot be read." + + " Please run `pytest --help` for instruction." + ) from None + + +def hist_len_type(string) -> int: + "Check history length format." + try: + return int(string) + except (TypeError, ValueError): + raise argparse.ArgumentTypeError( + f"Invalid input for `--rank-hist-len`: '{string}'. It must be an integer." + ) from None + + +def seed_type(string) -> int: + "Check seed format." + try: + return int(string) + except (TypeError, ValueError): + raise argparse.ArgumentTypeError( + f"Invalid input for `--rank-seed`: '{string}'. It must be an integer." + ) from None + + +def min_max_normalization(x: list[float]) -> np.ndarray: + x = np.array(x) + if x.size == 0: + return x + x_range = (np.max(x) - np.min(x)) + x = (x - np.min(x)) / x_range if x_range else np.zeros(len(x)) + return x + + +def parse_rtp_weights(config) -> list[float]: + """Get weights, non-default CLI overrides ini file input.""" + weights = _resolve_ini_value( + config, + cli_opt="--rank-weight", + default=DEFAULT_WEIGHT, + ini_key="rank_weight", + type_fn=weight_type, + ) + + weights = [float(w) for w in weights.split("-")] + weight_sum = sum(weights) + if weight_sum == 0: + return [0, 0] + return [w_i / weight_sum for w_i in weights] + + +def parse_rtp_level(config) -> str: + """Get test group level, non-default CLI overrides ini file input.""" + return _resolve_ini_value( + config, + cli_opt="--rank-level", + default=DEFAULT_RANK_LEVEL, + ini_key="rank_level", + type_fn=level_type, + ) + + +def parse_replay(config) -> str | None: + """Get replay file, non-default CLI overrides ini file input.""" + return _resolve_ini_value( + config, + cli_opt="--rank-replay", + default=DEFAULT_REPLAY, + ini_key="rank_replay", + type_fn=replay_type, + ) + + +def parse_hist_len(config) -> int: + """Get history length, non-default CLI overrides ini file input.""" + return _resolve_ini_value( + config, + cli_opt="--rank-hist-len", + default=DEFAULT_HIST_LEN, + ini_key="rank_hist_len", + type_fn=hist_len_type, + ) + + +def parse_seed(config) -> int: + """Get random seed, non-default CLI overrides ini file input.""" + return _resolve_ini_value( + config, + cli_opt="--rank-seed", + default=DEFAULT_SEED, + ini_key="rank_seed", + type_fn=seed_type, + ) + + +def parse_no_rank(config) -> bool: + """Get no-rank option, non-default CLI overrides ini file input.""" + no_rtp = config.getoption("--no-rank") + if not no_rtp: + return config.getini("no_rank") + return True diff --git a/src/pytest_regsmart/ranking/ranker.py b/src/pytest_regsmart/ranking/ranker.py new file mode 100644 index 0000000..bbf820d --- /dev/null +++ b/src/pytest_regsmart/ranking/ranker.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import collections +import os +import random +import time + +import numpy as np + +from ..const import RANK_LEVEL + + +def get_test_group(nodeid: str, level: RANK_LEVEL) -> str: + """Get test group at different granularity levels. + + Given: folder/testfile.py::TestClass::testmethod[param1] + - function: folder/testfile.py::TestClass::testmethod + - module: folder/testfile.py + + Otherwise, treat each PUT as a unique test group. + """ + test_without_param = nodeid.split("[")[0] + test_file_path = test_without_param.split("::")[0] + if level == RANK_LEVEL.FUNCTION: + return test_without_param + elif level == RANK_LEVEL.MODULE: + return test_file_path + else: + return nodeid + + +def get_ranking(scores: dict, level: RANK_LEVEL, init_order: dict) -> dict: + """Aggregate scores per test group, sort, return {nodeid: rank}.""" + tests = [] + for nodeid, score in scores.items(): + group = get_test_group(nodeid, level) + tests.append([nodeid, score, group]) + + group_scores = collections.defaultdict(list) + for _, score, group in tests: + group_scores[group].append(score) + agg_group_scores = { + group: np.mean(score_list) + for group, score_list in group_scores.items() + } + + tests.sort( + key=lambda x: (agg_group_scores[x[2]], init_order[x[0]]), + ) + return { + nodeid: rank + for rank, (nodeid, _, _) in enumerate(tests) + } + + +def run_rtp( + items: list, + level: RANK_LEVEL, + weights: list[float], + replay_file: str | None, + seed: int, + log_dict: dict, + load_feature_fn, +) -> None: + """Orchestrates the ranking: replay, random, or hybrid.""" + init_order = {item.nodeid: i for i, item in enumerate(items)} + start_time = time.time() + + scores: dict = {} + if replay_file and os.path.exists(replay_file): + with open(replay_file) as f: + test_list = [x.strip() for x in f] + scores = {x: i for i, x in enumerate(test_list)} + elif weights == [0, 0]: + items.sort(key=lambda item: item.nodeid) + random.seed(seed) + scores = {item.nodeid: random.random() for item in items} + else: + w_time, w_fail = weights + h_time = load_feature_fn("last_durations", items, True) + h_fail = load_feature_fn("num_runs_since_fail", items, True) + + scores = { + item.nodeid: -( + h_time[i] * w_time + h_fail[i] * w_fail + ) + for i, item in enumerate(items) + } + + rank = get_ranking(scores, level, init_order) + + od_items = [ + item for item in items + if item.get_closest_marker("order") + or item.get_closest_marker("dependency") + ] + nod_items = [ + item for item in items + if item not in od_items + ] + nod_items.sort( + key=lambda item: ( + rank.get(item.nodeid, 0), init_order[item.nodeid] + ) + ) + items[:] = od_items + nod_items + + log_dict["Time to run the regression test prioritization (s)"] = time.time() - start_time diff --git a/src/pytest_regsmart/reporter.py b/src/pytest_regsmart/reporter.py new file mode 100644 index 0000000..04354fe --- /dev/null +++ b/src/pytest_regsmart/reporter.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from _pytest.config import Config +from _pytest.terminal import TerminalReporter + +from .ranking.rank_args import parse_no_rank + + +def pytest_report_header(config: Config) -> str | None: + """Report plugin configurations before test session starts.""" + + if not config.getoption("--regsmart"): + return None + + if parse_no_rank(config): + return [ + "\nStarting RTS (Regression Test Selection)", + "Using --no-rank (RTP disabled).", + ] + + diff_level = config.getoption("--diff-level") + weight = config.getoption("--rank-weight") + replay = config.getoption("--rank-replay") + rank_level = config.getoption("--rank-level") + hist_len = config.getoption("--rank-hist-len") + random_seed = config.getoption("--rank-seed") + return [ + "\nStarting Smart Regression Test Management (RTS + RTP)", + ] + [ + f"Using --diff-level={diff_level}", + f"Using --rank-weight={weight}", + f"Using --rank-level={rank_level}", + f"Using --rank-hist-len={hist_len}", + f"Using --rank-seed={random_seed}", + f"Using --rank-replay={replay}", + ] + + +def pytest_terminal_summary( + terminalreporter: TerminalReporter, + log_dict: dict, + warnings: tuple[str, ...] = (), + branch: str = "", +) -> None: + """Report plugin runtime when it is enabled.""" + tr = terminalreporter + tr._tw.sep("=", "pytest-regsmart summary info") + if branch: + tr._tw.line(f"Default branch used for comparison: {branch}") + for w in warnings: + tr._tw.line(f"WARNING: {w}") + for k, v in log_dict.items(): + tr._tw.line(f"{k}: {v*1000:.2f} ms ({v} s)") diff --git a/src/pytest_regsmart/selection/__init__.py b/src/pytest_regsmart/selection/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/pytest_regsmart/selection/deps_graph.py b/src/pytest_regsmart/selection/deps_graph.py new file mode 100644 index 0000000..d259a4c --- /dev/null +++ b/src/pytest_regsmart/selection/deps_graph.py @@ -0,0 +1,230 @@ +from __future__ import annotations + +import logging +import os +from collections import defaultdict +from dataclasses import dataclass, field +from typing import TypeAlias + +from pyan.analyzer import CallGraphVisitor +from pyan.modvis import ImportVisitor +from pyan.node import Flavor + +from pytest_regsmart.const import DEFAULT_DIFF_LEVEL, DIFF_LEVEL + +from ..utils import _find_py_files +from .git_manager import resolve_repo + +#initially I'll try the simplest form using only files; functions are not needed yet +#use the pyan3 API, not the CLI + +_KEPT_FLAVORS = { + Flavor.FUNCTION, + Flavor.METHOD, + Flavor.CLASSMETHOD, + Flavor.STATICMETHOD, + Flavor.CLASS, +} #only functions, methods and classes on the fucntion callgraph (classes because of internal methods that can be desconsidered in git diff) + + +@dataclass(frozen=True, slots=True) +class FunctionMetadata: + filepath: str + start_line: int + end_line: int + + +NodeId: TypeAlias = str #file or function level according to diff-level +DependenciesMap: TypeAlias = dict[NodeId, set[NodeId]] # node -> who it depends on +DependentsMap: TypeAlias = dict[NodeId, set[NodeId]] # node -> who depends on it +FunctionNodes: TypeAlias = dict[str, FunctionMetadata] +FunctionsByFile: TypeAlias = dict[str, set[str]] +ModuleToPath: TypeAlias = dict[str, str] + + +@dataclass +class DependencyGraph: + dependents: DependentsMap #key: module (file level) or function_id (function level), value: set of modules that depend on / are affected by it + function_nodes: FunctionNodes = field(default_factory=dict) #the key represents the funcion name/node name + functions_by_file: FunctionsByFile = field(default_factory=dict) + + +def _extract_function_nodes( + graph: CallGraphVisitor, + working_dir: str, +) -> tuple[FunctionNodes, FunctionsByFile]: + """Filters and converts pyan3 nodes into simpler function nodes indexed by name""" + + function_nodes: FunctionNodes = {} + functions_by_file: FunctionsByFile = defaultdict(set) + + for node_group in graph.nodes.values(): + for node in node_group: + if node.flavor not in _KEPT_FLAVORS or node.ast_node is None: + continue + + filepath = os.path.relpath(node.filename, working_dir) + if filepath.startswith(".."): + continue + + function_name = node.get_name() #combines the module (node.namespace) and the function name (node.name) + function_nodes[function_name] = FunctionMetadata( + filepath=filepath, + start_line=node.ast_node.lineno, + end_line=node.ast_node.end_lineno, + ) + functions_by_file[filepath].add(function_name) + + return function_nodes, dict(functions_by_file) + + +def _convert_module_to_relative_path(fullpaths: ModuleToPath, working_dir: str) -> ModuleToPath: + """Convert pyan3 module names to paths relative to the repo (to match the diff). + + E.g.: "pytest_regsmart.selector" -> "pytest_regsmart/selector.py" + """ + return { + module: os.path.relpath(fullpath, working_dir) + for module, fullpath in fullpaths.items() + } + + +def _build_import_name_to_path(fullpaths: ModuleToPath, working_dir: str) -> dict[str, str]: + """Map every possible import-style suffix of a module's dotted name to its file path. + + E.g. for module "src.text_toolkit.text_stats": + "text_stats" -> path + "text_toolkit.text_stats" -> path + "src.text_toolkit.text_stats" -> path (redundant with module_to_path, harmless) + + For __init__ modules, the __init__ segment is dropped so that the package + name itself maps to the __init__.py file: + "src.text_toolkit.__init__" -> segments become ["src", "text_toolkit"] + "text_toolkit" -> path + "src.text_toolkit" -> path + + __init__ modules take priority over regular modules for the same import name, + matching Python's import resolution semantics (from pkg import X resolves to + pkg/__init__.py, not pkg/X.py). + + Ambiguous suffixes (same suffix pointing to different non-init files) are + dropped entirely. + """ + import_to_path: dict[str, str] = {} + ambiguous: set[str] = set() + + for module, fullpath in fullpaths.items(): + rel_path = os.path.relpath(fullpath, working_dir) + segments = module.split(".") + is_init = segments[-1] == "__init__" + if is_init: + segments = segments[:-1] + + for i in range(len(segments)): + candidate = ".".join(segments[i:]) + existing = import_to_path.get(candidate) + if existing is None or is_init: + import_to_path[candidate] = rel_path + elif existing != rel_path: + ambiguous.add(candidate) + + for name in ambiguous: + import_to_path.pop(name, None) + + return import_to_path + + +def _invert_dependency_map( + connections: DependenciesMap, + module_to_path: ModuleToPath | None = None, # only necessary for file-level graphs + import_name_to_path: dict[str, str] | None = None, # fallback for import-style names that don't match path-derived module names (ImportVisitor problem) + ) -> DependentsMap: + """Invert 'Y imports X' into 'X is used by Y'. + + E.g.: if plugin.py imports selector.py, the result contains + selector.py -> {plugin.py} (changing selector.py affects plugin.py); OR changing run affects test_run() + + With module_to_path: translates module names to relative paths (file-level graph) + e.g.: "pytest_regsmart.selector" -> "pytest_regsmart/selector.py" + Without module_to_path: keeps ids (functions references) as-is (function-level graph) + e.g.: "pytest_regsmart.selector:selector" -> "pytest_regsmart.selector:plugin" (changing selector affects plugin) + """ + + dependents = defaultdict(set) #dict that does not raise KeyError on missing keys; a dict subclass: https://www.geeksforgeeks.org/python/defaultdict-in-python/ + + for importer, dependencies in connections.items(): + importer_path = importer if module_to_path is None else module_to_path.get(importer) + if importer_path is None: + continue #importer is not in the repo - just a precaution, should not happen + + for imported in dependencies: + imported_path = imported if module_to_path is None else module_to_path.get(imported) + if imported_path is None and import_name_to_path is not None: + imported_path = import_name_to_path.get(imported) + if imported_path is None: + continue #doesnt import a file in the repo + dependents[imported_path].add(importer_path) + + return dict(dependents) + + +def _build_file_dependency_graph( + python_files: list[str], + working_dir: str, +) -> DependencyGraph: + graph = ImportVisitor(filenames=python_files, logger=logging.getLogger(__name__), root=working_dir) + #graph.modules: module -> set of modules imported by it + #graph.fullpaths: module -> absolute file path + + module_to_path = _convert_module_to_relative_path(graph.fullpaths, working_dir) + import_name_to_path = _build_import_name_to_path(graph.fullpaths, working_dir) + dependents = _invert_dependency_map(graph.modules, module_to_path, import_name_to_path) # invert so it becomes module -> who IMPORTS it / who is affected by it + + return DependencyGraph(dependents=dependents) + + +def _build_function_dependency_graph( + python_files: list[str], + working_dir: str, +) -> DependencyGraph: + graph = CallGraphVisitor( + filenames=python_files, + root=working_dir, + logger=logging.getLogger(__name__), + ) + + function_nodes, functions_by_files = _extract_function_nodes(graph, working_dir) + + function_dependencies: defaultdict[str, set[str]] = defaultdict(set) + + for function_node, dependency_nodes in graph.uses_edges.items(): + function_name = function_node.get_name() + if function_name not in function_nodes: + continue #skip nodes that are not functions/methods/classes + + for dependency in dependency_nodes: + dependency_name = dependency.get_name() + if dependency_name in function_nodes: + function_dependencies[function_name].add(dependency_name) + + dependents = _invert_dependency_map(function_dependencies) #function_dependencies already have the function names, so no need to convert to paths + + return DependencyGraph( + dependents=dependents, + function_nodes=function_nodes, + functions_by_file=functions_by_files + ) + + +def get_dependency_graph( + repo_path: str = ".", + graph_level: DIFF_LEVEL = DEFAULT_DIFF_LEVEL, +) -> DependencyGraph: + repo = resolve_repo(repo_path) + working_dir = repo.working_tree_dir + python_files = _find_py_files(working_dir) + + if graph_level == DIFF_LEVEL.FUNCTION: + return _build_function_dependency_graph(python_files, working_dir) + + return _build_file_dependency_graph(python_files, working_dir) diff --git a/src/pytest_regsmart/selection/git_manager.py b/src/pytest_regsmart/selection/git_manager.py new file mode 100644 index 0000000..3961212 --- /dev/null +++ b/src/pytest_regsmart/selection/git_manager.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from typing import TypeAlias + +import pytest +from git import Repo +from git.exc import BadName, GitCommandError, InvalidGitRepositoryError, NoSuchPathError + +from pytest_regsmart.const import DEFAULT_DIFF_LEVEL, DIFF_HUNK_HEADER, DIFF_LEVEL + +LineRange: TypeAlias = tuple[int, int] +ChangedLineRanges: TypeAlias = dict[str, list[LineRange]] + + +@dataclass +class DiffResult: + used_branch: str + modified_files: list[str] = field(default_factory=list) #staged+unstaged + untracked_files: list[str] = field(default_factory=list) #brand new files - maybe add a future flag to look only at unstaged+untracked tests + deleted_files: list[str] = field(default_factory=list) + changed_line_ranges: ChangedLineRanges = field(default_factory=dict) #only for DIFF_LEVEL.FUNCTION + no_merge_base: bool = False #base resolved, but no shared history with HEAD + + +def resolve_repo(repo_path: str = ".") -> Repo: + return Repo(repo_path) + + +def verify_git_repo(repo_path: str = ".") -> bool: + try: + resolve_repo(repo_path) + return True + except (InvalidGitRepositoryError, NoSuchPathError): + return False + + +_DEFAULT_BRANCH_CANDIDATES = ("main", "master") + + +def resolve_base_ref(repo: Repo) -> str | None: + """Ref que o git consegue resolver para a branch default (main/master): + primeiro a branch local, depois a remote-tracking (origin/main) -- que e o + que sobra depois do actions/checkout com fetch-depth: 0 em qualquer evento.""" + for branch in _DEFAULT_BRANCH_CANDIDATES: + for ref in (branch, f"origin/{branch}"): + try: + repo.commit(ref) + except (BadName, GitCommandError): + continue + return ref # "main" local ou "origin/main" + return None + + +def _is_deleted(repo: Repo, merge_base_hash: str, path: str) -> bool: + """True se o arquivo foi apagado do disco, mas ainda existe no commit-base.""" + if os.path.exists(os.path.join(repo.working_tree_dir, path)): + return False + try: + repo.git.show(f"{merge_base_hash}:{path}") + return True + except GitCommandError: + return False + + +def parse_diff_output(raw_diff: str) -> ChangedLineRanges: + """Parses `git diff -U0` and returns the changed line ranges for each file from the entire repo""" + changed_line_ranges: ChangedLineRanges = {} + current_file: str | None = None + + for line in raw_diff.splitlines(): + if line.startswith("+++ b/"): + current_file = line.removeprefix("+++ b/") + continue + if line == "+++ /dev/null": + current_file = None + continue + if current_file is None: + continue + + hunk_match = DIFF_HUNK_HEADER.match(line) + if not hunk_match: + continue + + # filters the hunk header: @@ -1,10 +1,12 @@ to get the start and end lines + new_start_line = int(hunk_match.group(1)) + new_count = int(hunk_match.group(2)) if hunk_match.group(2) is not None else 1 # it's 1 if the hunk is a single line change (e.g., +1) + new_end_line = new_start_line if new_count == 0 else new_start_line + new_count - 1 # has the same value as start_line if the change is just deletion + + # get the changed line ranges for the current modified file + changed_line_ranges.setdefault(current_file, []).append((new_start_line, new_end_line)) + + return changed_line_ranges + + +def get_git_diff(repo_path: str = ".", diff_level: DIFF_LEVEL = DEFAULT_DIFF_LEVEL) -> DiffResult: + repo = resolve_repo(repo_path=repo_path) + untracked_diff = repo.untracked_files + + if not repo.head.is_valid(): #repo has no commits yet (just git init); only check the untracked diff + return DiffResult( + modified_files=[], + untracked_files=untracked_diff, + used_branch="" + ) + + base_ref = resolve_base_ref(repo) #maybe make this configurable via a flag later + if base_ref is None: + raise pytest.UsageError( + "main/master nao foi encontrada (nem local nem origin/main). " + "Em CI raso (fetch-depth: 1) a base nao e buscada; use fetch-depth: 0 no " + "actions/checkout, e garanta que a main tambem seja buscada " + "(ex: `git fetch origin main:refs/remotes/origin/main`)." + ) + + try: + merge_base_commit = repo.merge_base(base_ref, repo.head.commit)[0] # https://git-scm.com/docs/git-merge-base#_description + except (IndexError, GitCommandError): + # Base resolved but shares no history with HEAD (e.g. unrelated first commit). + return DiffResult( + modified_files=[], + untracked_files=untracked_diff, + used_branch=base_ref, + no_merge_base=True, + ) + + working_dir_diff = repo.git.diff(merge_base_commit, name_only=True).splitlines() + deleted_files = [path for path in working_dir_diff if _is_deleted(repo, merge_base_commit.hexsha, path)] + + if diff_level == DIFF_LEVEL.FUNCTION: + raw_diff = repo.git.diff(merge_base_commit, unified=0) + + return DiffResult( + modified_files=working_dir_diff, + untracked_files=untracked_diff, + deleted_files=deleted_files, + used_branch=base_ref, + changed_line_ranges=parse_diff_output(raw_diff) + ) + + return DiffResult( + modified_files=working_dir_diff, + untracked_files=untracked_diff, + deleted_files=deleted_files, + used_branch=base_ref + ) diff --git a/src/pytest_regsmart/selection/selection_args.py b/src/pytest_regsmart/selection/selection_args.py new file mode 100644 index 0000000..56c5f10 --- /dev/null +++ b/src/pytest_regsmart/selection/selection_args.py @@ -0,0 +1,26 @@ +import argparse + +from ..const import DEFAULT_DIFF_LEVEL, DIFF_LEVEL +from ..utils import _resolve_ini_value + + +def level_type(string: str) -> DIFF_LEVEL: + "Check level format." + try: + return DIFF_LEVEL(string) + except ValueError: + raise argparse.ArgumentTypeError( + "Invalid input for `--diff-level`." + + " Please run `pytest --help` for instruction." + ) from None + + +def parse_diff_level(config) -> DIFF_LEVEL: + """Get selected level for git diff, non-default CLI overrides ini file input.""" + return _resolve_ini_value( + config, + cli_opt="--diff-level", + default=DEFAULT_DIFF_LEVEL, + ini_key="diff_level", + type_fn=level_type, + ) diff --git a/src/pytest_regsmart/selection/selector.py b/src/pytest_regsmart/selection/selector.py new file mode 100644 index 0000000..8680b95 --- /dev/null +++ b/src/pytest_regsmart/selection/selector.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +import os +import time +from dataclasses import dataclass + +from pytest_regsmart.const import DEFAULT_DIFF_LEVEL, DIFF_LEVEL + +from ..utils import _is_conftest, _is_test_file +from .deps_graph import DependencyGraph, get_dependency_graph +from .git_manager import DiffResult, get_git_diff + + +@dataclass +class SelectionResult: + affected_tests: list[str] + has_diff: bool + branch: str + full_run: bool = False + no_merge_base: bool = False + + +def line_diff_match_function_ids( + diff_result: DiffResult, + deps_graph: DependencyGraph, +) -> set[str]: + """Project changed diff lines onto function-level call graph nodes. + Hunks outside every function (imports, constants) or files without + specific range fallback to the entire file""" + changed: set[str] = set() + + no_range_files = ( + set(diff_result.untracked_files) + | (set(diff_result.modified_files) - set(diff_result.changed_line_ranges)) + ) #maybe broken + + for filepath in no_range_files: + changed.update(deps_graph.functions_by_file.get(filepath, set())) + + for filepath, ranges in diff_result.changed_line_ranges.items(): + candidates = deps_graph.functions_by_file.get(filepath, set()) + if not candidates: + continue + + for start, end in ranges: + matched = { + fid for fid in candidates + if start <= deps_graph.function_nodes[fid].end_line + and deps_graph.function_nodes[fid].start_line <= end + } + changed.update(matched or candidates) #not a specific match + + return changed + + +def _get_affected_nodes( + changed_nodes: set[str], + deps_graph: DependencyGraph, +) -> set[str]: + """ For each file/function modified in the diff, look up its node in the dependency graph and get the related value (dependents) + #loooking the dependents, keep only those that are tests + #i also want indirectly affected tests, not just direct dependents""" + + seen = set(changed_nodes) + to_check = list(changed_nodes) + + while to_check: + current_node = to_check.pop() + dependents = deps_graph.dependents.get(current_node, set()) + + for dependent in dependents: + if dependent in seen: + continue + + seen.add(dependent) #seen = dependent was discovered and will be visited + to_check.append(dependent) #even if not a test, check its dependents for indirect affected tests + + return seen + + +def _get_affected_tests_at_file_level( + diff_result: DiffResult, + deps_graph: DependencyGraph, +) -> list[str]: + changed_files = set(diff_result.modified_files) | set(diff_result.untracked_files) + affected_nodes = _get_affected_nodes(changed_files, deps_graph) + + return sorted(node for node in affected_nodes if _is_test_file(node)) + + +def _get_affected_tests_at_function_level( + diff_result: DiffResult, + deps_graph: DependencyGraph, +) -> list[str]: + changed_function_ids = line_diff_match_function_ids(diff_result, deps_graph) + affected_function_ids = _get_affected_nodes(changed_function_ids, deps_graph) + + # pytest filters collected items by their nodeid. A function graph id uses + # dotted Python names, while pytest uses ``path.py::Class::test``. + return sorted( + { + _function_id_to_pytest_nodeid( + function_id, deps_graph.function_nodes[function_id].filepath + ) + for function_id in affected_function_ids + if _is_test_file(deps_graph.function_nodes[function_id].filepath) + } + ) + + +def _function_id_to_pytest_nodeid(function_id: str, filepath: str) -> str: + """Translate a pyan3 function id into the equivalent pytest nodeid. + + tests.selection.test_selector.py.test_regsmart_requires_git_repo =>> + tests/selection/test_selector.py::test_regsmart_requires_git_repo + """ + + + module_name = os.path.splitext(filepath)[0].replace(os.sep, ".") + qualified_name = function_id.removeprefix(f"{module_name}.") + + return f"{filepath}::{qualified_name.replace('.', '::')}" + + +def run_rts(level: DIFF_LEVEL = DEFAULT_DIFF_LEVEL, log_dict: dict | None = None) -> SelectionResult: + """[wip] Orchestrates the selection...""" + start_time = time.time() + + diff_result = get_git_diff(diff_level=level) + has_diff=bool(diff_result.modified_files or diff_result.untracked_files) + used_branch = diff_result.used_branch + + if diff_result.no_merge_base: + return SelectionResult( + affected_tests=[], + has_diff=has_diff, + branch=used_branch, + no_merge_base=True, + ) + + if not has_diff: + return SelectionResult( + affected_tests=[], + has_diff=has_diff, + branch=used_branch + ) + + changed_files = set(diff_result.modified_files) | set(diff_result.untracked_files) + if any(_is_conftest(f) for f in changed_files): + return SelectionResult( + affected_tests=[], + has_diff=has_diff, + branch=used_branch, + full_run=True + ) + + deps_graph = get_dependency_graph(graph_level= level) + + selected = ( + _get_affected_tests_at_function_level(diff_result, deps_graph) + if level == DIFF_LEVEL.FUNCTION + else _get_affected_tests_at_file_level(diff_result, deps_graph) + ) + + if log_dict is not None: + log_dict["Time to run the regression test selection (s)"] = time.time() - start_time + + return SelectionResult( + affected_tests=selected, + has_diff=has_diff, + branch=used_branch + ) diff --git a/src/pytest_regsmart/utils.py b/src/pytest_regsmart/utils.py new file mode 100644 index 0000000..b6d23c6 --- /dev/null +++ b/src/pytest_regsmart/utils.py @@ -0,0 +1,36 @@ +import argparse +import os + +import pytest + + +def _find_py_files(working_dir: str) -> list[str]: + excludes = [".venv", ".git", "__pycache__", "dist", "build", "venv", "site-packages"] + + py_files = [] + for dirpath, dirnames, filenames in os.walk(working_dir): + dirnames[:] = [d for d in dirnames if d not in excludes and not d.endswith(".egg-info")] # keep only core directories to check + for filename in filenames: + if filename.endswith(".py"): + py_files.append(os.path.join(dirpath, filename)) #build the full path + return py_files + + +def _is_test_file(filepath: str) -> bool: + filename = os.path.basename(filepath) + return filename.startswith("test_") or filename.endswith("_test.py") + + +def _is_conftest(filepath: str) -> bool: + return os.path.basename(filepath) == "conftest.py" + + +def _resolve_ini_value(config, cli_opt, default, ini_key, type_fn): + value = config.getoption(cli_opt) + if value == default: + ini_val = config.getini(ini_key) + value = ini_val if ini_val else value + try: + return type_fn(value) + except argparse.ArgumentTypeError as e: + raise pytest.UsageError(f"Invalid value for '{ini_key}' in pytest.ini: {e}") from None diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py index c6481d5..7db703c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1 +1,116 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from git import Repo + pytest_plugins = ["pytester"] + + +@pytest.fixture +def git_repo(tmp_path) -> Repo: + repo = Repo.init(tmp_path) + writer = repo.config_writer() + writer.set_value("user", "name", "Test") + writer.set_value("user", "email", "test@test.com") + writer.release() + return repo + + +@pytest.fixture +def commit_file(git_repo): + def _commit_file(filename: str, content: str = "") -> Path: + filepath = Path(git_repo.working_tree_dir) / filename + filepath.write_text(content) + git_repo.index.add(filename) + git_repo.index.commit(f"Add {filename}") + return filepath + + return _commit_file + + +@pytest.fixture +def mytester(pytester): + pytester.makefile( + ".ini", + pytest=""" + [pytest] + console_output_style = classic + """, + ) + repo = Repo.init(pytester.path) + writer = repo.config_writer() + writer.set_value("user", "name", "Test") + writer.set_value("user", "email", "test@test.com") + writer.release() + (pytester.path / ".gitignore").write_text("__pycache__/\n") + repo.index.add([".gitignore"]) + repo.index.commit("chore: baseline") + yield pytester + + +@pytest.fixture +def selection_project(pytester): + """Git repo with production module + tests that import it, all committed. + + Tests real selection: the test changes `service.py` + (production) to produce a diff and select only the dependent tests cases. + Returns (project Path, Repo). + """ + pytester.makefile( + ".ini", + pytest=""" + [pytest] + console_output_style = classic + """, + ) + repo = Repo.init(pytester.path) + writer = repo.config_writer() + writer.set_value("user", "name", "Test") + writer.set_value("user", "email", "test@test.com") + writer.release() + + (pytester.path / ".gitignore").write_text("__pycache__/\n.pytest_cache/\n") + pytester.makepyfile( + service=""" + def run(): + return 42 + """, + test_service=""" + import time + from service import run + + def test_service_fast(): + time.sleep(0.05) + assert run() == 42 + + def test_service_slow(): + time.sleep(0.5) + assert run() == 42 + """, + test_other=""" + import time + from service import run + + def test_other_run(): + time.sleep(0.2) + assert run() == 42 + """, + test_unrelated=""" + def test_unrelated(): + assert True + """, + ) + repo.index.add( + [ + ".gitignore", + "pytest.ini", + "service.py", + "test_service.py", + "test_other.py", + "test_unrelated.py", + ] + ) + repo.index.commit("chore: baseline") + yield pytester, repo diff --git a/tests/fake_test_data.py b/tests/fake_test_data.py new file mode 100644 index 0000000..01f9eb9 --- /dev/null +++ b/tests/fake_test_data.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +test_method_one = \ + """ + import time + + def func(x): + return x + 1 + + def test_slow(): + time.sleep(1.5) + assert func(4) == 5 + + # FAIL + def test_fast_fail(): + time.sleep(0.5) + assert func(3) == 5 + + def test_medium(): + time.sleep(1) + assert func(4) == 5 + """ + + +test_class_one = \ + """ + import time + + def func(x): + return x + 1 + + class TestClassSample: + def test_fast(self): + time.sleep(0.7) + assert func(4) == 5 + + # FAIL + def test_slow_fail(self): + time.sleep(1.7) + assert func(3) == 5 + + def test_medium(self): + time.sleep(1.2) + assert func(4) == 5 + """ + + +test_put_one = \ + """ + import pytest + + @pytest.mark.parametrize("param", {"a", "b", "c", "d"}) + def test_put_unordered(param): + pass + + @pytest.mark.parametrize("param", ["d", "e", 1, 3, "a", "b", " "]) + def test_put_ordered(param): + pass + + """ + + +test_a_method = \ + """ + import time + + def func(x): + return x + 1 + + def test_a_slow(): + time.sleep(1.5) + assert func(4) == 5 + + # FAIL + def test_b_fast_fail(): + time.sleep(0.5) + assert func(3) == 5 + + def test_c_medium(): + time.sleep(1) + assert func(4) == 5 + """ + + +test_b_class = \ + """ + import time + + def func(x): + return x + 1 + + class TestClassA: + def test_a_fast(self): + time.sleep(0.7) + assert func(4) == 5 + + # FAIL + def test_b_slow_fail(self): + time.sleep(1.7) + assert func(3) == 5 + + def test_c_medium(self): + time.sleep(1.2) + assert func(4) == 5 + """ + + +test_c_put = \ + """ + import pytest + import time + + @pytest.mark.parametrize("param", {0.1, 0.2, 0.3, 0.4}) + def test_a_put_unordered(param): + time.sleep(param) + pass + + @pytest.mark.parametrize("param", [0.45, 0.25, 0.55, 0.35, 0.15]) + def test_b_put_ordered(param): + time.sleep(param) + pass + + """ + + +test_c_put_ordered = \ + """ + import pytest + import time + + @pytest.mark.parametrize("param", [0.45, 0.25, 0.55, 0.35, 0.15]) + def test_b_put_ordered(param): + time.sleep(param) + pass + + """ + + +test_a_method_two = \ + """ + import time + + def func(x): + return x + 1 + + def test_a_slow(): + time.sleep(0.05) + assert func(4) == 5 + + # FAIL + def test_b_fast_fail(): + time.sleep(0.01) + assert func(3) == 5 + + def test_c_medium(): + time.sleep(0.02) + assert func(4) == 5 + """ + + +test_order = \ + """ + import pytest + import time + @pytest.mark.order(2) + def test_foo(): + time.sleep(4.5) + assert True + + @pytest.mark.order(1) + def test_bar(): + time.sleep(5) + assert True + """ + + +test_dependency = \ + """ + import pytest + import time + + @pytest.mark.dependency() + @pytest.mark.xfail(reason="deliberate fail") + def test_a(): + time.sleep(4) + assert False + + @pytest.mark.dependency() + def test_b(): + time.sleep(3.5) + pass + + @pytest.mark.dependency(depends=["test_a"]) + def test_c(): + time.sleep(3) + pass + + @pytest.mark.dependency(depends=["test_b"]) + def test_d(): + time.sleep(2.5) + pass + + @pytest.mark.dependency(depends=["test_b", "test_c"]) + def test_e(): + time.sleep(2) + pass + """ + + +replay_order_one = \ + """ + test_class_one.py::TestClassSample::test_fast + test_class_one.py::TestClassSample::test_slow_fail + test_method_one.py::test_medium + test_method_one.py::test_fast_fail + test_class_one.py::TestClassSample::test_medium + test_method_one.py::test_slow + """ diff --git a/tests/ranking/test_rank_args.py b/tests/ranking/test_rank_args.py new file mode 100644 index 0000000..31c65f3 --- /dev/null +++ b/tests/ranking/test_rank_args.py @@ -0,0 +1,157 @@ +import pytest + +from src.pytest_regsmart.const import RANK_LEVEL +from src.pytest_regsmart.ranking.rank_args import ( + parse_hist_len, + parse_no_rank, + parse_replay, + parse_rtp_level, + parse_rtp_weights, + parse_seed, +) + + +class _FakeConfig: + def __init__(self, option, ini=None): + self._option = option + self._ini = ini + + def getoption(self, _name): + return self._option + + def getini(self, _name): + return self._ini + + +@pytest.mark.parametrize( + ("option", "ini", "expected"), + [ + pytest.param("0-1", "1-0", [0, 1], id="cli value wins over ini"), + pytest.param( + "1-0", + "0.4-0.2", + pytest.approx([0.4 / 0.6, 0.2 / 0.6]), + id="ini used when cli has default", + ), + pytest.param("1-0", None, [1, 0], id="default when ini unset"), + pytest.param("0-0", None, [0, 0], id="zero sum returns zeros"), + ], +) +def test_parse_rtp_weights(option, ini, expected): + config = _FakeConfig(option=option, ini=ini) + + assert parse_rtp_weights(config) == expected + + +@pytest.mark.parametrize( + ("option", "ini", "expected"), + [ + pytest.param("module", "put", "module", id="cli value wins over ini"), + pytest.param( + RANK_LEVEL.PUT, + "function", + "function", + id="ini used when cli has default", + ), + pytest.param(RANK_LEVEL.PUT, "put", "put", id="default is plain str"), + ], +) +def test_parse_rtp_level(option, ini, expected): + result = parse_rtp_level(_FakeConfig(option=option, ini=ini)) + + assert result == expected + assert type(result) is str + + +@pytest.mark.parametrize( + "from_cli", + [ + pytest.param(True, id="cli value wins over ini"), + pytest.param(False, id="ini uses valid file"), + ], +) +def test_parse_replay_uses_provided_file(tmp_path, from_cli): + ini_file = tmp_path / "ini_order.txt" + ini_file.write_text("test_a\n") + cli_file = tmp_path / "cli_order.txt" + cli_file.write_text("test_a\n") + + config = _FakeConfig( + option=str(cli_file) if from_cli else None, + ini=str(ini_file), + ) + + expected = str(cli_file) if from_cli else str(ini_file) + assert parse_replay(config) == expected + + +def test_parse_replay_default_none_when_ini_unset(): + config = _FakeConfig(option=None, ini=None) + + assert parse_replay(config) is None + + +def test_parse_replay_ini_missing_file_raises_usage_error(tmp_path): + config = _FakeConfig(option=None, ini=str(tmp_path / "missing.txt")) + + with pytest.raises(pytest.UsageError, match="rank_replay"): + parse_replay(config) + + +@pytest.mark.parametrize( + ("option", "ini", "expected"), + [ + pytest.param(40, 10, 40, id="cli value wins over ini"), + pytest.param(50, "30", 30, id="ini used when cli has default"), + pytest.param(50, 50, 50, id="default when ini unset"), + ], +) +def test_parse_hist_len(option, ini, expected): + config = _FakeConfig(option=option, ini=ini) + + assert parse_hist_len(config) == expected + + +@pytest.mark.parametrize( + ("option", "ini", "expected"), + [ + pytest.param(1234, 0, 1234, id="cli value wins over ini"), + pytest.param(0, "42", 42, id="ini used when cli has default"), + pytest.param(0, 0, 0, id="default when ini unset"), + ], +) +def test_parse_seed(option, ini, expected): + config = _FakeConfig(option=option, ini=ini) + + assert parse_seed(config) == expected + + +@pytest.mark.parametrize( + ("option", "ini", "expected"), + [ + pytest.param(False, False, False, id="default false when unset"), + pytest.param(False, True, True, id="ini true"), + pytest.param(True, False, True, id="cli true wins"), + ], +) +def test_parse_no_rank(option, ini, expected): + config = _FakeConfig(option=option, ini=ini) + + assert parse_no_rank(config) is expected + + +@pytest.mark.parametrize( + ("parse_fn", "option", "ini", "match"), + [ + pytest.param(parse_rtp_weights, "1-0", "1-3-2", "rank_weight", id="weight too many parts"), + pytest.param(parse_rtp_weights, "1-0", "x-y", "rank_weight", id="weight non numeric parts"), + pytest.param(parse_rtp_level, RANK_LEVEL.PUT, "class", "rank_level", id="level unknown value"), + pytest.param(parse_hist_len, 50, "abc", "rank_hist_len", id="hist len not an integer"), + pytest.param(parse_seed, 0, "not-a-number", "rank_seed", id="seed not an integer"), + ], +) +def test_invalid_ini_value_raises_usage_error(parse_fn, option, ini, match): + config = _FakeConfig(option=option, ini=ini) + + with pytest.raises(pytest.UsageError, match=match): + parse_fn(config) diff --git a/tests/ranking/test_ranking_heuristics.py b/tests/ranking/test_ranking_heuristics.py new file mode 100644 index 0000000..9485688 --- /dev/null +++ b/tests/ranking/test_ranking_heuristics.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +from pytest_regsmart.ranking.rank_args import min_max_normalization +from tests.fake_test_data import test_class_one, test_method_one + + +def test_min_max_normalization_accepts_empty_input(): + assert min_max_normalization([]).tolist() == [] + + +def test_default(mytester): + mytester.makepyfile( + test_method_one=test_method_one, + test_class_one=test_class_one, + ) + + args = ["-v"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=4, failed=2) + + args = ["-v", "--regsmart"] + out = mytester.runpytest(*args) + + out.assert_outcomes(passed=4, failed=2) + out.stdout.fnmatch_lines( + [ + "test_method_one.py::test_fast_fail FAILED", + "test_class_one.py::TestClassSample::test_fast PASSED", + "test_method_one.py::test_medium PASSED", + "test_class_one.py::TestClassSample::test_medium PASSED", + "test_method_one.py::test_slow PASSED", + "test_class_one.py::TestClassSample::test_slow_fail FAILED", + ], + consecutive=True + ) + + +def test_faster_test_first(mytester): + mytester.makepyfile( + test_method_one=test_method_one, + test_class_one=test_class_one, + ) + + args = ["-v"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=4, failed=2) + + args = ["-v", "--regsmart", "--rank-weight=1-0"] + out = mytester.runpytest(*args) + + out.assert_outcomes(passed=4, failed=2) + out.stdout.fnmatch_lines( + [ + "test_method_one.py::test_fast_fail FAILED", + "test_class_one.py::TestClassSample::test_fast PASSED", + "test_method_one.py::test_medium PASSED", + "test_class_one.py::TestClassSample::test_medium PASSED", + "test_method_one.py::test_slow PASSED", + "test_class_one.py::TestClassSample::test_slow_fail FAILED", + ], + consecutive=True + ) + + +def test_recent_fail_first(mytester): + mytester.makepyfile( + test_method_one=test_method_one, + test_class_one=test_class_one, + ) + + args = ["-v"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=4, failed=2) + + args = ["-v", "--regsmart", "--rank-weight=0-1"] + out = mytester.runpytest(*args) + + out.assert_outcomes(passed=4, failed=2) + out.stdout.fnmatch_lines( + [ + "test_class_one.py::TestClassSample::test_slow_fail FAILED", + "test_method_one.py::test_fast_fail FAILED", + "test_class_one.py::TestClassSample::test_fast PASSED", + "test_class_one.py::TestClassSample::test_medium PASSED", + "test_method_one.py::test_slow PASSED", + "test_method_one.py::test_medium PASSED", + ], + consecutive=True + ) + + +def test_550_weight(mytester): + mytester.makepyfile( + test_method_one=test_method_one, + test_class_one=test_class_one, + ) + + args = ["-v"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=4, failed=2) + + args = ["-v", "--regsmart", "--rank-weight=5-5"] + out = mytester.runpytest(*args) + + out.assert_outcomes(passed=4, failed=2) + out.stdout.fnmatch_lines( + [ + "test_method_one.py::test_fast_fail FAILED", + "test_class_one.py::TestClassSample::test_slow_fail FAILED", + "test_class_one.py::TestClassSample::test_fast PASSED", + "test_method_one.py::test_medium PASSED", + "test_class_one.py::TestClassSample::test_medium PASSED", + "test_method_one.py::test_slow PASSED", + ], + consecutive=True + ) diff --git a/tests/ranking/test_ranking_levels.py b/tests/ranking/test_ranking_levels.py new file mode 100644 index 0000000..af34af3 --- /dev/null +++ b/tests/ranking/test_ranking_levels.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from tests.fake_test_data import ( + test_a_method, + test_a_method_two, + test_b_class, + test_c_put, + test_c_put_ordered, +) + + +def test_put_level_ranking(mytester): + mytester.makepyfile( + test_a_method=test_a_method, + test_b_class=test_b_class, + test_c_put=test_c_put, + ) + + args = ["-v"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=13, failed=2) + + args = ["-v", "--regsmart", "--rank-level=put"] + out = mytester.runpytest(*args) + + out.assert_outcomes(passed=13, failed=2) + out.stdout.fnmatch_lines( + [ + "test_c_put.py::test_a_put_unordered[0.1] PASSED", + "test_c_put.py::test_b_put_ordered[0.15] PASSED", + "test_c_put.py::test_a_put_unordered[0.2] PASSED", + "test_c_put.py::test_b_put_ordered[0.25] PASSED", + "test_c_put.py::test_a_put_unordered[0.3] PASSED", + "test_c_put.py::test_b_put_ordered[0.35] PASSED", + "test_c_put.py::test_a_put_unordered[0.4] PASSED", + "test_c_put.py::test_b_put_ordered[0.45] PASSED", + "test_a_method.py::test_b_fast_fail FAILED", + "test_c_put.py::test_b_put_ordered[0.55] PASSED", + "test_b_class.py::TestClassA::test_a_fast PASSED", + "test_a_method.py::test_c_medium PASSED", + "test_b_class.py::TestClassA::test_c_medium PASSED", + "test_a_method.py::test_a_slow PASSED", + "test_b_class.py::TestClassA::test_b_slow_fail FAILED" + ], + consecutive=True + ) + + +def test_function_level_ranking(mytester): + mytester.makepyfile( + test_a_method=test_a_method, + test_b_class=test_b_class, + test_c_put=test_c_put_ordered, + ) + + args = ["-v"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=9, failed=2) + + args = ["-v", "--regsmart", "--rank-level=function"] + out = mytester.runpytest(*args) + + out.assert_outcomes(passed=9, failed=2) + out.stdout.fnmatch_lines( + [ + "test_c_put.py::test_b_put_ordered[0.45] PASSED", + "test_c_put.py::test_b_put_ordered[0.25] PASSED", + "test_c_put.py::test_b_put_ordered[0.55] PASSED", + "test_c_put.py::test_b_put_ordered[0.35] PASSED", + "test_c_put.py::test_b_put_ordered[0.15] PASSED", + "test_a_method.py::test_b_fast_fail FAILED", + "test_b_class.py::TestClassA::test_a_fast PASSED", + "test_a_method.py::test_c_medium PASSED", + "test_b_class.py::TestClassA::test_c_medium PASSED", + "test_a_method.py::test_a_slow PASSED", + "test_b_class.py::TestClassA::test_b_slow_fail FAILED", + ], + consecutive=True + ) + + +def test_module_level_ranking(mytester): + mytester.makepyfile( + test_a_method=test_a_method, + test_b_class=test_b_class, + test_c_put=test_c_put_ordered, + ) + + args = ["-v"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=9, failed=2) + + args = ["-v", "--regsmart", "--rank-level=module"] + out = mytester.runpytest(*args) + + out.assert_outcomes(passed=9, failed=2) + out.stdout.fnmatch_lines( + [ + "test_c_put.py::test_b_put_ordered[0.45] PASSED", + "test_c_put.py::test_b_put_ordered[0.25] PASSED", + "test_c_put.py::test_b_put_ordered[0.55] PASSED", + "test_c_put.py::test_b_put_ordered[0.35] PASSED", + "test_c_put.py::test_b_put_ordered[0.15] PASSED", + "test_a_method.py::test_a_slow PASSED", + "test_a_method.py::test_b_fast_fail FAILED", + "test_a_method.py::test_c_medium PASSED", + "test_b_class.py::TestClassA::test_a_fast PASSED", + "test_b_class.py::TestClassA::test_b_slow_fail FAILED", + "test_b_class.py::TestClassA::test_c_medium PASSED", + ], + consecutive=True + ) + + +def test_function_level_ranking_with_duplicate_methods(mytester): + mytester.makepyfile( + test_a_method=test_a_method, + test_a_method_two=test_a_method_two, + test_b_class=test_b_class, + test_c_put=test_c_put_ordered, + ) + + args = ["-v"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=11, failed=3) + + args = ["-v", "--regsmart", "--rank-level=function"] + out = mytester.runpytest(*args) + + out.assert_outcomes(passed=11, failed=3) + out.stdout.fnmatch_lines( + [ + "test_a_method_two.py::test_b_fast_fail FAILED", + "test_a_method_two.py::test_c_medium PASSED", + "test_a_method_two.py::test_a_slow PASSED", + "test_c_put.py::test_b_put_ordered[0.45] PASSED", + "test_c_put.py::test_b_put_ordered[0.25] PASSED", + "test_c_put.py::test_b_put_ordered[0.55] PASSED", + "test_c_put.py::test_b_put_ordered[0.35] PASSED", + "test_c_put.py::test_b_put_ordered[0.15] PASSED", + "test_a_method.py::test_b_fast_fail FAILED", + "test_b_class.py::TestClassA::test_a_fast PASSED", + "test_a_method.py::test_c_medium PASSED", + "test_b_class.py::TestClassA::test_c_medium PASSED", + "test_a_method.py::test_a_slow PASSED", + "test_b_class.py::TestClassA::test_b_slow_fail FAILED", + ], + consecutive=True + ) diff --git a/tests/ranking/test_replay.py b/tests/ranking/test_replay.py new file mode 100644 index 0000000..64814dc --- /dev/null +++ b/tests/ranking/test_replay.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from tests.fake_test_data import ( + replay_order_one, + test_a_method, + test_class_one, + test_method_one, +) + + +def test_replay(mytester): + mytester.makepyfile( + test_method_one=test_method_one, + test_class_one=test_class_one, + ) + + mytester.maketxtfile( + replay_order=replay_order_one + ) + + args = ["-v"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=4, failed=2) + + args = ["-v", "--regsmart", "--rank-replay=replay_order.txt"] + out = mytester.runpytest(*args) + + out.assert_outcomes(passed=4, failed=2) + out.stdout.fnmatch_lines( + [ + "test_class_one.py::TestClassSample::test_fast PASSED", + "test_class_one.py::TestClassSample::test_slow_fail FAILED", + "test_method_one.py::test_medium PASSED", + "test_method_one.py::test_fast_fail FAILED", + "test_class_one.py::TestClassSample::test_medium PASSED", + "test_method_one.py::test_slow PASSED", + ], + consecutive=True + ) + + +def test_replay_with_random(mytester): + mytester.makepyfile( + test_method_one=test_method_one, + test_class_one=test_class_one, + ) + + mytester.maketxtfile( + replay_order=replay_order_one + ) + + args = [ + "-v", + "--regsmart", + "--rank-replay=replay_order.txt", + "--rank-weight=0-0" + ] + out = mytester.runpytest(*args) + error_msg = "--rank-replay cannot be used together with random order." + assert len([x for x in out.errlines if error_msg in x]) == 1 + + +def test_invalid_replay(mytester): + mytester.makepyfile( + test_a_method=test_a_method, + ) + + args = ["-v", "--regsmart", "--rank-replay=order.txt"] + out = mytester.runpytest(*args) + error_msg = "File provided to `--rank-replay` cannot be read." + assert any(error_msg in x for x in out.errlines) diff --git a/tests/selection/test_deps_graph.py b/tests/selection/test_deps_graph.py new file mode 100644 index 0000000..9e86327 --- /dev/null +++ b/tests/selection/test_deps_graph.py @@ -0,0 +1,504 @@ +from __future__ import annotations + +import logging +import os +from pathlib import Path + +from pyan.analyzer import CallGraphVisitor +from pytest import mark, param + +from pytest_regsmart.const import DIFF_LEVEL +from src.pytest_regsmart.selection.deps_graph import ( + FunctionMetadata, + _build_import_name_to_path, + _convert_module_to_relative_path, + _extract_function_nodes, + _find_py_files, + _invert_dependency_map, + get_dependency_graph, +) + + +def _write(tmp_path: Path, relpath: str, content: str = "") -> Path: + filepath = tmp_path / relpath + filepath.parent.mkdir(parents=True, exist_ok=True) + filepath.write_text(content) + return filepath + + +# --------------------------------------------------------------------------- +# _find_py_files +# --------------------------------------------------------------------------- + + +def test_find_py_files_only_discovers_project_files(tmp_path): + _write(tmp_path, "app/mod.py") + _write(tmp_path, "app/sub/helper.py") + _write(tmp_path, "not_python.txt") + + files = _find_py_files(str(tmp_path)) + + assert {os.path.relpath(f, tmp_path) for f in files} == { + os.path.join("app", "mod.py"), + os.path.join("app", "sub", "helper.py"), + } + + +def test_find_py_files_excludes_env_and_build_dirs(tmp_path): + _write(tmp_path, "app/mod.py") + _write(tmp_path, ".venv/lib.py") + _write(tmp_path, ".git/hooks.py") + _write(tmp_path, "__pycache__/cache.py") + _write(tmp_path, "dist/bundle.py") + _write(tmp_path, "build/gen.py") + _write(tmp_path, "pkg.egg-info/PKG-INFO.py") + + files = _find_py_files(str(tmp_path)) + + assert {os.path.relpath(f, tmp_path) for f in files} == { + os.path.join("app", "mod.py") + } + + +# --------------------------------------------------------------------------- +# _convert_module_to_relative_path +# --------------------------------------------------------------------------- + + +def test_convert_module_to_relative_path(tmp_path): + fullpaths = { + "pkg.mod": str(tmp_path / "pkg" / "mod.py"), + "pkg": str(tmp_path / "pkg" / "__init__.py"), + } + + result = _convert_module_to_relative_path(fullpaths, str(tmp_path)) + + assert result == { + "pkg.mod": os.path.join("pkg", "mod.py"), + "pkg": os.path.join("pkg", "__init__.py"), + } + + +# --------------------------------------------------------------------------- +# _build_import_name_to_path +# --------------------------------------------------------------------------- + + +@mark.parametrize( + "fullpaths,expected", + [ + param( + { + "pkg.__init__": "pkg/__init__.py", + "pkg.service": "pkg/service.py", + "tests.test_app": "tests/test_app.py", + }, + { + "pkg": os.path.join("pkg", "__init__.py"), + "service": os.path.join("pkg", "service.py"), + "test_app": os.path.join("tests", "test_app.py"), + "pkg.service": os.path.join("pkg", "service.py"), + "tests.test_app": os.path.join("tests", "test_app.py"), + }, + id="flat-layout", + ), + param( + { + "src.hi": "src/hi.py", + "src.text_toolkit.__init__": "src/text_toolkit/__init__.py", + "src.text_toolkit.text_stats": "src/text_toolkit/text_stats.py", + "tests.test_hi": "tests/test_hi.py", + "tests.test_text_stats": "tests/test_text_stats.py", + }, + { + "hi": os.path.join("src", "hi.py"), + "text_toolkit": os.path.join("src", "text_toolkit", "__init__.py"), + "text_stats": os.path.join("src", "text_toolkit", "text_stats.py"), + "test_hi": os.path.join("tests", "test_hi.py"), + "test_text_stats": os.path.join("tests", "test_text_stats.py"), + "src.hi": os.path.join("src", "hi.py"), + "src.text_toolkit": os.path.join("src", "text_toolkit", "__init__.py"), + "src.text_toolkit.text_stats": os.path.join("src", "text_toolkit", "text_stats.py"), + "tests.test_hi": os.path.join("tests", "test_hi.py"), + "tests.test_text_stats": os.path.join("tests", "test_text_stats.py"), + "text_toolkit.text_stats": os.path.join("src", "text_toolkit", "text_stats.py"), + }, + id="src-layout", + ), + ], +) +def test_build_import_name_to_path(tmp_path, fullpaths, expected): + abs_fullpaths = {k: str(tmp_path / v) for k, v in fullpaths.items()} + + result = _build_import_name_to_path(abs_fullpaths, str(tmp_path)) + + assert result == expected + + +def test_build_import_name_to_path_drops_ambiguous(tmp_path): + fullpaths = { + "a.foo": str(tmp_path / "a" / "foo.py"), + "b.foo": str(tmp_path / "b" / "foo.py"), + } + + result = _build_import_name_to_path(fullpaths, str(tmp_path)) + + assert "foo" not in result + + +def test_build_import_name_to_path_init_package_name(tmp_path): + fullpaths = { + "mypkg.__init__": str(tmp_path / "mypkg" / "__init__.py"), + } + + result = _build_import_name_to_path(fullpaths, str(tmp_path)) + + assert result["mypkg"] == os.path.join("mypkg", "__init__.py") + assert "__init__" not in result + + +# --------------------------------------------------------------------------- +# _invert_dependency_map +# --------------------------------------------------------------------------- + +@mark.parametrize( + "module_imports,module_to_path,expected_dependents", + [ + ( + { + "app.main": {"app.service", "os"}, + "app.service": {"app.helpers"}, + }, + { + "app.main": "app/main.py", + "app.service": "app/service.py", + "app.helpers": "app/helpers.py", + }, + { + "app/service.py": {"app/main.py"}, + "app/helpers.py": {"app/service.py"}, + }, + ), + ( + { + "app.main": {"app.service", "os", "requests"}, + "app.service": {"numpy"}, + }, + { + "app.main": "app/main.py", + "app.service": "app/service.py", + }, + { + "app/service.py": {"app/main.py"}, + }, + ), + ( + { + "app.main": {"app.service"}, + "app.service": set(), + }, + { + "app.main": "app/main.py", + "app.service": "app/service.py", + }, + { + "app/service.py": {"app/main.py"}, + }, + ), + ( + { + "a.f": {"b.g", "c.h"}, + "b.g": {"c.h"}, + }, + None, + { + "b.g": {"a.f"}, + "c.h": {"a.f", "b.g"}, + }, + ), + ]) +def test_invert_dependency_map_cases(module_imports,module_to_path,expected_dependents): + graph = _invert_dependency_map(module_imports, module_to_path) + + assert graph == expected_dependents + + +@mark.parametrize( + "connections,module_to_path,import_name_to_path,expected", + [ + param( + {"tests.test_app": {"text_toolkit"}}, + { + "tests.test_app": "tests/test_app.py", + "src.text_toolkit.__init__": "src/text_toolkit/__init__.py", + }, + {"text_toolkit": "src/text_toolkit/__init__.py"}, + {"src/text_toolkit/__init__.py": {"tests/test_app.py"}}, + id="fallback-resolves", + ), + param( + {"tests.test_app": {"mypkg.service"}}, + { + "tests.test_app": "tests/test_app.py", + "mypkg.service": "mypkg/service.py", + }, + {"service": "mypkg/service.py"}, + {"mypkg/service.py": {"tests/test_app.py"}}, + id="exact-match-wins", + ), + param( + {"tests.test_app": {"stdlib_module"}}, + {"tests.test_app": "tests/test_app.py"}, + {}, + {}, + id="fallback-also-fails", + ), + ], +) +def test_invert_dependency_map_fallback(connections, module_to_path, import_name_to_path, expected): + result = _invert_dependency_map(connections, module_to_path, import_name_to_path) + assert result == expected + + +# --------------------------------------------------------------------------- +# _extract_function_nodes +# --------------------------------------------------------------------------- + + +def test_extract_function_nodes_keeps_classes_and_methods(tmp_path): + _write(tmp_path, "pkg/__init__.py") + _write( + tmp_path, + "pkg/mod.py", + "class Calculator:\n" + " def add(self, a, b):\n" + " return a + b\n" + "\n" + "def make_calc():\n" + " return Calculator()\n", + ) + + graph = CallGraphVisitor( + filenames=[str(tmp_path / "pkg" / "mod.py")], + root=str(tmp_path), + logger=logging.getLogger(__name__), + ) + nodes, functions_by_file = _extract_function_nodes(graph, str(tmp_path)) + + assert set(nodes) == { + "pkg.mod.Calculator", + "pkg.mod.Calculator.add", + "pkg.mod.make_calc", + } + assert nodes["pkg.mod.Calculator.add"] == FunctionMetadata( + filepath=os.path.join("pkg", "mod.py"), + start_line=2, + end_line=3, + ) + assert nodes["pkg.mod.make_calc"] == FunctionMetadata( + filepath=os.path.join("pkg", "mod.py"), + start_line=5, + end_line=6, + ) + assert functions_by_file == { + os.path.join("pkg", "mod.py"): { + "pkg.mod.Calculator", + "pkg.mod.Calculator.add", + "pkg.mod.make_calc", + } + } + + +# --------------------------------------------------------------------------- +# get_dependency_graph (end-to-end) +# --------------------------------------------------------------------------- + + +def _build_sample_project(repo): + root = Path(repo.working_tree_dir) + _write(root, "mypkg/__init__.py") + _write( + root, + "mypkg/service.py", + "import os\n\ndef run():\n return os.getcwd()\n", + ) + _write(root, "mypkg/main.py", "from .service import run\n") + _write(root, "tests/test_app.py", "from mypkg.service import run\n") + _write(root, ".venv/skip.py", "import os\n") + _write(root, "build/gen.py", "print('generated')\n") + + +def test_get_dependency_graph_end_to_end(git_repo): + _build_sample_project(git_repo) + + graph = get_dependency_graph(git_repo.working_tree_dir, graph_level=DIFF_LEVEL.FILE) + + assert graph.dependents["mypkg/service.py"] == { + "mypkg/main.py", + "tests/test_app.py", + } + assert graph.dependents["mypkg/__init__.py"] == { + "mypkg/main.py", + "tests/test_app.py", + } + + +def test_get_dependency_graph_drops_stdlib_and_non_repo_files(git_repo): + _build_sample_project(git_repo) + + graph = get_dependency_graph(git_repo.working_tree_dir, graph_level=DIFF_LEVEL.FILE) + + for filepath in graph.dependents: + assert filepath.endswith(".py") + assert not any( + part in filepath + for part in (".venv", "build", "__pycache__", ".git") + ) + assert not any("os" in filepath for filepath in graph.dependents) + + +def test_get_dependency_graph_leaf_module_has_no_entry(git_repo): + _build_sample_project(git_repo) + + graph = get_dependency_graph(git_repo.working_tree_dir, graph_level=DIFF_LEVEL.FILE) + + assert "mypkg/main.py" not in graph.dependents + assert "tests/test_app.py" not in graph.dependents + + +def test_get_dependency_graph_defaults_to_dot_repo(monkeypatch, git_repo): + _build_sample_project(git_repo) + monkeypatch.setattr( + "src.pytest_regsmart.selection.deps_graph.resolve_repo", lambda repo_path=".": git_repo + ) + + graph = get_dependency_graph(graph_level=DIFF_LEVEL.FILE) + + assert graph.dependents["mypkg/service.py"] == { + "mypkg/main.py", + "tests/test_app.py", + } + + +# --------------------------------------------------------------------------- +# get_dependency_graph (src-layout end-to-end) +# --------------------------------------------------------------------------- + + +def _build_src_layout_project(repo): + root = Path(repo.working_tree_dir) + _write(root, "src/mypkg/__init__.py", "from mypkg.service import run\n") + _write( + root, + "src/mypkg/service.py", + "def run():\n return 42\n", + ) + _write(root, "tests/test_app.py", "from mypkg.service import run\n") + _write(root, "tests/test_other.py", "from mypkg import service\n") + + +def test_get_dependency_graph_src_layout(git_repo): + _build_src_layout_project(git_repo) + + graph = get_dependency_graph(git_repo.working_tree_dir, graph_level=DIFF_LEVEL.FILE) + + assert "tests/test_app.py" in graph.dependents["src/mypkg/service.py"] + assert "tests/test_other.py" in graph.dependents["src/mypkg/__init__.py"] + + +def test_get_dependency_graph_src_layout_import_submodule(git_repo): + root = Path(git_repo.working_tree_dir) + _write(root, "src/pkg/__init__.py") + _write(root, "src/pkg/core.py", "def compute():\n return 1\n") + _write(root, "tests/test_core.py", "from pkg.core import compute\n") + + graph = get_dependency_graph(git_repo.working_tree_dir, graph_level=DIFF_LEVEL.FILE) + + assert "tests/test_core.py" in graph.dependents["src/pkg/core.py"] + + +# --------------------------------------------------------------------------- +# get_dependency_graph (function-level) +# --------------------------------------------------------------------------- + + +def _build_function_sample_project(repo): + root = Path(repo.working_tree_dir) + _write(root, "mypkg/__init__.py") + _write( + root, + "mypkg/service.py", + "import os\n" + "\n" + "def run():\n" + " return os.getcwd()\n" + "\n" + "def helper():\n" + " return 42\n", + ) + _write( + root, + "mypkg/main.py", + "from .service import run\n" + "\n" + "def main():\n" + " return run()\n", + ) + _write( + root, + "tests/test_app.py", + "from mypkg.service import run, helper\n" + "\n" + "def test_app():\n" + " assert run()\n" + "\n" + "def test_helper():\n" + " assert helper()\n", + ) + + +def test_get_function_dependency_graph_end_to_end(git_repo): + _build_function_sample_project(git_repo) + + graph = get_dependency_graph(git_repo.working_tree_dir, graph_level=DIFF_LEVEL.FUNCTION) + + assert graph.dependents == { + "mypkg.service.run": {"mypkg.main.main", "tests.test_app.test_app"}, + "mypkg.service.helper": {"tests.test_app.test_helper"}, + } + + +def test_get_function_dependency_graph_keys_are_unit_ids(git_repo): + _build_function_sample_project(git_repo) + + graph = get_dependency_graph(git_repo.working_tree_dir, graph_level=DIFF_LEVEL.FUNCTION) + + assert "mypkg/service.py" not in graph.dependents + assert all("/" not in key and not key.endswith(".py") for key in graph.dependents) + assert set(graph.dependents) <= set(graph.function_nodes) + + +def test_get_function_dependency_graph_locations(git_repo): + _build_function_sample_project(git_repo) + + graph = get_dependency_graph(git_repo.working_tree_dir, graph_level=DIFF_LEVEL.FUNCTION) + + assert graph.function_nodes["mypkg.service.run"] == FunctionMetadata( + filepath="mypkg/service.py", start_line=3, end_line=4 + ) + assert graph.function_nodes["mypkg.service.helper"] == FunctionMetadata( + filepath="mypkg/service.py", start_line=6, end_line=7 + ) + assert graph.function_nodes["tests.test_app.test_helper"] == FunctionMetadata( + filepath="tests/test_app.py", start_line=6, end_line=7 + ) + + +def test_get_dependency_graph_defaults_to_function_level(git_repo): + _build_function_sample_project(git_repo) + + graph = get_dependency_graph(git_repo.working_tree_dir) + + assert "mypkg.service.run" in graph.dependents + assert graph.function_nodes + assert "mypkg/service.py" not in graph.dependents diff --git a/tests/selection/test_git.py b/tests/selection/test_git.py new file mode 100644 index 0000000..d8f40a6 --- /dev/null +++ b/tests/selection/test_git.py @@ -0,0 +1,422 @@ +from __future__ import annotations + +import os +from pathlib import Path + +import pytest +from git import Repo +from git.exc import InvalidGitRepositoryError + +from src.pytest_regsmart.const import DIFF_LEVEL +from src.pytest_regsmart.selection.git_manager import ( + get_default_repo_branch, + get_git_diff, + parse_diff_output, + verify_git_repo, +) + + +def test_verify_git_repo_true(git_repo, commit_file): + commit_file("file_a.py") + + assert verify_git_repo(str(git_repo.working_tree_dir)) is True + + +def test_verify_git_repo_false(tmp_path): + assert verify_git_repo(str(tmp_path)) is False + + +def test_verify_git_repo_nonexistent_path(tmp_path): + assert verify_git_repo(str(tmp_path / "does-not-exist")) is False + + +def test_commits_ahead_of_main(git_repo, commit_file): + commit_file("file_a.py") + git_repo.git.branch("-m", "main") + git_repo.git.checkout("-b", "feature") + commit_file("file_b.py") + + result = get_git_diff(str(git_repo.working_tree_dir)) + + assert "file_b.py" in result.modified_files + assert "file_a.py" not in result.modified_files + + +def test_with_unstaged_changes(git_repo, commit_file): + commit_file("file_a.py", "original") + git_repo.git.branch("-m", "main") + filepath = Path(git_repo.working_tree_dir) / "file_a.py" + filepath.write_text("modified") + + result = get_git_diff(str(git_repo.working_tree_dir)) + + assert "file_a.py" in result.modified_files + + +def test_with_untracked_files(git_repo, commit_file): + commit_file("file_a.py") + git_repo.git.branch("-m", "main") + untracked = Path(git_repo.working_tree_dir) / "new_file.py" + untracked.write_text("new") + + result = get_git_diff(str(git_repo.working_tree_dir)) + + assert "new_file.py" in result.untracked_files + assert "file_a.py" not in result.modified_files + + +def test_combined(git_repo, commit_file): + commit_file("file_a.py", "original") + git_repo.git.branch("-m", "main") + git_repo.git.checkout("-b", "feature") + commit_file("file_b.py") + (Path(git_repo.working_tree_dir) / "file_a.py").write_text("unstaged") + (Path(git_repo.working_tree_dir) / "new_file.py").write_text("untracked") + + result = get_git_diff(str(git_repo.working_tree_dir)) + + assert "file_b.py" in result.modified_files + assert "file_a.py" in result.modified_files + assert "new_file.py" in result.untracked_files + + +def test_no_changes(git_repo, commit_file): + commit_file("file_a.py") + git_repo.git.branch("-m", "main") + + result = get_git_diff(str(git_repo.working_tree_dir)) + + assert result.modified_files == [] + assert result.untracked_files == [] + + +def test_not_a_repo(tmp_path): + with pytest.raises(InvalidGitRepositoryError): + get_git_diff(str(tmp_path)) + + +def test_default_branch_is_main(git_repo, commit_file): + commit_file("file_a.py") + git_repo.git.branch("-m", "main") + + assert get_default_repo_branch(git_repo) == "main" + + +def test_default_branch_is_master(git_repo, commit_file): + commit_file("file_a.py") + git_repo.git.branch("-m", "master") + + assert get_default_repo_branch(git_repo) == "master" + + +def test_default_branch_feature_prefers_local_main(git_repo, commit_file): + commit_file("file_a.py") + git_repo.git.branch("-m", "main") + git_repo.git.checkout("-b", "feature") + + assert get_default_repo_branch(git_repo) == "main" + + +def test_default_branch_detached_with_local_main(git_repo, commit_file): + commit_file("file_a.py") + git_repo.git.branch("-m", "main") + git_repo.git.checkout("--detach") + + assert get_default_repo_branch(git_repo) == "main" + + +def test_get_git_diff_usage_error_when_no_base_destination(git_repo, commit_file): + commit_file("file_a.py") + git_repo.git.branch("-m", "develop") + + with pytest.raises(pytest.UsageError): + get_git_diff(str(git_repo.working_tree_dir)) + + +def test_default_branch_uses_upstream_tracking(git_repo, commit_file): + commit_file("file_a.py") + git_repo.git.branch("-m", "main") + git_repo.git.checkout("-b", "dev") + git_repo.git.branch("-u", "main", "dev") + + assert get_default_repo_branch(git_repo) == "main" + + +def test_default_branch_uses_remote_upstream_when_no_local_main(tmp_path): + origin = tmp_path / "origin" + origin.mkdir() + Repo.init(origin, bare=True) + + local = tmp_path / "local" + local_repo = Repo.init(local) + (local / "file_a.py").write_text("base\n") + local_repo.index.add(["file_a.py"]) + local_repo.index.commit("base") + local_repo.git.branch("-m", "main") + local_repo.create_remote("origin", str(origin)) + local_repo.git.push("--set-upstream", "origin", "main") + + local_repo.git.checkout("-b", "dev") + local_repo.git.push("--set-upstream", "origin", "dev") + local_repo.delete_head("main", force=True) # only 'dev' stays local + local_repo.git.branch("-u", "origin/main", "dev") # dev heads to origin/main + + # no local 'main'/'master' and no origin/HEAD -> must fall back to the upstream (origin/main) + assert get_default_repo_branch(local_repo) == "origin/main" + + +def test_default_branch_skips_self_mirror_upstream(tmp_path): + origin = tmp_path / "origin" + origin.mkdir() + Repo.init(origin, bare=True) + + local = tmp_path / "local" + local_repo = Repo.init(local) + (local / "file_a.py").write_text("base\n") + local_repo.index.add(["file_a.py"]) + local_repo.index.commit("base") + local_repo.git.branch("-m", "main") + local_repo.create_remote("origin", str(origin)) + local_repo.git.push("--set-upstream", "origin", "main") + + local_repo.git.checkout("-b", "feature") + local_repo.git.push("--set-upstream", "origin", "feature") + local_repo.delete_head("main", force=True) # only 'feature' stays local + + # upstream only mirrors the current branch (origin/feature) -> not a valid destination + assert get_default_repo_branch(local_repo) is None + + +def test_default_branch_from_github_base_ref(git_repo, commit_file, monkeypatch): + commit_file("file_a.py") + git_repo.git.branch("-m", "main") + git_repo.git.checkout("-b", "feature") + monkeypatch.setenv("GITHUB_BASE_REF", "main") + + assert get_default_repo_branch(git_repo) == "main" + + +def test_default_branch_detached_without_local_main_returns_none(git_repo, commit_file): + commit_file("file_a.py") + git_repo.git.branch("-m", "develop") + git_repo.git.checkout("--detach") + + assert get_default_repo_branch(git_repo) is None + + +def test_get_git_diff_no_merge_base_runs_full(git_repo, commit_file): + commit_file("file_a.py") + git_repo.git.branch("-m", "main") + git_repo.git.checkout("--orphan", "orphan-branch") + git_repo.git.rm("-r", "--cached", ".") + commit_file("orphan.py") + + result = get_git_diff(str(git_repo.working_tree_dir)) + + assert result.no_merge_base is True + assert result.modified_files == [] + + +def test_get_git_diff_no_commits(tmp_path): + Repo.init(tmp_path) + (tmp_path / "new_file.py").write_text("new") + + result = get_git_diff(str(tmp_path)) + + assert result.modified_files == [] + assert "new_file.py" in result.untracked_files + assert result.used_branch == "" + + +def _diff(*lines: str) -> str: + return "\n".join(lines) + + +@pytest.mark.parametrize( + ("raw_diff", "expected"), + [ + ( + _diff("--- a/a.py", "+++ b/a.py", "@@ -9,9 +9,9 @@", "-old", "+new"), + {"a.py": [(9, 17)]}, + ), + ( + _diff( + "+++ b/a.py", + "@@ -9,9 +9,9 @@", + "-old", + "+new", + "@@ -20,2 +21,2 @@", + "-x", + "+y", + ), + {"a.py": [(9, 17), (21, 22)]}, + ), + ( + _diff( + "+++ b/a.py", + "@@ -9,9 +9,9 @@", + "-old", + "+new", + "+++ b/b.py", + "@@ -1,26 +1,26 @@", + "-a", + "+b", + ), + {"a.py": [(9, 17)], "b.py": [(1, 26)]}, + ), + ( + _diff("+++ b/novo.py", "@@ -0,0 +1,26 @@", "+def a():", "+ pass"), + {"novo.py": [(1, 26)]}, + ), + ( + _diff("+++ b/a.py", "@@ -10,3 +9,0 @@", "- a", "- b", "- c"), + {"a.py": [(9, 9)]}, + ), + ( + _diff( + "+++ b/a.py", + "@@ -1,3 +1,3 @@", + "-a", + "+b", + "+++ /dev/null", + "@@ -1,30 +0,0 @@", + "- gone", + ), + {"a.py": [(1, 3)]}, + ), + (_diff("+++ b/a.py", "@@ -18 +18 @@", "-x", "+y"), {"a.py": [(18, 18)]}), + ( + _diff( + "--- a/x.py", + "+++ b/x.py", + "@@ -1,3 +1,4 @@", + " def a():", + "- return 1", + "+ return 2", + "+++ comentario estranho", + "+ mais codigo", + "@@ -20,2 +21,2 @@", + "-def b():", + "+def b():", + ), + {"x.py": [(1, 4), (21, 22)]}, + ), + ], + ids=[ + "single hunk edit", + "multiple hunks same file", + "multiple files", + "new file addition", + "pure deletion anchors on new_start", + "deleted file ignored and does not contaminate", + "hunk without count", + "added line starting with '++ ' does not corrupt", + ], +) +def test_parse_diff_output(raw_diff, expected): + assert parse_diff_output(raw_diff) == expected + + +@pytest.mark.parametrize( + ("original", "edited", "expected"), + [ + ("a\nb\nc\n", "a\nB\nc\n", {"file.py": [(2, 2)]}), + ("a\nb\nc\n", "a\nb\nX\nY\nc\n", {"file.py": [(3, 4)]}), + ("a\nb\nc\n", "a\nc\n", {"file.py": [(1, 1)]}), + ], + ids=[ + "single line edit", + "insert two lines", + "delete one line anchors on new_start", + ], +) +def test_get_git_diff_changed_line_ranges_edits(git_repo, commit_file, original, edited, expected): + commit_file("file.py", original) + git_repo.git.branch("-m", "main") + (Path(git_repo.working_tree_dir) / "file.py").write_text(edited) + + assert get_git_diff(str(git_repo.working_tree_dir), diff_level=DIFF_LEVEL.FUNCTION).changed_line_ranges == expected + + +def test_get_git_diff_changed_line_ranges_committed_on_feature_branch(git_repo, commit_file): + commit_file("file.py", "a\nb\nc\n") + git_repo.git.branch("-m", "main") + git_repo.git.checkout("-b", "feature") + filepath = Path(git_repo.working_tree_dir) / "file.py" + filepath.write_text("a\nB\nc\n") + git_repo.index.add("file.py") + git_repo.index.commit("edit line 2") + + assert get_git_diff(str(git_repo.working_tree_dir), diff_level=DIFF_LEVEL.FUNCTION).changed_line_ranges == {"file.py": [(2, 2)]} + + +def test_get_git_diff_changed_line_ranges_deleted_file_absent(git_repo, commit_file): + commit_file("file.py", "a\nb\nc\n") + git_repo.git.branch("-m", "main") + os.remove(Path(git_repo.working_tree_dir) / "file.py") + + assert get_git_diff(str(git_repo.working_tree_dir), diff_level=DIFF_LEVEL.FUNCTION).changed_line_ranges == {} + + +def test_get_git_diff_changed_line_ranges_no_commits(tmp_path): + Repo.init(tmp_path) + + assert get_git_diff(str(tmp_path), diff_level=DIFF_LEVEL.FUNCTION).changed_line_ranges == {} + + +def test_get_git_diff_deleted_file(git_repo, commit_file): + commit_file("file_a.py") + commit_file("file_b.py") + git_repo.git.branch("-m", "main") + os.remove(Path(git_repo.working_tree_dir) / "file_a.py") + + result = get_git_diff(str(git_repo.working_tree_dir)) + + assert result.deleted_files == ["file_a.py"] + assert "file_a.py" in result.modified_files + + +def test_get_git_diff_no_deleted_files(git_repo, commit_file): + commit_file("file_a.py") + git_repo.git.branch("-m", "main") + + result = get_git_diff(str(git_repo.working_tree_dir)) + + assert result.deleted_files == [] + + +def test_shallow_single_branch_clone_raises_usage_error(tmp_path): + """Reproduces GitHub Actions default checkout (fetch-depth: 1, single branch): + only the pushed branch exists locally, so no base destination is available.""" + origin = tmp_path / "origin" + origin.mkdir() + Repo.init(origin, bare=True) + + local = tmp_path / "local" + local_repo = Repo.init(local) + (local / "file_a.py").write_text("base\n") + local_repo.index.add(["file_a.py"]) + local_repo.index.commit("base") + local_repo.git.branch("-m", "main") + local_repo.create_remote("origin", str(origin)) + local_repo.git.push("--set-upstream", "origin", "main") + + local_repo.git.checkout("-b", "feature") + (local / "file_b.py").write_text("new\n") + local_repo.index.add(["file_b.py"]) + local_repo.index.commit("feature commit") + local_repo.git.push("--set-upstream", "origin", "feature") + + # shallow clone of only the feature branch (depth 1), like actions/checkout on push + shallow_path = tmp_path / "shallow" + Repo.clone_from( + str(origin), + str(shallow_path), + depth=1, + single_branch=True, + branch="feature", + ) + + assert get_default_repo_branch(Repo(shallow_path)) is None + with pytest.raises(pytest.UsageError): + get_git_diff(str(shallow_path)) diff --git a/tests/selection/test_selection.py b/tests/selection/test_selection.py new file mode 100644 index 0000000..90100a0 --- /dev/null +++ b/tests/selection/test_selection.py @@ -0,0 +1,375 @@ +import os +from pathlib import Path + +import pytest + +from src.pytest_regsmart.const import DIFF_LEVEL +from src.pytest_regsmart.selection.deps_graph import ( + DependencyGraph, + FunctionMetadata, +) +from src.pytest_regsmart.selection.git_manager import DiffResult +from src.pytest_regsmart.selection.selector import ( + _function_id_to_pytest_nodeid, + _get_affected_tests_at_file_level, + _get_affected_tests_at_function_level, + line_diff_match_function_ids, + run_rts, +) + + +def test_get_affected_tests_reaches_indirect_test_files_dependents(): + deps_graph = DependencyGraph( + dependents={ + "service.py": {"ranker.py"}, + "ranker.py": {"main.py"}, + "main.py": {"test_app.py"}, + } + ) + diff_result = DiffResult( + modified_files=["service.py"], + untracked_files=[], + used_branch="main", + ) + + result = _get_affected_tests_at_file_level(diff_result, deps_graph) + + assert result == ["test_app.py"] + + +def test_function_level_selection_returns_pytest_nodeids(): + deps_graph = DependencyGraph( + dependents={"app.service.run": {"tests.test_app.test_run"}}, + function_nodes={ + "app.service.run": FunctionMetadata("app/service.py", 1, 2), + "tests.test_app.test_run": FunctionMetadata("tests/test_app.py", 3, 4), + }, + functions_by_file={"app/service.py": {"app.service.run"}}, + ) + diff_result = DiffResult( + modified_files=["app/service.py"], + used_branch="main", + changed_line_ranges={"app/service.py": [(1, 1)]}, + ) + + result = _get_affected_tests_at_function_level(diff_result, deps_graph) + + assert result == ["tests/test_app.py::test_run"] + + +# --------------------------------------------------------------------------- +# line_diff_match_function_ids +# --------------------------------------------------------------------------- + + +def _function_graph() -> DependencyGraph: + return DependencyGraph( + dependents={ + "app.service.run": {"tests.test_app.test_run"}, + "app.service.helper": {"tests.test_app.test_helper"}, + "app.new.fresh": set(), + }, + function_nodes={ + "app.service.run": FunctionMetadata("app/service.py", 3, 4), + "app.service.helper": FunctionMetadata("app/service.py", 6, 7), + "app.new.fresh": FunctionMetadata("app/new.py", 1, 2), + "tests.test_app.test_run": FunctionMetadata("tests/test_app.py", 3, 4), + "tests.test_app.test_helper": FunctionMetadata("tests/test_app.py", 6, 7), + }, + functions_by_file={ + "app/service.py": {"app.service.run", "app.service.helper"}, + "app/new.py": {"app.new.fresh"}, + "tests/test_app.py": { + "tests.test_app.test_run", + "tests.test_app.test_helper", + }, + }, + ) + + +def test_line_diff_matches_only_touched_function(): + deps_graph = _function_graph() + diff_result = DiffResult( + modified_files=["app/service.py"], + used_branch="main", + changed_line_ranges={"app/service.py": [(6, 7)]}, + ) + + changed = line_diff_match_function_ids(diff_result, deps_graph) + + assert changed == {"app.service.helper"} + + +def test_line_diff_spanning_functions_matches_all_of_them(): + deps_graph = _function_graph() + diff_result = DiffResult( + modified_files=["app/service.py"], + used_branch="main", + changed_line_ranges={"app/service.py": [(3, 7)]}, + ) + + changed = line_diff_match_function_ids(diff_result, deps_graph) + + assert changed == {"app.service.run", "app.service.helper"} + + +def test_line_diff_outside_any_function_falls_back_to_whole_file(): + deps_graph = _function_graph() + diff_result = DiffResult( + modified_files=["app/service.py"], + used_branch="main", + changed_line_ranges={"app/service.py": [(1, 1)]}, + ) + + changed = line_diff_match_function_ids(diff_result, deps_graph) + + assert changed == {"app.service.run", "app.service.helper"} + + +def test_line_diff_modified_file_without_ranges_uses_whole_file(): + deps_graph = _function_graph() + diff_result = DiffResult( + modified_files=["app/service.py"], + used_branch="main", + ) + + changed = line_diff_match_function_ids(diff_result, deps_graph) + + assert changed == {"app.service.run", "app.service.helper"} + + +def test_line_diff_untracked_file_marks_all_its_functions(): + deps_graph = _function_graph() + diff_result = DiffResult( + modified_files=[], + untracked_files=["app/new.py"], + used_branch="main", + ) + + changed = line_diff_match_function_ids(diff_result, deps_graph) + + assert changed == {"app.new.fresh"} + + +def test_line_diff_file_absent_from_graph_contributes_nothing(): + deps_graph = _function_graph() + diff_result = DiffResult( + modified_files=["deleted/service.py"], + used_branch="main", + ) + + changed = line_diff_match_function_ids(diff_result, deps_graph) + + assert changed == set() + + +# --------------------------------------------------------------------------- +# _function_id_to_pytest_nodeid +# --------------------------------------------------------------------------- + + +def test_nodeid_conversion_for_plain_function(): + nodeid = _function_id_to_pytest_nodeid( + "tests.test_app.test_run", "tests/test_app.py" + ) + + assert nodeid == "tests/test_app.py::test_run" + + +def test_nodeid_conversion_for_class_method(): + nodeid = _function_id_to_pytest_nodeid( + "pkg.mod.Calculator.add", "pkg/mod.py" + ) + + assert nodeid == "pkg/mod.py::Calculator::add" + + +# --------------------------------------------------------------------------- +# run_rts (end-to-end on a real repo) +# --------------------------------------------------------------------------- + + +def _commit_sample_project(git_repo, commit_file): + commit_file("service.py", "def run():\n return 42\n") + commit_file( + "test_app.py", + "from service import run\n" + "\n" + "def test_run():\n" + " assert run()\n", + ) + git_repo.git.branch("-m", "main") + + +def test_run_rts_function_level_returns_full_nodeids( + git_repo, commit_file, monkeypatch +): + _commit_sample_project(git_repo, commit_file) + Path(git_repo.working_tree_dir, "service.py").write_text( + "def run():\n return 0\n" + ) + monkeypatch.chdir(git_repo.working_tree_dir) + + result = run_rts(DIFF_LEVEL.FUNCTION) + + assert result.affected_tests == ["test_app.py::test_run"] + assert result.has_diff is True + assert result.branch == "main" + + +def test_run_rts_without_diff_selects_nothing(git_repo, commit_file, monkeypatch): + _commit_sample_project(git_repo, commit_file) + monkeypatch.chdir(git_repo.working_tree_dir) + + result = run_rts(DIFF_LEVEL.FUNCTION) + + assert result.affected_tests == [] + assert result.has_diff is False + assert result.branch == "main" + assert result.full_run is False + + +# --------------------------------------------------------------------------- +# run_rts (conftest -> full suite) +# --------------------------------------------------------------------------- + + +def _write_untracked(repo, relpath: str, content: str = "") -> Path: + filepath = Path(repo.working_tree_dir) / relpath + filepath.parent.mkdir(parents=True, exist_ok=True) + filepath.write_text(content) + return filepath + + +def _commit_sample_project_with_conftest(git_repo, commit_file): + _commit_sample_project(git_repo, commit_file) + commit_file("conftest.py", "# shared fixtures\n") + + +@pytest.mark.parametrize("level", [DIFF_LEVEL.FILE, DIFF_LEVEL.FUNCTION]) +def test_run_rts_conftest_untracked_triggers_full_run(git_repo, commit_file, monkeypatch, level): + _commit_sample_project(git_repo, commit_file) + _write_untracked(git_repo, "conftest.py", "# shared fixtures\n") + monkeypatch.chdir(git_repo.working_tree_dir) + + result = run_rts(level) + + assert result.full_run is True + assert result.affected_tests == [] + assert result.has_diff is True + assert result.branch == "main" + + +@pytest.mark.parametrize("level", [DIFF_LEVEL.FILE, DIFF_LEVEL.FUNCTION]) +def test_run_rts_conftest_untracked_with_modified_file_triggers_full_run( + git_repo, commit_file, monkeypatch, level +): + _commit_sample_project(git_repo, commit_file) + Path(git_repo.working_tree_dir, "service.py").write_text( + "def run():\n return 0\n" + ) + _write_untracked(git_repo, "conftest.py", "# shared fixtures\n") + monkeypatch.chdir(git_repo.working_tree_dir) + + result = run_rts(level) + + assert result.full_run is True + assert result.affected_tests == [] + + +@pytest.mark.parametrize("level", [DIFF_LEVEL.FILE, DIFF_LEVEL.FUNCTION]) +def test_run_rts_conftest_tracked_modified_triggers_full_run( + git_repo, commit_file, monkeypatch, level +): + _commit_sample_project_with_conftest(git_repo, commit_file) + Path(git_repo.working_tree_dir, "conftest.py").write_text( + "# shared fixtures (edited)\n" + ) + monkeypatch.chdir(git_repo.working_tree_dir) + + result = run_rts(level) + + assert result.full_run is True + assert result.affected_tests == [] + + +@pytest.mark.parametrize("level", [DIFF_LEVEL.FILE, DIFF_LEVEL.FUNCTION]) +def test_run_rts_conftest_tracked_deleted_triggers_full_run( + git_repo, commit_file, monkeypatch, level +): + _commit_sample_project_with_conftest(git_repo, commit_file) + os.remove(Path(git_repo.working_tree_dir, "conftest.py")) + monkeypatch.chdir(git_repo.working_tree_dir) + + result = run_rts(level) + + assert result.full_run is True + assert result.affected_tests == [] + + +@pytest.mark.parametrize("level", [DIFF_LEVEL.FILE, DIFF_LEVEL.FUNCTION]) +def test_run_rts_conftest_in_subdirectory_untracked_triggers_full_run( + git_repo, commit_file, monkeypatch, level +): + _commit_sample_project(git_repo, commit_file) + _write_untracked(git_repo, "tests/conftest.py", "# shared fixtures\n") + monkeypatch.chdir(git_repo.working_tree_dir) + + result = run_rts(level) + + assert result.full_run is True + assert result.affected_tests == [] + + +@pytest.mark.parametrize( + ("level", "expected"), + [ + (DIFF_LEVEL.FILE, ["test_app.py"]), + (DIFF_LEVEL.FUNCTION, ["test_app.py::test_run"]), + ], +) +def test_run_rts_without_conftest_selects_normally(git_repo, commit_file, monkeypatch, level, expected): + _commit_sample_project(git_repo, commit_file) + Path(git_repo.working_tree_dir, "service.py").write_text( + "def run():\n return 0\n" + ) + monkeypatch.chdir(git_repo.working_tree_dir) + + result = run_rts(level) + + assert result.full_run is False + assert result.affected_tests == expected + + +def test_run_rts_conftest_does_not_log_selection_time(git_repo, commit_file, monkeypatch): + _commit_sample_project(git_repo, commit_file) + _write_untracked(git_repo, "conftest.py", "# shared fixtures\n") + monkeypatch.chdir(git_repo.working_tree_dir) + log_dict: dict = {} + + run_rts(DIFF_LEVEL.FUNCTION, log_dict=log_dict) + + assert "Time to run the regression test selection (s)" not in log_dict + + +def test_run_rts_without_diff_does_not_log_selection_time(git_repo, commit_file, monkeypatch): + _commit_sample_project(git_repo, commit_file) + monkeypatch.chdir(git_repo.working_tree_dir) + log_dict: dict = {} + + run_rts(DIFF_LEVEL.FUNCTION, log_dict=log_dict) + + assert "Time to run the regression test selection (s)" not in log_dict + + +def test_run_rts_logs_selection_time_when_selecting(git_repo, commit_file, monkeypatch): + _commit_sample_project(git_repo, commit_file) + Path(git_repo.working_tree_dir, "service.py").write_text( + "def run():\n return 0\n" + ) + monkeypatch.chdir(git_repo.working_tree_dir) + log_dict: dict = {} + + run_rts(DIFF_LEVEL.FUNCTION, log_dict=log_dict) + + assert "Time to run the regression test selection (s)" in log_dict diff --git a/tests/selection/test_selection_args.py b/tests/selection/test_selection_args.py new file mode 100644 index 0000000..2f6fc67 --- /dev/null +++ b/tests/selection/test_selection_args.py @@ -0,0 +1,56 @@ +import argparse + +import pytest + +from src.pytest_regsmart.const import DIFF_LEVEL +from src.pytest_regsmart.selection.selection_args import ( + level_type, + parse_diff_level, +) + + +class _FakeConfig: + def __init__(self, option: DIFF_LEVEL, ini: str = ""): + self._option = option + self._ini = ini + + def getoption(self, _name: str) -> DIFF_LEVEL: + return self._option + + def getini(self, _name: str) -> str: + return self._ini + + +def test_level_type_accepts_valid_levels(): + assert level_type("file") is DIFF_LEVEL.FILE + assert level_type("function") is DIFF_LEVEL.FUNCTION + + +def test_level_type_rejects_invalid_level(): + with pytest.raises(argparse.ArgumentTypeError, match="--diff-level"): + level_type("line") + + +def test_parse_diff_level_cli_value_wins_over_ini(): + config = _FakeConfig(option=DIFF_LEVEL.FILE, ini="function") + + assert parse_diff_level(config) is DIFF_LEVEL.FILE + + +def test_parse_diff_level_ini_used_when_cli_has_default(): + config = _FakeConfig(option=DIFF_LEVEL.FUNCTION, ini="file") + + assert parse_diff_level(config) is DIFF_LEVEL.FILE + + +def test_parse_diff_level_default_when_ini_unset(): + config = _FakeConfig(option=DIFF_LEVEL.FUNCTION, ini="") + + assert parse_diff_level(config) is DIFF_LEVEL.FUNCTION + + +def test_parse_diff_level_invalid_ini_value_raises_usage_error(): + config = _FakeConfig(option=DIFF_LEVEL.FUNCTION, ini="class") + + with pytest.raises(pytest.UsageError, match="diff_level"): + parse_diff_level(config) diff --git a/tests/selection/test_selector_opts.py b/tests/selection/test_selector_opts.py new file mode 100644 index 0000000..0eb77f0 --- /dev/null +++ b/tests/selection/test_selector_opts.py @@ -0,0 +1,335 @@ +from __future__ import annotations + +from pathlib import Path + +from tests.fake_test_data import test_method_one + + +def _ran_files(out) -> list[str]: + return sorted( + { + line.split("::")[0] + for line in out.outlines + if "::" in line and ("PASSED" in line or "FAILED" in line) + } + ) + + +def _ran_nodeids(out) -> list[str]: + return sorted( + line.split(" ")[0] + for line in out.outlines + if "::" in line and "PASSED" in line + ) + + +def _change(repo, filename: str, content: str) -> None: + (Path(repo.working_tree_dir) / filename).write_text(content) + + +def _commit_new_file(repo, filename: str, content: str) -> None: + (Path(repo.working_tree_dir) / filename).write_text(content) + repo.index.add([filename]) + repo.index.commit(f"Add {filename}") + + +def test_regsmart_requires_git_repo(pytester): + pytester.makepyfile(test_method_one=test_method_one) + + out = pytester.runpytest("--regsmart") + + assert any("--regsmart requires a git repository." in x for x in out.errlines) + assert not any("::" in x and "PASSED" in x for x in out.outlines) + + +def test_selection_no_rank_only_affected_in_collection_order(selection_project): + pytester, repo = selection_project + _change(repo, "service.py", "def run():\n return 42 # changed\n") + + out = pytester.runpytest("-v", "--regsmart", "--no-rank") + + out.assert_outcomes(passed=3) + assert _ran_files(out) == ["test_other.py", "test_service.py"] + assert any("Using --no-rank (RTP disabled)." in x for x in out.outlines) + + +def test_function_level_selects_only_tests_of_changed_function(selection_project): + pytester, repo = selection_project + _commit_new_file( + repo, + "extra.py", + 'def alpha():\n return "a"\n\n\ndef beta():\n return "b"\n', + ) + _commit_new_file( + repo, + "test_extra.py", + "from extra import alpha, beta\n" + "\n" + 'def test_alpha():\n assert alpha() == "a"\n' + "\n" + 'def test_beta():\n assert beta() == "b"\n', + ) + _change( + repo, + "extra.py", + 'def alpha():\n return "a"\n\n\ndef beta():\n return "b" # touched\n', + ) + + out = pytester.runpytest("-v", "--regsmart", "--diff-level=function", "--no-rank") + + out.assert_outcomes(passed=1) + assert _ran_nodeids(out) == ["test_extra.py::test_beta"] + + +def test_function_level_runs_parametrized_variants_of_selected_node(selection_project): + pytester, repo = selection_project + _commit_new_file( + repo, + "test_params.py", + "import pytest\n" + "from service import run\n" + "\n" + '@pytest.mark.parametrize("value", [10, 20])\n' + "def test_run_value(value):\n" + " assert run() == 42\n", + ) + _change(repo, "service.py", "def run():\n return 42 # changed\n") + + out = pytester.runpytest("-v", "--regsmart", "--diff-level=function", "--no-rank") + + out.assert_outcomes(passed=5) + nodeids = _ran_nodeids(out) + assert "test_params.py::test_run_value[10]" in nodeids + assert "test_params.py::test_run_value[20]" in nodeids + + +def test_diff_level_ini_option_used_when_cli_absent(selection_project): + pytester, repo = selection_project + _commit_new_file( + repo, + "extra.py", + 'def alpha():\n return "a"\n\n\ndef beta():\n return "b"\n', + ) + _commit_new_file( + repo, + "test_extra.py", + "from extra import alpha, beta\n" + "\n" + 'def test_alpha():\n assert alpha() == "a"\n' + "\n" + 'def test_beta():\n assert beta() == "b"\n', + ) + _change( + repo, + "extra.py", + 'def alpha():\n return "a"\n\n\ndef beta():\n return "b" # touched\n', + ) + (Path(repo.working_tree_dir) / "pytest.ini").write_text( + "[pytest]\nconsole_output_style = classic\ndiff_level = file\n" + ) + + out = pytester.runpytest("-v", "--regsmart", "--no-rank") + + out.assert_outcomes(passed=2) + assert _ran_files(out) == ["test_extra.py"] + + +def test_invalid_rank_weight_ini_raises_usage_error(selection_project): + pytester, repo = selection_project + _change(repo, "service.py", "def run():\n return 42 # changed\n") + (Path(repo.working_tree_dir) / "pytest.ini").write_text( + "[pytest]\nconsole_output_style = classic\nrank_weight = 1-3-2\n" + ) + + out = pytester.runpytest("-v", "--regsmart") + + assert any("rank_weight" in x for x in out.errlines) + assert not any("::" in x and "PASSED" in x for x in out.outlines) + + +def test_invalid_diff_level_ini_raises_usage_error(selection_project): + pytester, repo = selection_project + (Path(repo.working_tree_dir) / "pytest.ini").write_text( + "[pytest]\nconsole_output_style = classic\ndiff_level = class\n" + ) + + out = pytester.runpytest("-v", "--regsmart") + + assert any("diff_level" in x for x in out.errlines) + + +def test_report_header_shows_diff_level(selection_project): + pytester, repo = selection_project + _change(repo, "service.py", "def run():\n return 42 # changed\n") + + out = pytester.runpytest("--regsmart") + + assert any("Using --diff-level=function" in x for x in out.outlines) + + +def test_selection_with_rank_levels(selection_project): + pytester, repo = selection_project + pytester.runpytest("-v") + _change(repo, "service.py", "def run():\n return 42 # changed\n") + + for level in ("put", "function", "module"): + out = pytester.runpytest("-v", "--regsmart", f"--rank-level={level}") + + out.assert_outcomes(passed=3) + assert _ran_files(out) == ["test_other.py", "test_service.py"] + + +def test_selection_with_replay_order(selection_project): + pytester, repo = selection_project + _change(repo, "service.py", "def run():\n return 42 # changed\n") + pytester.maketxtfile( + replay_order=""" + test_other.py::test_other_run + test_service.py::test_service_fast + test_service.py::test_service_slow + """ + ) + + out = pytester.runpytest("-v", "--regsmart", "--rank-replay=replay_order.txt") + + out.assert_outcomes(passed=3) + test_lines = [x for x in out.outlines if "::" in x and "PASSED" in x] + assert test_lines[0] == "test_other.py::test_other_run PASSED" + + +def test_selection_with_random_model(selection_project): + pytester, repo = selection_project + _change(repo, "service.py", "def run():\n return 42 # changed\n") + + out = pytester.runpytest( + "-v", "--regsmart", "--rank-weight=0-0", "--rank-seed=42" + ) + + out.assert_outcomes(passed=3) + assert _ran_files(out) == ["test_other.py", "test_service.py"] + + +def test_seed_fix_change_test_file_selects_only_it(selection_project): + pytester, repo = selection_project + _change(repo, "test_service.py", "from service import run\n\n" + "def test_service_fast():\n assert run() == 42\n") + + out = pytester.runpytest("-v", "--regsmart", "--no-rank") + + out.assert_outcomes(passed=1) + assert _ran_files(out) == ["test_service.py"] + + +def test_no_diff_with_rank_runs_everything(selection_project): + pytester, _repo = selection_project + + out = pytester.runpytest("-v", "--regsmart") + + out.assert_outcomes(passed=4) + assert any( + "No diff detected: regression test selection was skipped." in x + for x in out.outlines + ) + assert not any( + "Time to run the regression test selection (s)" in x for x in out.outlines + ) + + +def test_no_diff_with_no_rank_plugin_does_nothing(selection_project): + pytester, _repo = selection_project + + out = pytester.runpytest("-v", "--regsmart", "--no-rank") + + out.assert_outcomes(passed=4) + assert any( + "No diff detected and --no-rank enabled: pytest-regsmart is not doing anything." + in x for x in out.outlines + ) + + +def test_conftest_untracked_runs_full_suite(selection_project): + pytester, repo = selection_project + (Path(repo.working_tree_dir) / "conftest.py").write_text( + "# shared fixtures\n" + ) + + out = pytester.runpytest("-v", "--regsmart", "--no-rank") + + out.assert_outcomes(passed=4) + assert _ran_files(out) == [ + "test_other.py", + "test_service.py", + "test_unrelated.py", + ] + assert any( + "conftest.py changed: regression test selection was skipped." in x + for x in out.outlines + ) + assert not any( + "Time to run the regression test selection (s)" in x for x in out.outlines + ) + + +def test_conftest_untracked_with_modified_file_runs_full_suite(selection_project): + pytester, repo = selection_project + _change(repo, "service.py", "def run():\n return 42 # changed\n") + (Path(repo.working_tree_dir) / "conftest.py").write_text( + "# shared fixtures\n" + ) + + out = pytester.runpytest( + "-v", "--regsmart", "--diff-level=function", "--no-rank" + ) + + out.assert_outcomes(passed=4) + assert len(_ran_files(out)) == 3 + assert any( + "conftest.py changed: regression test selection was skipped." in x + for x in out.outlines + ) + assert not any( + "Time to run the regression test selection (s)" in x for x in out.outlines + ) + + +def test_conftest_only_warning_mentions_full_suite(selection_project): + pytester, repo = selection_project + (Path(repo.working_tree_dir) / "conftest.py").write_text( + "# shared fixtures\n" + ) + + out = pytester.runpytest("-v", "--regsmart") + + out.assert_outcomes(passed=4) + assert any( + "The full suite will run." in x for x in out.outlines + ) + + +def test_no_merge_base_runs_full_suite_with_warning(selection_project): + pytester, repo = selection_project + repo.git.checkout("--orphan", "orphan-branch") + repo.git.rm("-r", "--cached", ".") + for f in ".gitignore", "pytest.ini", "service.py", "test_service.py", "test_other.py", "test_unrelated.py": + repo.index.add([f]) + repo.index.commit("orphan baseline") + + # orphan branch shares no history with the base -> no merge base -> full suite + out = pytester.runpytest("-v", "--regsmart", "--no-rank") + + out.assert_outcomes(passed=4) + assert any( + "No shared history" in x for x in out.outlines + ) + + +def test_detached_head_without_base_raises_clean_usage_error(selection_project): + pytester, repo = selection_project + repo.git.branch("-m", "dev") + repo.git.checkout("--detach") + + # detached HEAD with no main/master, no origin/HEAD -> clean UsageError, no crash + out = pytester.runpytest("--regsmart") + + assert any("Unable to determine the base branch" in x for x in out.errlines) + assert not any("::" in x and "PASSED" in x for x in out.outlines) diff --git a/tests/selection/test_utils.py b/tests/selection/test_utils.py new file mode 100644 index 0000000..b4d3306 --- /dev/null +++ b/tests/selection/test_utils.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import os + +from src.pytest_regsmart.utils import _is_conftest + + +def test_is_conftest_root_level(): + assert _is_conftest("conftest.py") is True + + +def test_is_conftest_in_subdirectory(): + assert _is_conftest("tests/conftest.py") is True + assert _is_conftest(os.path.join("tests", "conftest.py")) is True + assert _is_conftest("tests/sub/conftest.py") is True + + +def test_is_conftest_rejects_other_files(): + assert _is_conftest("service.py") is False + assert _is_conftest("test_conftest.py") is False + assert _is_conftest("conftest.py.bak") is False + assert _is_conftest("CONFTEST.py") is False \ No newline at end of file diff --git a/tests/test_interop.py b/tests/test_interop.py new file mode 100644 index 0000000..6307a1d --- /dev/null +++ b/tests/test_interop.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from tests.fake_test_data import test_a_method, test_dependency, test_order + + +def test_order_dependency_marker(mytester): + mytester.makepyfile( + test_a_method=test_a_method, + test_order=test_order, + test_dependency=test_dependency, + ) + + args = ["-v"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=8, failed=1, xfailed=1) + + args = ["-v", "--regsmart"] + out = mytester.runpytest(*args) + + out.assert_outcomes(passed=8, failed=1, xfailed=1) + out.stdout.fnmatch_lines( + [ + "test_dependency.py::test_a XFAIL (deliberate fail)", + "test_dependency.py::test_b PASSED", + "test_dependency.py::test_c PASSED", + "test_dependency.py::test_d PASSED", + "test_dependency.py::test_e PASSED", + "test_order.py::test_foo PASSED", + "test_order.py::test_bar PASSED", + "test_a_method.py::test_b_fast_fail FAILED", + "test_a_method.py::test_c_medium PASSED", + "test_a_method.py::test_a_slow PASSED", + ], + consecutive=True + ) diff --git a/tests/test_no_rank.py b/tests/test_no_rank.py new file mode 100644 index 0000000..5363849 --- /dev/null +++ b/tests/test_no_rank.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from tests.fake_test_data import test_method_one + + +def test_no_rank_without_regsmart(mytester): + mytester.makepyfile( + test_method_one=test_method_one, + ) + args = ["-v", "--no-rank"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=2, failed=1) + + +def test_no_rank_disables_rtp(mytester): + mytester.makepyfile(test_method_one=test_method_one) + args = ["-v", "--regsmart", "--no-rank"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=2, failed=1) + out.stdout.fnmatch_lines([ + "test_method_one.py::test_slow PASSED", + "test_method_one.py::test_fast_fail FAILED", + "test_method_one.py::test_medium PASSED", + ], consecutive=True) + + +@pytest.mark.parametrize("ranking_flag", [ + "--rank-weight=0-1", + "--rank-weight=1-0", + "--rank-level=function", + "--rank-hist-len=30", + "--rank-seed=42", +]) +def test_no_rank_with_ranking_flag_error(mytester, ranking_flag): + mytester.makepyfile(test_method_one=test_method_one) + args = ["-v", "--regsmart", "--no-rank", ranking_flag] + out = mytester.runpytest(*args) + assert "--no-rank cannot be used together" in str(out.errlines + out.outlines) + + +@pytest.mark.parametrize("ini_value, expect_no_rank_header", [ + ("false", False), + ("true", True), +]) +def test_no_rank_ini_value(mytester, ini_value, expect_no_rank_header): + mytester.makepyfile(test_method_one=test_method_one) + (Path(mytester.path) / "pytest.ini").write_text( + f"[pytest]\nconsole_output_style = classic\nno_rank = {ini_value}\n" + ) + out = mytester.runpytest("-v", "--regsmart") + has_header = any("Using --no-rank (RTP disabled)." in x for x in out.outlines) + assert has_header is expect_no_rank_header + + +def test_no_rank_header_and_summary(mytester): + mytester.makepyfile(test_method_one=test_method_one) + args = ["-v", "--regsmart", "--no-rank"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=2, failed=1) + + assert any("Using --no-rank (RTP disabled)." in x for x in out.outlines) + assert any("pytest-regsmart summary info" in x for x in out.outlines) + assert any("Time to collect test features (s)" in x for x in out.outlines) + + log_lines = ( + "Using --rank-weight", + "Using --rank-level", + "Using --rank-hist-len", + "Using --rank-seed", + ) + assert len([x for x in out.outlines if x.startswith(log_lines)]) == 0 diff --git a/tests/test_options.py b/tests/test_options.py new file mode 100644 index 0000000..ef817ea --- /dev/null +++ b/tests/test_options.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +from tests.fake_test_data import ( + test_a_method, + test_b_class, + test_c_put, + test_class_one, + test_method_one, + test_put_one, +) + + +def test_logging(mytester): + mytester.makepyfile( + test_method_one=test_method_one + ) + + args = ["-v"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=2, failed=1) + log_text = ( + "Using --rank-weight", + "Using --rank-level", + "Using --rank-hist-len", + "Using --rank-seed", + "Time to run the regression test prioritization (s)", + "Time to collect test features (s)", + ) + + header = "= pytest-regsmart summary info =" + assert len([x for x in out.outlines if header in x]) == 0 + assert len([x for x in out.outlines if x.startswith(log_text)]) == 0 + + args = ["-v", "--regsmart"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=2, failed=1) + assert len([x for x in out.outlines if x.startswith(log_text)]) == 6 + + +def test_invalid_weight(mytester): + mytester.makepyfile( + test_method_one=test_method_one + ) + args = ["-v", "--regsmart", "--rank-weight=1-3-2"] + out = mytester.runpytest(*args) + error_msg = "Cannot parse input for `--rank-weight`." + assert any(error_msg in x for x in out.errlines) + + args = ["-v", "--regsmart", "--rank-weight=1-3-x"] + out = mytester.runpytest(*args) + error_msg = "Cannot parse input for `--rank-weight`." + assert any(error_msg in x for x in out.errlines) + + +def test_random_order(mytester): + mytester.makepyfile( + test_method_one=test_method_one, + test_class_one=test_class_one, + ) + + args = ["-v", "--regsmart", "--rank-weight=0-0"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=4, failed=2) + assert len([x for x in out.outlines if x.startswith("Using --rank-seed=")]) == 1 + test_lines_default1 = [x for x in out.outlines if "::" in x] + + args = ["-v", "--regsmart", "--rank-weight=0.0-0.0", "--rank-seed=8"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=4, failed=2) + assert len([x for x in out.outlines if x.startswith("Using --rank-seed=8")]) == 1 + test_lines_1 = [x for x in out.outlines if "::" in x] + + args = ["-v", "--regsmart", "--rank-weight=0-0", "--rank-seed=16"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=4, failed=2) + assert len([x for x in out.outlines if x.startswith("Using --rank-seed=16")]) == 1 + test_lines_2 = [x for x in out.outlines if "::" in x] + + assert test_lines_default1 != test_lines_1 != test_lines_2 + + +def test_xdist(mytester): + mytester.makepyfile( + test_put_one=test_put_one, + ) + + args = ["-v", "--regsmart", "-n", "auto", "--rank-weight=0-0"] + out = mytester.runpytest(*args) + assert len([x for x in out.outlines if x.startswith("ERROR")]) == 0 + + +def test_invalid_level(mytester): + mytester.makepyfile( + test_a_method=test_a_method, + test_b_class=test_b_class, + test_c_put=test_c_put, + ) + + args = ["-v", "--regsmart", "--rank-level=class"] + out = mytester.runpytest(*args) + error_msg = "Invalid input for `--rank-level`:" + assert any(error_msg in x for x in out.errlines) + + +def test_summary_reports_used_branch(mytester): + mytester.makepyfile( + test_method_one=test_method_one, + ) + + args = ["-v", "--regsmart"] + out = mytester.runpytest(*args) + out.assert_outcomes(passed=2, failed=1) + assert any( + x.startswith("Default branch used for comparison:") + for x in out.outlines + ) diff --git a/tests/test_pytest_ranking.py b/tests/test_pytest_ranking.py deleted file mode 100644 index 8952207..0000000 --- a/tests/test_pytest_ranking.py +++ /dev/null @@ -1,962 +0,0 @@ -from __future__ import annotations - -import textwrap - -import pytest - -test_method_one = \ - """ - import time - - def func(x): - return x + 1 - - def test_slow(): - time.sleep(1.5) - assert func(4) == 5 - - # FAIL - def test_fast_fail(): - time.sleep(0.5) - assert func(3) == 5 - - def test_medium(): - time.sleep(1) - assert func(4) == 5 - """ - - -test_class_one = \ - """ - import time - - def func(x): - return x + 1 - - class TestClassSample: - def test_fast(self): - time.sleep(0.7) - assert func(4) == 5 - - # FAIL - def test_slow_fail(self): - time.sleep(1.7) - assert func(3) == 5 - - def test_medium(self): - time.sleep(1.2) - assert func(4) == 5 - """ - - -source_method_one = \ - """ - print("source_method_one") - """ - - -source_class_one = \ - """ - print("source_method_one") - """ - - -test_put_one = \ - """ - import pytest - - @pytest.mark.parametrize("param", {"a", "b", "c", "d"}) - def test_put_unordered(param): - pass - - @pytest.mark.parametrize("param", ["d", "e", 1, 3, "a", "b", " "]) - def test_put_ordered(param): - pass - - """ - - -@pytest.fixture -def mytester(pytester): - pytester.makefile( - ".ini", - pytest=""" - [pytest] - console_output_style = classic - """, - ) - yield pytester - - -def test_default(mytester): - """Default currently is faster test first""" - mytester.makepyfile( - test_method_one=test_method_one, - test_class_one=test_class_one, - ) - - # Run without RTP. - args = ["-v"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=4, failed=2) - - # Run with RTP. - args = ["-v", "--rank"] - out = mytester.runpytest(*args) - - # assert outcome to be the same as if no rtp - out.assert_outcomes(passed=4, failed=2) - # assert faster tests are run first - out.stdout.fnmatch_lines( - [ - "test_method_one.py::test_fast_fail FAILED", - "test_class_one.py::TestClassSample::test_fast PASSED", - "test_method_one.py::test_medium PASSED", - "test_class_one.py::TestClassSample::test_medium PASSED", - "test_method_one.py::test_slow PASSED", - "test_class_one.py::TestClassSample::test_slow_fail FAILED", - ], - consecutive=True - ) - pass - - -def test_faster_test_first(mytester): - mytester.makepyfile( - test_method_one=test_method_one, - test_class_one=test_class_one, - ) - - # Run without RTP. - args = ["-v"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=4, failed=2) - - # Run with RTP. - args = ["-v", "--rank", "--rank-weight=1-0-0"] - out = mytester.runpytest(*args) - - # assert outcome to be the same as if no rtp - out.assert_outcomes(passed=4, failed=2) - # assert faster tests are run first - out.stdout.fnmatch_lines( - [ - "test_method_one.py::test_fast_fail FAILED", - "test_class_one.py::TestClassSample::test_fast PASSED", - "test_method_one.py::test_medium PASSED", - "test_class_one.py::TestClassSample::test_medium PASSED", - "test_method_one.py::test_slow PASSED", - "test_class_one.py::TestClassSample::test_slow_fail FAILED", - ], - consecutive=True - ) - pass - - -def test_recent_fail_first(mytester): - mytester.makepyfile( - test_method_one=test_method_one, - test_class_one=test_class_one, - ) - - # Run without RTP. - args = ["-v"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=4, failed=2) - - # Run with RTP. - args = ["-v", "--rank", "--rank-weight=0-1-0"] - out = mytester.runpytest(*args) - - # assert outcome to be the same as if no rtp - out.assert_outcomes(passed=4, failed=2) - out.stdout.fnmatch_lines( - [ - "test_class_one.py::TestClassSample::test_slow_fail FAILED", - "test_method_one.py::test_fast_fail FAILED", - "test_class_one.py::TestClassSample::test_fast PASSED", - "test_class_one.py::TestClassSample::test_medium PASSED", - "test_method_one.py::test_slow PASSED", - "test_method_one.py::test_medium PASSED", - ], - consecutive=True - ) - pass - - -def test_550_weight(mytester): - """--rank-weight=.5-.5-0, run recently failed and faster tests first""" - mytester.makepyfile( - test_method_one=test_method_one, - test_class_one=test_class_one, - ) - - # Run without RTP. - args = ["-v"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=4, failed=2) - - # Run with RTP. - args = ["-v", "--rank", "--rank-weight=5-5-0"] - out = mytester.runpytest(*args) - - # assert outcome to be the same as if no rtp - out.assert_outcomes(passed=4, failed=2) - out.stdout.fnmatch_lines( - [ - "test_method_one.py::test_fast_fail FAILED", - "test_class_one.py::TestClassSample::test_slow_fail FAILED", - "test_class_one.py::TestClassSample::test_fast PASSED", - "test_method_one.py::test_medium PASSED", - "test_class_one.py::TestClassSample::test_medium PASSED", - "test_method_one.py::test_slow PASSED", - ], - consecutive=True - ) - pass - - -def test_001_028_weight(mytester): - """run failed tests more related to code change first""" - mytester.makepyfile( - test_method_one=test_method_one, - test_class_one=test_class_one, - ) - - # Run without RTP. - args = ["-v"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=4, failed=2) - - # Run with RTP. - args = ["-v", "--rank"] - out = mytester.runpytest(*args) - - # assert outcome to be the same as if no rtp - out.assert_outcomes(passed=4, failed=2) - out.stdout.fnmatch_lines( - [ - "test_method_one.py::test_fast_fail FAILED", - "test_class_one.py::TestClassSample::test_fast PASSED", - "test_method_one.py::test_medium PASSED", - "test_class_one.py::TestClassSample::test_medium PASSED", - "test_method_one.py::test_slow PASSED", - "test_class_one.py::TestClassSample::test_slow_fail FAILED", - ], - consecutive=True - ) - - mytester.makepyfile(source_method_one=source_method_one) - # Run with RTP. - args = ["-v", "--rank", "--rank-weight=0-0-1"] - out = mytester.runpytest(*args) - - # assert outcome to be the same as if no rtp - out.assert_outcomes(passed=4, failed=2) - # assert tests more related to the change are run first - out.stdout.fnmatch_lines( - [ - "test_method_one.py::test_slow PASSED", - "test_method_one.py::test_fast_fail FAILED", - "test_method_one.py::test_medium PASSED", - "test_class_one.py::TestClassSample::test_fast PASSED", - "test_class_one.py::TestClassSample::test_slow_fail FAILED", - "test_class_one.py::TestClassSample::test_medium PASSED", - ], - consecutive=True - ) - - mytester.makepyfile(source_class_one=source_class_one) - # Run with RTP. - args = ["-v", "--rank", "--rank-weight=0-2-8"] - out = mytester.runpytest(*args) - - # assert outcome to be the same as if no rtp - out.assert_outcomes(passed=4, failed=2) - out.stdout.fnmatch_lines( - [ - "test_class_one.py::TestClassSample::test_slow_fail FAILED", - "test_class_one.py::TestClassSample::test_fast PASSED", - "test_class_one.py::TestClassSample::test_medium PASSED", - "test_method_one.py::test_fast_fail FAILED", - "test_method_one.py::test_slow PASSED", - "test_method_one.py::test_medium PASSED", - ], - consecutive=True - ) - - -def test_208_093_weight(mytester): - """run faster tests more related to code change first""" - mytester.makepyfile( - test_method_one=test_method_one, - test_class_one=test_class_one, - ) - - # Run without RTP. - args = ["-v"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=4, failed=2) - - # Run with RTP. - args = ["-v", "--rank"] - out = mytester.runpytest(*args) - - # assert outcome to be the same as if no rtp - out.assert_outcomes(passed=4, failed=2) - out.stdout.fnmatch_lines( - [ - "test_method_one.py::test_fast_fail FAILED", - "test_class_one.py::TestClassSample::test_fast PASSED", - "test_method_one.py::test_medium PASSED", - "test_class_one.py::TestClassSample::test_medium PASSED", - "test_method_one.py::test_slow PASSED", - "test_class_one.py::TestClassSample::test_slow_fail FAILED", - ], - consecutive=True - ) - - mytester.makepyfile(source_method_one=source_method_one) - # Run with RTP. - args = ["-v", "--rank", "--rank-weight=.2-0-.8"] - out = mytester.runpytest(*args) - - # assert outcome to be the same as if no rtp - out.assert_outcomes(passed=4, failed=2) - # assert tests more related to the change are run first - out.stdout.fnmatch_lines( - [ - "test_method_one.py::test_fast_fail FAILED", - "test_method_one.py::test_medium PASSED", - "test_method_one.py::test_slow PASSED", - "test_class_one.py::TestClassSample::test_fast PASSED", - "test_class_one.py::TestClassSample::test_medium PASSED", - "test_class_one.py::TestClassSample::test_slow_fail FAILED", - ], - consecutive=True - ) - - mytester.makepyfile(source_class_one=source_class_one) - # Run with RTP. - args = ["-v", "--rank", "--rank-weight=0-9-3"] - out = mytester.runpytest(*args) - - # assert outcome to be the same as if no rtp - out.assert_outcomes(passed=4, failed=2) - out.stdout.fnmatch_lines( - [ - "test_class_one.py::TestClassSample::test_slow_fail FAILED", - "test_method_one.py::test_fast_fail FAILED", - "test_class_one.py::TestClassSample::test_fast PASSED", - "test_class_one.py::TestClassSample::test_medium PASSED", - "test_method_one.py::test_slow PASSED", - "test_method_one.py::test_medium PASSED", - ], - consecutive=True - ) - - -def test_logging(mytester): - mytester.makepyfile( - test_method_one=test_method_one - ) - - # Without RTP. - args = ["-v"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=2, failed=1) - # Should print nothing. - log_text = ( - "Using --rank-weight", - "Using --rank-level", - "Using --rank-hist-len", - "Using --rank-seed", - "Number of changed Python files", - "Time to compute test-change similarity (s)", - "Time to reorder tests (s)", - "Time to collect test features (s)", - ) - - header = "= pytest-ranking summary info =" - assert len([x for x in out.outlines if header in x]) == 0 - assert len([x for x in out.outlines if x.startswith(log_text)]) == 0 - - # Run with RTP. - args = ["-v", "--rank"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=2, failed=1) - # Should log everything. - assert len([x for x in out.outlines if x.startswith(log_text)]) == 8 - - -def test_invalid_weight(mytester): - mytester.makepyfile( - test_method_one=test_method_one - ) - args = ["-v", "--rank", "--rank-weight=1-3"] - out = mytester.runpytest(*args) - error_msg = "pytest: error: argument --rank-weight:" \ - + " Cannot parse input for `--rank-weight`." - assert len([x for x in out.errlines if x.startswith(error_msg)]) == 1 - - args = ["-v", "--rank", "--rank-weight=1-3-x"] - out = mytester.runpytest(*args) - error_msg = "pytest: error: argument --rank-weight:" \ - + " Cannot parse input for `--rank-weight`." - assert len([x for x in out.errlines if x.startswith(error_msg)]) == 1 - - -def test_random_order(mytester): - mytester.makepyfile( - test_method_one=test_method_one, - test_class_one=test_class_one, - ) - - # Run without RTP. - args = ["-v"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=4, failed=2) - # Should print nothing. - log_text = ( - "Using --rank-weight", - "Using --rank-level", - "Using --rank-hist-len", - "Using --rank-seed", - "Number of changed Python files", - "Time to compute test-change similarity (s)", - "Time to reorder tests (s)", - "Time to collect test features (s)", - ) - assert len([x for x in out.outlines if x.startswith(log_text)]) == 0 - - # Run default. - args = ["-v", "--rank"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=4, failed=2) - # Should log everything. - assert len([x for x in out.outlines if x.startswith(log_text)]) == 8 - - # Run with default seed. - args = ["-v", "--rank", "--rank-weight=0-0-0"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=4, failed=2) - log_text = ( - "Using --rank-seed=", - ) - assert len([x for x in out.outlines if x.startswith(log_text)]) == 1 - test_lines_default1 = [x for x in out.outlines if "::" in x] - - # Run with specific seed. - args = ["-v", "--rank", "--rank-weight=0.0-0.0-0.0", "--rank-seed=8"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=4, failed=2) - log_text = ( - "Using --rank-seed=8", - ) - assert len([x for x in out.outlines if x.startswith(log_text)]) == 1 - test_lines_1 = [x for x in out.outlines if "::" in x] - - # Run with specific seed. - args = ["-v", "--rank", "--rank-weight=0-0-0", "--rank-seed=16"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=4, failed=2) - log_text = ( - "Using --rank-seed=16", - ) - assert len([x for x in out.outlines if x.startswith(log_text)]) == 1 - test_lines_2 = [x for x in out.outlines if "::" in x] - - # Assert test order differ by three different seeds. - assert test_lines_default1 != test_lines_1 != test_lines_2 - - -def test_xdist(mytester): - """Test the plugin under pytest-xdist (test parallel).""" - mytester.makepyfile( - test_put_one=test_put_one, - ) - - args = ["-v", "--rank", "-n", "auto", "--rank-weight=0-0-0"] - out = mytester.runpytest(*args) - assert len([x for x in out.outlines if x.startswith("ERROR")]) == 0 - pass - - -test_a_method = \ - """ - import time - - def func(x): - return x + 1 - - def test_a_slow(): - time.sleep(1.5) - assert func(4) == 5 - - # FAIL - def test_b_fast_fail(): - time.sleep(0.5) - assert func(3) == 5 - - def test_c_medium(): - time.sleep(1) - assert func(4) == 5 - """ - - -test_b_class = \ - """ - import time - - def func(x): - return x + 1 - - class TestClassA: - def test_a_fast(self): - time.sleep(0.7) - assert func(4) == 5 - - # FAIL - def test_b_slow_fail(self): - time.sleep(1.7) - assert func(3) == 5 - - def test_c_medium(self): - time.sleep(1.2) - assert func(4) == 5 - """ - -test_c_put = \ - """ - import pytest - import time - - @pytest.mark.parametrize("param", {0.1, 0.2, 0.3, 0.4}) - def test_a_put_unordered(param): - time.sleep(param) - pass - - @pytest.mark.parametrize("param", [0.45, 0.25, 0.55, 0.35, 0.15]) - def test_b_put_ordered(param): - time.sleep(param) - pass - - """ - - -test_c_put_ordered = \ - """ - import pytest - import time - - @pytest.mark.parametrize("param", [0.45, 0.25, 0.55, 0.35, 0.15]) - def test_b_put_ordered(param): - time.sleep(param) - pass - - """ - - -def test_put_level_ranking(mytester): - mytester.makepyfile( - test_a_method=test_a_method, - test_b_class=test_b_class, - test_c_put=test_c_put, - ) - - # Without RTP. - args = ["-v"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=13, failed=2) - - # With RTP. - args = ["-v", "--rank", "--rank-level=put"] - out = mytester.runpytest(*args) - - # Assert outcome to be the same as if no RTP. - out.assert_outcomes(passed=13, failed=2) - # Assert faster tests are run first at PUT level. - out.stdout.fnmatch_lines( - [ - "test_c_put.py::test_a_put_unordered[0.1] PASSED", - "test_c_put.py::test_b_put_ordered[0.15] PASSED", - "test_c_put.py::test_a_put_unordered[0.2] PASSED", - "test_c_put.py::test_b_put_ordered[0.25] PASSED", - "test_c_put.py::test_a_put_unordered[0.3] PASSED", - "test_c_put.py::test_b_put_ordered[0.35] PASSED", - "test_c_put.py::test_a_put_unordered[0.4] PASSED", - "test_c_put.py::test_b_put_ordered[0.45] PASSED", - "test_a_method.py::test_b_fast_fail FAILED", - "test_c_put.py::test_b_put_ordered[0.55] PASSED", - "test_b_class.py::TestClassA::test_a_fast PASSED", - "test_a_method.py::test_c_medium PASSED", - "test_b_class.py::TestClassA::test_c_medium PASSED", - "test_a_method.py::test_a_slow PASSED", - "test_b_class.py::TestClassA::test_b_slow_fail FAILED" - ], - consecutive=True - ) - pass - - -def test_function_level_ranking(mytester): - mytester.makepyfile( - test_a_method=test_a_method, - test_b_class=test_b_class, - test_c_put=test_c_put_ordered, - ) - - # Run without RTP. - args = ["-v"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=9, failed=2) - - # Run with RTP. - args = ["-v", "--rank", "--rank-level=function"] - out = mytester.runpytest(*args) - - # Assert outcome to be the same as if no RTP. - out.assert_outcomes(passed=9, failed=2) - out.stdout.fnmatch_lines( - [ - "test_c_put.py::test_b_put_ordered[0.45] PASSED", - "test_c_put.py::test_b_put_ordered[0.25] PASSED", - "test_c_put.py::test_b_put_ordered[0.55] PASSED", - "test_c_put.py::test_b_put_ordered[0.35] PASSED", - "test_c_put.py::test_b_put_ordered[0.15] PASSED", - "test_a_method.py::test_b_fast_fail FAILED", - "test_b_class.py::TestClassA::test_a_fast PASSED", - "test_a_method.py::test_c_medium PASSED", - "test_b_class.py::TestClassA::test_c_medium PASSED", - "test_a_method.py::test_a_slow PASSED", - "test_b_class.py::TestClassA::test_b_slow_fail FAILED", - ], - consecutive=True - ) - pass - - -def test_module_level_ranking(mytester): - mytester.makepyfile( - test_a_method=test_a_method, - test_b_class=test_b_class, - test_c_put=test_c_put_ordered, - ) - - # Run without RTP. - args = ["-v"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=9, failed=2) - - # Run with RTP. - args = ["-v", "--rank", "--rank-level=module"] - out = mytester.runpytest(*args) - - # assert outcome to be the same as if no rtp - out.assert_outcomes(passed=9, failed=2) - out.stdout.fnmatch_lines( - [ - "test_c_put.py::test_b_put_ordered[0.45] PASSED", - "test_c_put.py::test_b_put_ordered[0.25] PASSED", - "test_c_put.py::test_b_put_ordered[0.55] PASSED", - "test_c_put.py::test_b_put_ordered[0.35] PASSED", - "test_c_put.py::test_b_put_ordered[0.15] PASSED", - "test_a_method.py::test_a_slow PASSED", - "test_a_method.py::test_b_fast_fail FAILED", - "test_a_method.py::test_c_medium PASSED", - "test_b_class.py::TestClassA::test_a_fast PASSED", - "test_b_class.py::TestClassA::test_b_slow_fail FAILED", - "test_b_class.py::TestClassA::test_c_medium PASSED", - ], - consecutive=True - ) - pass - - -def test_dir_level_ranking(mytester): - - a = mytester.mkdir("a") - a.joinpath("test_a_method.py").write_text( - textwrap.dedent(test_a_method)) - a.joinpath("test_b_class.py").write_text( - textwrap.dedent(test_b_class)) - b = mytester.mkdir("b") - b.joinpath("test_c_put.py").write_text( - textwrap.dedent(test_c_put_ordered)) - - # Without RTP. - args = ["-v"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=9, failed=2) - - # With RTP. - args = ["-v", "--rank", "--rank-level=dir"] - out = mytester.runpytest(*args) - - # Assert outcome. - out.assert_outcomes(passed=9, failed=2) - out.stdout.fnmatch_lines( - [ - "b/test_c_put.py::test_b_put_ordered[0.45] PASSED", - "b/test_c_put.py::test_b_put_ordered[0.25] PASSED", - "b/test_c_put.py::test_b_put_ordered[0.55] PASSED", - "b/test_c_put.py::test_b_put_ordered[0.35] PASSED", - "b/test_c_put.py::test_b_put_ordered[0.15] PASSED", - "a/test_a_method.py::test_a_slow PASSED", - "a/test_a_method.py::test_b_fast_fail FAILED", - "a/test_a_method.py::test_c_medium PASSED", - "a/test_b_class.py::TestClassA::test_a_fast PASSED", - "a/test_b_class.py::TestClassA::test_b_slow_fail FAILED", - "a/test_b_class.py::TestClassA::test_c_medium PASSED", - ], - consecutive=True - ) - pass - - -def test_invalid_level(mytester): - mytester.makepyfile( - test_a_method=test_a_method, - test_b_class=test_b_class, - test_c_put=test_c_put, - ) - - args = ["-v", "--rank", "--rank-level=class"] - out = mytester.runpytest(*args) - error_msg = "pytest: error: argument --rank-level:" \ - + " Invalid input for `--rank-level`." \ - + " Please run `pytest --help` for instruction." - assert len([x for x in out.errlines if x.startswith(error_msg)]) == 1 - - -test_a_method_two = \ - """ - import time - - def func(x): - return x + 1 - - def test_a_slow(): - time.sleep(0.05) - assert func(4) == 5 - - # FAIL - def test_b_fast_fail(): - time.sleep(0.01) - assert func(3) == 5 - - def test_c_medium(): - time.sleep(0.02) - assert func(4) == 5 - """ - - -def test_function_level_ranking_with_duplicate_methods(mytester): - mytester.makepyfile( - test_a_method=test_a_method, - test_a_method_two=test_a_method_two, - test_b_class=test_b_class, - test_c_put=test_c_put_ordered, - ) - - # Without RTP. - args = ["-v"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=11, failed=3) - - # With RTP. - args = ["-v", "--rank", "--rank-level=function"] - out = mytester.runpytest(*args) - - # Assert outcome to be the same with/without RTP. - out.assert_outcomes(passed=11, failed=3) - # Assert that tests with the same method name, - # i.e., from test_a_method and test_a_method_two, - # are in two different group. - out.stdout.fnmatch_lines( - [ - "test_a_method_two.py::test_b_fast_fail FAILED", - "test_a_method_two.py::test_c_medium PASSED", - "test_a_method_two.py::test_a_slow PASSED", - "test_c_put.py::test_b_put_ordered[0.45] PASSED", - "test_c_put.py::test_b_put_ordered[0.25] PASSED", - "test_c_put.py::test_b_put_ordered[0.55] PASSED", - "test_c_put.py::test_b_put_ordered[0.35] PASSED", - "test_c_put.py::test_b_put_ordered[0.15] PASSED", - "test_a_method.py::test_b_fast_fail FAILED", - "test_b_class.py::TestClassA::test_a_fast PASSED", - "test_a_method.py::test_c_medium PASSED", - "test_b_class.py::TestClassA::test_c_medium PASSED", - "test_a_method.py::test_a_slow PASSED", - "test_b_class.py::TestClassA::test_b_slow_fail FAILED", - ], - consecutive=True - ) - pass - - -test_order = \ - """ - import pytest - import time - @pytest.mark.order(2) - def test_foo(): - time.sleep(4.5) - assert True - - @pytest.mark.order(1) - def test_bar(): - time.sleep(5) - assert True - """ - -test_dependency = \ - """ - import pytest - import time - - @pytest.mark.dependency() - @pytest.mark.xfail(reason="deliberate fail") - def test_a(): - time.sleep(4) - assert False - - @pytest.mark.dependency() - def test_b(): - time.sleep(3.5) - pass - - @pytest.mark.dependency(depends=["test_a"]) - def test_c(): - time.sleep(3) - pass - - @pytest.mark.dependency(depends=["test_b"]) - def test_d(): - time.sleep(2.5) - pass - - @pytest.mark.dependency(depends=["test_b", "test_c"]) - def test_e(): - time.sleep(2) - pass - """ - - -def test_order_dependency_marker(mytester): - mytester.makepyfile( - test_a_method=test_a_method, - test_order=test_order, - test_dependency=test_dependency, - ) - - # Run without RTP. - args = ["-v"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=8, failed=1, xfailed=1) - - # Run with RTP. - args = ["-v", "--rank"] - out = mytester.runpytest(*args) - - # assert outcome to be the same as if no rtp - out.assert_outcomes(passed=8, failed=1, xfailed=1) - # assert that tests with the same method name, - # i.e., from test_a_method and test_a_method_two - # are in two different group - out.stdout.fnmatch_lines( - [ - "test_dependency.py::test_a XFAIL (deliberate fail)", - "test_dependency.py::test_b PASSED", - "test_dependency.py::test_c PASSED", - "test_dependency.py::test_d PASSED", - "test_dependency.py::test_e PASSED", - "test_order.py::test_foo PASSED", - "test_order.py::test_bar PASSED", - "test_a_method.py::test_b_fast_fail FAILED", - "test_a_method.py::test_c_medium PASSED", - "test_a_method.py::test_a_slow PASSED", - ], - consecutive=True - ) - pass - - -replay_order_one = \ - """ - test_class_one.py::TestClassSample::test_fast - test_class_one.py::TestClassSample::test_slow_fail - test_method_one.py::test_medium - test_method_one.py::test_fast_fail - test_class_one.py::TestClassSample::test_medium - test_method_one.py::test_slow - """ - - -def test_replay(mytester): - mytester.makepyfile( - test_method_one=test_method_one, - test_class_one=test_class_one, - ) - - mytester.maketxtfile( - replay_order=replay_order_one - ) - - # Run without RTP. - args = ["-v"] - out = mytester.runpytest(*args) - out.assert_outcomes(passed=4, failed=2) - - # Run with RTP. - args = ["-v", "--rank", "--rank-replay=replay_order.txt"] - out = mytester.runpytest(*args) - - # assert outcome to be the same as if no rtp - out.assert_outcomes(passed=4, failed=2) - out.stdout.fnmatch_lines( - [ - "test_class_one.py::TestClassSample::test_fast PASSED", - "test_class_one.py::TestClassSample::test_slow_fail FAILED", - "test_method_one.py::test_medium PASSED", - "test_method_one.py::test_fast_fail FAILED", - "test_class_one.py::TestClassSample::test_medium PASSED", - "test_method_one.py::test_slow PASSED", - ], - consecutive=True - ) - - -def test_replay_with_random(mytester): - mytester.makepyfile( - test_method_one=test_method_one, - test_class_one=test_class_one, - ) - - mytester.maketxtfile( - replay_order=replay_order_one - ) - - # Run with RTP. - args = [ - "-v", - "--rank", - "--rank-replay=replay_order.txt", - "--rank-weight=0-0-0" - ] - out = mytester.runpytest(*args) - error_msg = "--rank-replay cannot be used together with random order." - assert len([x for x in out.outlines if error_msg in x]) == 1 - - -def test_invalid_replay(mytester): - mytester.makepyfile( - test_a_method=test_a_method, - ) - - args = ["-v", "--rank", "--rank-replay=order.txt"] - out = mytester.runpytest(*args) - error_msg = "pytest: error: argument --rank-replay:" \ - + " File provided to `--rank-replay` cannot be read." \ - + " Please run `pytest --help` for instruction." - assert len([x for x in out.errlines if x.startswith(error_msg)]) == 1 diff --git a/tox.ini b/tox.ini index 3db4ad9..66c5e8c 100644 --- a/tox.ini +++ b/tox.ini @@ -1,14 +1,10 @@ -# For more information about tox, see https://tox.readthedocs.io/en/latest/ [tox] -envlist = py35,py36,py37,py38,py310,py311,py312,pypy3,flake8 +requires = tox-uv +envlist = py310,py311,py312 [testenv] +installer = uv deps = pytest pytest-xdist commands = pytest -xv {posargs:tests} - -[testenv:flake8] -skip_install = true -deps = flake8 -commands = flake8 src tests setup.py diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..b135251 --- /dev/null +++ b/uv.lock @@ -0,0 +1,734 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version < '3.11'", +] + +[[package]] +name = "cachetools" +version = "7.1.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/d2/47e8bc06fe2a06d3f5bdf20f1126ab66c4e99dc48d940e7ba873f7ac7131/cachetools-7.1.7.tar.gz", hash = "sha256:a3e2a00b14d8f8a6b70c1dae7b4685e7ad3bc965c5b42124a2d6ce895da6cf50", size = 40680, upload-time = "2026-08-01T21:20:40.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/d8/767faeda872075724b95dd675466a645f1b92aadcdcf2d1429dcfd76c176/cachetools-7.1.7-py3-none-any.whl", hash = "sha256:ef98ef375ad188819ef2f9b3645e3987f4b8c5b7550e436ad998c2de78296df0", size = 16830, upload-time = "2026-08-01T21:20:38.977Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + +[[package]] +name = "filelock" +version = "3.32.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/64/a02e6765de08964ed371eca577870593245afc9dfac16d037de7c10d18e6/filelock-3.32.3.tar.gz", hash = "sha256:0ffa185a3540854c95caa7fa76b76cb219d907415e2c5dc9af25fd970563487f", size = 218135, upload-time = "2026-08-13T16:00:05.577Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/8e/50f46a9c0ce8d2861a394c1347caae037ea0431d2f67d7feb151cbc4649a/filelock-3.32.3-py3-none-any.whl", hash = "sha256:7f0ca4bcc0e181c60dbbd8aa9ab5b120ebb99e4e064e83636340056f833a1f09", size = 98901, upload-time = "2026-08-13T16:00:03.974Z" }, +] + +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.59" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/dc/126b28e76b24a9268ba931ad3e012f71ebdadf62fd9f17758f7074bb0b20/gitpython-3.1.59.tar.gz", hash = "sha256:0a1475cfdc38a5bfba1a3e9a4a9da52a39749ecec322b772915c019f94e5b7e4", size = 230445, upload-time = "2026-08-10T12:03:20.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/ed/ae57eb7d344f43f87b74b3a281ead6ec7d6394eef72a7b1dcb28dd089550/gitpython-3.1.59-py3-none-any.whl", hash = "sha256:67a82f537384578643624c8b2c531938a9b82be431663e575dcf638526631d4c", size = 220996, upload-time = "2026-08-10T12:03:18.804Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" }, + { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, + { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, + { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, + { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, + { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/d7/e7bfbc86e9f99ff7807e24de7703f032e9c9ba80bb355cf26e0e9bc5a75e/platformdirs-4.11.3.tar.gz", hash = "sha256:66a73d38a849810252df809a3d8bcbda8e26f6c189920e7535ad608a48dbb5ab", size = 33050, upload-time = "2026-08-13T22:43:27.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/a9/c34aebedd3a4c9afe5101b1b8713710b3fec18087c8a36c35d2f909861bd/platformdirs-4.11.3-py3-none-any.whl", hash = "sha256:5ed065d443751de711da036041a7a214122efc4a4de393b3f4137ba5576540e7", size = 23491, upload-time = "2026-08-13T22:43:26.121Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pyan3" +version = "2.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/f4/0fc2023b7971af8ab00e261ed59916be93ff7ae745f9d4e91de40cbf85ff/pyan3-2.6.2.tar.gz", hash = "sha256:ecb63d32ea28ec10caf22aad11310022a341c9bf5e8b9301e9a64ed3fe2edc91", size = 101955, upload-time = "2026-07-14T08:20:53.698Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/b6/6c4aadea4274657b5539c4d0365aba87d44ce12f4cf32556aee616187b80/pyan3-2.6.2-py3-none-any.whl", hash = "sha256:51b3f124286398f983e67bb1569074ca98047e51c420e9055376c56c9232c41e", size = 99491, upload-time = "2026-07-14T08:20:52.487Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyproject-api" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/bd/2f985c12bf33fdd8637e3a8f9418d6806f177601dee7c4924d0b2bb28650/pyproject_api-1.11.0.tar.gz", hash = "sha256:b8807d85a293e6c9f133e6575946fed45f1d42b22d58c780b33aa2421a799549", size = 23787, upload-time = "2026-07-21T13:09:33.744Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/6a/2822ee12bafe87af8f6cc620f7d1f126e77f82ce56ba888b94a9af5a979c/pyproject_api-1.11.0-py3-none-any.whl", hash = "sha256:860060c8832dce983b5eec6f41c4c43eb3ec06ff7332387a63acdf5ca27b68d8", size = 13275, upload-time = "2026-07-21T13:09:32.559Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-regsmart" +version = "0.6.3" +source = { editable = "." } +dependencies = [ + { name = "gitpython" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pyan3" }, + { name = "pytest" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest-xdist" }, + { name = "ruff" }, + { name = "tox" }, + { name = "tox-uv" }, +] + +[package.metadata] +requires-dist = [ + { name = "gitpython", specifier = ">=3.1.58" }, + { name = "numpy" }, + { name = "pyan3", specifier = ">=1.2.0" }, + { name = "pytest", specifier = ">=9.0.3" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest-xdist" }, + { name = "ruff" }, + { name = "tox" }, + { name = "tox-uv" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/b7/ac44da2cf0e53ada0e419033c2d058219c95dc1403126f163304c9e814b1/python_discovery-1.5.2.tar.gz", hash = "sha256:45fd4f20a4e3f9b7bf2e0817870bc8e3b320a19658da177af800768c82dbf354", size = 82350, upload-time = "2026-08-12T14:05:26.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/45/689603d04b3bb8d7faa00f25c24acef993aab7813b3dbbfc472a459ab0b5/python_discovery-1.5.2-py3-none-any.whl", hash = "sha256:3e338c2d0f15dfaeea57493f4c2c6caebe0e998ea815c30ae8bf8ee21f1112d3", size = 38350, upload-time = "2026-08-12T14:05:25.113Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2", size = 4891904, upload-time = "2026-08-13T15:17:13.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7", size = 10902799, upload-time = "2026-08-13T15:16:27.382Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081", size = 11135539, upload-time = "2026-08-13T15:16:30.87Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9", size = 10475095, upload-time = "2026-08-13T15:16:33.259Z" }, + { url = "https://files.pythonhosted.org/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84", size = 10668771, upload-time = "2026-08-13T15:16:35.65Z" }, + { url = "https://files.pythonhosted.org/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870", size = 10699568, upload-time = "2026-08-13T15:16:38.195Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b", size = 11499365, upload-time = "2026-08-13T15:16:40.623Z" }, + { url = "https://files.pythonhosted.org/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413", size = 12311728, upload-time = "2026-08-13T15:16:43.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82", size = 11699896, upload-time = "2026-08-13T15:16:46.209Z" }, + { url = "https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb", size = 11058736, upload-time = "2026-08-13T15:16:48.823Z" }, + { url = "https://files.pythonhosted.org/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474", size = 11586911, upload-time = "2026-08-13T15:16:51.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da", size = 10954265, upload-time = "2026-08-13T15:16:54.763Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50", size = 10709886, upload-time = "2026-08-13T15:16:57.339Z" }, + { url = "https://files.pythonhosted.org/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506", size = 11210392, upload-time = "2026-08-13T15:17:00.171Z" }, + { url = "https://files.pythonhosted.org/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d", size = 11626910, upload-time = "2026-08-13T15:17:03.299Z" }, + { url = "https://files.pythonhosted.org/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a", size = 10931415, upload-time = "2026-08-13T15:17:05.726Z" }, + { url = "https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948", size = 11445993, upload-time = "2026-08-13T15:17:08.353Z" }, + { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" }, +] + +[[package]] +name = "smmap" +version = "5.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + +[[package]] +name = "tox" +version = "4.60.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "colorama" }, + { name = "filelock" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "pluggy" }, + { name = "pyproject-api" }, + { name = "python-discovery" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "tomli-w" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/1f/3acba68301b0081cec87b1769ba4c3a4a0a30f20879a05420673c7e4671d/tox-4.60.0.tar.gz", hash = "sha256:6f93bb580d35e00fc69cfec1116eecbe21155f747753ca5019374f7c69b2eac9", size = 301598, upload-time = "2026-08-13T23:14:26.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/e6/3a1042add765c0e1e67f76a3b0f2036bfe6b0229ace83c6ff9638a5e391b/tox-4.60.0-py3-none-any.whl", hash = "sha256:175abbc4cdef615d66874c0843be4f44c353c14aab6d89939bb22246f84122bd", size = 226332, upload-time = "2026-08-13T23:14:25.324Z" }, +] + +[[package]] +name = "tox-uv" +version = "1.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tox-uv-bare" }, + { name = "uv" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/d7/3fce976b9e295218a2d7e541e0f1e9b1259cb893de7a8d277952a66a798d/tox_uv-1.36.0-py3-none-any.whl", hash = "sha256:5f81b39be3fe4e14c6b9bb7ba637ed97d34efa6214efd5f525d95ee9559a99ac", size = 6564, upload-time = "2026-07-21T13:09:54.316Z" }, +] + +[[package]] +name = "tox-uv-bare" +version = "1.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "tox" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/df/9f90a59de8c87cece6e691eee53dc1cb0a824d73aea8f380ae2f6ecb71de/tox_uv_bare-1.36.0.tar.gz", hash = "sha256:d9b0a2fd0f74fa65d9597108f8a0ef7abb08651c9196a998a6073b781b45cfd0", size = 32548, upload-time = "2026-07-21T13:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/0a/6dc462e4fb543305283a6157c80f43e3d12ca4702da6ae6521d541c6b55c/tox_uv_bare-1.36.0-py3-none-any.whl", hash = "sha256:ba397dd0396df95a75744d4e42a50ee27207c0ffcf277b62ffba9c3de455a939", size = 22489, upload-time = "2026-07-21T13:09:55.389Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "uv" +version = "0.12.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/b0/3085b844fe59aa319a3f94a5cca9938fffecc82705aa9c2762a749f7095c/uv-0.12.5.tar.gz", hash = "sha256:442a21d181faae21742aaaf6d2091a0d27755d3eac344061a9a00c90169b7524", size = 7101936, upload-time = "2026-08-14T19:56:57.693Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/4c/6412d4a618230db699118b362ec41c54795f93992b43c53e225bd0213501/uv-0.12.5-py3-none-linux_armv6l.whl", hash = "sha256:2bd62134e56af35b9cf017aaf8ae41a605d6501dd49afc35b70b544a45dd8354", size = 23310055, upload-time = "2026-08-14T19:55:51.357Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ec/d76387b388fa21620088b89b9c67f2596a707add585104e0cb5e8abf55f2/uv-0.12.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:1a06c8bc4d43b5f6c1e3f2ae3d0f6455b07515f762516f95e52e6c0cbccedf15", size = 21401335, upload-time = "2026-08-14T19:55:55.371Z" }, + { url = "https://files.pythonhosted.org/packages/6d/bc/81ab953b7261ae6be40874b1f283a10873871e02eb353d354614dd8da96b/uv-0.12.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d87156bc174d94fae890bb7a261e2867140abb9fe1e9de81a5295e582fb9d0f5", size = 19290641, upload-time = "2026-08-14T19:55:58.998Z" }, + { url = "https://files.pythonhosted.org/packages/7d/13/07585043c10e648820bf826474dac46864ce6691da5dc52fee43c5c7523a/uv-0.12.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:2d65b7b3bc3fd28678f62aa7fb5d90f106ad9782c1354af60b6cecdf9ea9ecd9", size = 22245569, upload-time = "2026-08-14T19:56:02.729Z" }, + { url = "https://files.pythonhosted.org/packages/3e/6d/310f8f56f8d001b4000112a09d7b7de80fb2024a90208fabb9ddc457c123/uv-0.12.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:712624b62e25c84e5a10fc6aa144d8a81b685fdc067a54a7ca4367d75d2cf791", size = 22745152, upload-time = "2026-08-14T19:56:06.426Z" }, + { url = "https://files.pythonhosted.org/packages/92/da/7922b67eec5ee03e94333c5841b682c335033ee80acac17c3417bd752656/uv-0.12.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f9656ac7a00fd4314980fb0f790df1c1f3fa9cbcf9af9c6f611b19448b9da687", size = 22787947, upload-time = "2026-08-14T19:56:10.149Z" }, + { url = "https://files.pythonhosted.org/packages/62/55/5dbaed832a4b36809ef8a07c8e56e9fee0dedb0aa0454f6d232b6e468f2c/uv-0.12.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:568485b44e848eb3693f85d6b00299ccd8fc4d26902030dbf24f549c276db9ca", size = 23367616, upload-time = "2026-08-14T19:56:13.768Z" }, + { url = "https://files.pythonhosted.org/packages/11/77/baf761d12bb66efb01706e3bbb5926ed0d13cb0a40539a661fcfffd46de4/uv-0.12.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd08c82831b0033330f8eeeb0d90f938a4d999f25569bee68a975c736142d795", size = 24586263, upload-time = "2026-08-14T19:56:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a8/76c1031c4834c959bb8a8059c9feabeaa77488ce8b6a3529d6d929ae81cf/uv-0.12.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:edd9ff6154b891146a342c143cd29b330ad97ac6a4b20ff4a99a20a4da84ceca", size = 24160655, upload-time = "2026-08-14T19:56:21.568Z" }, + { url = "https://files.pythonhosted.org/packages/93/22/dacc9a0bc8604187a1ba954a3aef8329e4104eb0af772d2c3c634893bd9b/uv-0.12.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e195ccf1ed60c8bb24a6447ce306441a4181d54b602407e09bc56e963911c15", size = 23657089, upload-time = "2026-08-14T19:56:25.144Z" }, + { url = "https://files.pythonhosted.org/packages/39/98/e8f9c071622f2cb4072d8b587d27b27d23cf0d3ebf8b3687f5af6030f587/uv-0.12.5-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:58abfb0f658b39a834307a11223bc170294ea214263b4c99ecc7663720d43544", size = 22379954, upload-time = "2026-08-14T19:56:28.789Z" }, + { url = "https://files.pythonhosted.org/packages/73/95/4c3f060e95f7cbe9177b4ab361f0cbfc4ae22e5a49b22e73eee9f0d0a6ca/uv-0.12.5-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:6ad2c455f1fe4d2962f6fd7ccb3b1f61c61856681c9d99f40e170b2074353fa3", size = 23318163, upload-time = "2026-08-14T19:56:32.504Z" }, + { url = "https://files.pythonhosted.org/packages/a0/96/ca0497ef8912ef48dbbc9982a8b4212260c34d56bfd0d45fe67b31942121/uv-0.12.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a05b497c2a948c8600f4c831a89852b4d2514b7f561074225cc9edd0cc4811e2", size = 23470437, upload-time = "2026-08-14T19:56:36.525Z" }, + { url = "https://files.pythonhosted.org/packages/60/e7/8bdc37669a6cd2b46a2ec08ccbb58c61395ec84a073e199f5a4a64bb998f/uv-0.12.5-py3-none-musllinux_1_1_i686.whl", hash = "sha256:7817f8e957960f9ddc452ea353f283c0d6393e2e31b400276485adced5b1f371", size = 22545803, upload-time = "2026-08-14T19:56:40.606Z" }, + { url = "https://files.pythonhosted.org/packages/37/cc/01e39e1dbeb838a6b3c26bf97c867d6f366459b22a38bea691af8c6c94c0/uv-0.12.5-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:dc14e4f81a99b585a891350c60d1ff4557d54cb3c3c81fa45fd4e0dd512ba752", size = 23874113, upload-time = "2026-08-14T19:56:44.193Z" }, + { url = "https://files.pythonhosted.org/packages/0a/38/9053599a73a351d1cd34195c7a48c1db4d4d51b57b543607fad7ecf9354c/uv-0.12.5-py3-none-win32.whl", hash = "sha256:39bb102766c95571781a7b4c611675ea213e08df5c680f3936279b3c0d1f6c3c", size = 20744641, upload-time = "2026-08-14T19:56:47.689Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f6/a9af9311c7f5640ca2bfcfdedb7aca37fa6d1d9f5c981fb50c5be02b7477/uv-0.12.5-py3-none-win_amd64.whl", hash = "sha256:455c3e57602e2141e66e2f0bf685898c9c5e5a70377d14c9a71554a3baf3ddbf", size = 21621812, upload-time = "2026-08-14T19:56:51.126Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fb/e1266399f755f97a0783de379f2fed6dae0a2a240db32fe5a2eb976fec8a/uv-0.12.5-py3-none-win_arm64.whl", hash = "sha256:bea86f27a027e0e3af908db4bdd4f1ceef3ca2bd47673b5ccca7f550e325b1b4", size = 20381876, upload-time = "2026-08-14T19:56:54.883Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.7.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/dc/a6eb1ddfa7f1e390fa599b078453c97edb3f6f846b34fb4eac3e8ea16401/virtualenv-21.7.4.tar.gz", hash = "sha256:c9d960c95fa458171e58222a5ccab7465298e4b6559977865e627c4719f1e825", size = 5345511, upload-time = "2026-08-10T22:54:33.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/4c/eb2f52aeeaf30dbd073d315a251a63ae2b8263171ec4428c135140cb0802/virtualenv-21.7.4-py3-none-any.whl", hash = "sha256:376ec93cd6aab3044fa395d7db226db38043b7b5748948044b2a87168525e843", size = 5324444, upload-time = "2026-08-10T22:54:31.515Z" }, +]