diff --git a/ipd/sym/sym_util.py b/ipd/sym/sym_util.py index 57daf1e6..da77a1f7 100644 --- a/ipd/sym/sym_util.py +++ b/ipd/sym/sym_util.py @@ -16,10 +16,11 @@ def get_sym_frames(symid, opt, cenvec): frames, _ = get_nneigh(allframes, min(len(allframes), opt.max_nsub)) return allframes, frames -def sym_redock(xyz, Lasu, frames, opt, **_): +def sym_redock(xyz, Lasu, frames, opt, mask=None, **_): # resolve clashes in placed subunits # could probably use this to optimize the radius as well - def clash_error_comp(R0, T0, xyz, fit_tscale): + # mask: 1d th.Tensor or list + def clash_error_comp(R0, T0, xyz, fit_tscale, mask=None): xyz0 = xyz[:Lasu] xyz0_corr = xyz0.reshape(-1, 3) @ R0.T xyz0_corr = xyz0_corr.reshape(xyz0.shape) + fit_tscale*T0 @@ -35,9 +36,18 @@ def clash_error_comp(R0, T0, xyz, fit_tscale): Xsymmall = Xsymmall[:, 0, :] dsymm = th.cdist(Xsymmall, Xsymmall, p=2) dsymm_2 = dsymm.clone() + if mask is not None: + mask0 = mask[:Lasu.item()] if isinstance(Lasu, th.Tensor) else th.tensor(mask[:Lasu]) + mask_dsymm = ~(mask0[None, :].expand(Lasu, Lasu)) + mask_dsymm = mask_dsymm | mask_dsymm.T # dsymm_2 = dsymm.clone().fill_diagonal_(9999) # avoid in-place operation - for i in range(0, len(Xsymmall), Lasu): - dsymm_2[i:i + Lasu, i:i + Lasu] = 9999 + for i in range(0, len(Xsymmall), Lasu): # loop over rows + dsymm_2[i:i + Lasu, i:i + Lasu] = 9999 # masking intra-contact + if mask is not None: + for j in range(0, len(Xsymmall), Lasu): # loop over cols + if j == i: continue + # masking inter-contacts involving masked residues + dsymm_2[i:i + Lasu, j:j + Lasu][mask_dsymm] = 9999 clash = th.clamp(opt.fit_wclash - dsymm_2, min=0) loss = th.sum(clash) / Lasu return loss @@ -54,7 +64,7 @@ def Q2R(Q): def closure(): lbfgs.zero_grad() - loss = clash_error_comp(Q2R(Q0), T0, xyz, opt.fit_tscale) + loss = clash_error_comp(Q2R(Q0), T0, xyz, opt.fit_tscale, mask=mask) loss.backward() #retain_graph=True) return loss diff --git a/lib/evn/.clang-format b/lib/evn/.clang-format new file mode 100644 index 00000000..006a412f --- /dev/null +++ b/lib/evn/.clang-format @@ -0,0 +1,17 @@ +BasedOnStyle: LLVM +Standard: Cpp11 +IndentWidth: 4 +TabWidth: 4 +UseTab: Never +ColumnLimit: 90 +AllowShortIfStatementsOnASingleLine: true +AllowShortCaseLabelsOnASingleLine: true +AlignConsecutiveShortCaseStatements: + Enabled: true + AcrossEmptyLines: true + AcrossComments: true +AllowShortBlocksOnASingleLine: true +AllowShortEnumsOnASingleLine: true +AllowShortFunctionsOnASingleLine: true +AllowShortLambdasOnASingleLine: true +AllowShortLoopsOnASingleLine: true diff --git a/lib/evn/.github/PULL_REQUEST_TEMPLATE.md b/lib/evn/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..06d314ee --- /dev/null +++ b/lib/evn/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,64 @@ +## ๐Ÿš€ Summary + + + +Closes #... + +--- + +## ๐Ÿ”ง Type of change + + + +- [ ] **feat**: A new feature +- [ ] **fix**: A bug fix +- [ ] **docs**: Documentation only changes +- [ ] **style**: Formatting, missing semi colons, etc +- [ ] **refactor**: Refactoring (no functional changes) +- [ ] **perf**: A code change that improves performance +- [ ] **test**: Adding or fixing tests +- [ ] **chore**: Maintenance and tooling +- [ ] **ci**: Changes to CI/CD configuration + +--- + +## โœ… Checklist + +- [ ] PR title uses [Conventional Commits](https://www.conventionalcommits.org/) +- [ ] Tests added or updated (if applicable) +- [ ] Documentation updated (if applicable) +- [ ] I have verified this builds and works as intended +- [ ] Iโ€™m ready for review! + +--- + +## ๐Ÿงช Testing + + + +--- + +## ๐Ÿ“ฆ Release impact + +> semantic-release will determine version bump automatically. + +- [ ] BREAKING CHANGE (explain in footer below) +- [ ] This should trigger a **patch** release +- [ ] This should trigger a **minor** release +- [ ] This is internal only โ€” no release needed + +If this is a breaking change, explain why below. + +--- + +## ๐Ÿ““ Notes for reviewer + + diff --git a/lib/evn/.github/workflows/build_publish.yml b/lib/evn/.github/workflows/build_publish.yml new file mode 100644 index 00000000..d82abb5d --- /dev/null +++ b/lib/evn/.github/workflows/build_publish.yml @@ -0,0 +1,102 @@ +name: Build and upload to PyPI + +on: + workflow_dispatch: + pull_request: + push: + # branches: + # - main + release: + types: + - published + +jobs: + build_wheels: + name: Build wheels for ${{ matrix.os }} + runs-on: ${{ matrix.runs-on }} + strategy: + fail-fast: false + matrix: + os: [ linux-intel, linux-arm, macOS-intel, macOS-arm ] + # os: [ linux-intel, linux-arm, windows, macOS-intel, macOS-arm ] + include: + - archs: auto + platform: auto + - os: linux-intel + runs-on: ubuntu-latest + - os: linux-arm + runs-on: ubuntu-24.04-arm + # - os: windows + # runs-on: windows-latest + - os: macos-intel + # macos-13 was the last x86_64 runner + runs-on: macos-13 + - os: macos-arm + # macos-14+ (including latest) are ARM64 runners + runs-on: macos-latest + archs: auto,universal2 + + steps: + - uses: actions/checkout@v4 + + - name: Build wheels + uses: pypa/cibuildwheel@v2.23.1 + env: + CIBW_PLATFORM: ${{ matrix.platform }} + CIBW_ARCHS: ${{ matrix.archs }} + # Can also be configured directly, using `with:` + # with: + # package-dir: . + # output-dir: wheelhouse + # config-file: "{package}/pyproject.toml" + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + version: "0.6.8" + enable-cache: true + + - name: Test wheels + run: | + echo "Testing wheels with nox" + uv run --extra dev nox + + - uses: actions/upload-artifact@v4 + with: + name: cibw-wheels-${{ matrix.os }}-${{ strategy.job-index }} + path: ./wheelhouse/*.whl + + build_sdist: + name: Build source distribution + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build sdist + run: pipx run build --sdist + + - uses: actions/upload-artifact@v4 + with: + name: cibw-sdist + path: dist/*.tar.gz + + upload_pypi: + needs: [build_wheels, build_sdist] + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + # if: github.event_name == 'release' && github.event.action == 'published' + # or, alternatively, upload to PyPI on every tag starting with 'v' (remove on: release above to use this) + # if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + steps: + - uses: actions/download-artifact@v4 + with: + # unpacks all CIBW artifacts into dist/ + pattern: cibw-* + path: dist + merge-multiple: true + + - uses: pypa/gh-action-pypi-publish@release/v1 + # with: + # To test: repository-url: https://test.pypi.org/legacy/ diff --git a/lib/evn/.github/workflows/codecov.yaml b/lib/evn/.github/workflows/codecov.yaml new file mode 100644 index 00000000..e800e83b --- /dev/null +++ b/lib/evn/.github/workflows/codecov.yaml @@ -0,0 +1,30 @@ +name: codecov + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v4 + with: + python-version: '3.13' + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Run tests with coverage + run: | + uv run --extra all pytest --cov --cov-branch --cov-report=xml + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/lib/evn/.github/workflows/semantic-release.yaml b/lib/evn/.github/workflows/semantic-release.yaml new file mode 100644 index 00000000..03ebe9a8 --- /dev/null +++ b/lib/evn/.github/workflows/semantic-release.yaml @@ -0,0 +1,31 @@ +# .github/workflows/semantic-release.yaml + +name: Semantic Release + +on: + push: + branches: [main] + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write # for tag, changelog commit, release + issues: write # optional if you want to use release notes + pull-requests: write # optional + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Lint commit messages + uses: wagoid/commitlint-github-action@v5 + + - name: Semantic Release + uses: cycjimmy/semantic-release-action@v4 + with: + extra_plugins: | + @semantic-release/changelog + @semantic-release/git + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/lib/evn/.gitignore b/lib/evn/.gitignore new file mode 100644 index 00000000..2bcada78 --- /dev/null +++ b/lib/evn/.gitignore @@ -0,0 +1,47 @@ +sublime_build.log* +wheelhouse +__pycache__ +*.egg-info +*.egg +*.pyc +evn/_build +__pycache__ +# Prerequisites +*.d + +# Compiled Object files +*.slo +*.lo +*.o +*.obj + +# Precompiled Headers +*.gch +*.pch + +# Compiled Dynamic libraries +*.so +*.dylib +*.dll + +# Fortran module files +*.mod +*.smod + +# Compiled Static libraries +*.lai +*.la +*.a +*.lib + +# Executables +*.exe +*.out +*.app + +.doit.* +_build +*.sublime-workspace +/test-logs +.test* +*.orig diff --git a/lib/evn/.pre-commit-config.yaml b/lib/evn/.pre-commit-config.yaml new file mode 100644 index 00000000..de9bb8f0 --- /dev/null +++ b/lib/evn/.pre-commit-config.yaml @@ -0,0 +1,50 @@ +# .pre-commit-config.yaml +repos: + # โœ… Ruff: linter, formatter, isort + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.3.3 + hooks: + - id: ruff + args: [--fix, evn] + + # โœ… Validate pyproject.toml metadata and structure + - repo: https://github.com/abravalheri/validate-pyproject + rev: v0.24.1 + hooks: + - id: validate-pyproject + # Optional extra validations from SchemaStore: + additional_dependencies: ["validate-pyproject-schema-store[all]"] + + # โœ… Check for trailing whitespace, tabs, EOFs + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + + # โœ… Detect merge conflict markers + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: check-merge-conflict + + # โœ… Commitizen: Conventional Commit linter (Python-native) + - repo: https://github.com/commitizen-tools/commitizen + rev: v3.21.0 + hooks: + - id: commitizen + stages: [commit-msg] + language_version: python3.12 + + - repo: https://github.com/christophmeissner/pytest-pre-commit + rev: 1.0.0 + hooks: + - id: pytest + args: [--ignore,evn/format,--ignore,evn/tests/format] + additional_dependencies: ['assertpy', 'click', 'icecream', 'multipledispatch', + 'PrettyPrintTree', 'pyyaml', 'rapidfuzz', 'rich', 'ruff', + 'typing_extensions; python_version < "3.10"', 'wrapt','hypothesis', 'ninja_import', 'tomli'] + pass_filenames: false + always_run: true diff --git a/lib/evn/.python-version b/lib/evn/.python-version new file mode 100644 index 00000000..24ee5b1b --- /dev/null +++ b/lib/evn/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/lib/evn/CMakeLists.txt b/lib/evn/CMakeLists.txt new file mode 100644 index 00000000..33632eaa --- /dev/null +++ b/lib/evn/CMakeLists.txt @@ -0,0 +1,19 @@ +cmake_minimum_required(VERSION 3.15) +project(evn LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(PYBIND11_FINDPYTHON ON) + +find_package(pybind11 REQUIRED) +include_directories(${PROJECT_SOURCE_DIR}) + +pybind11_add_module(_token_column_format MODULE evn/format/_token_column_format.cpp) +set_target_properties(_token_column_format PROPERTIES PREFIX "" OUTPUT_NAME "_token_column_format" ) +target_link_libraries(_token_column_format PRIVATE pybind11::module) +install(TARGETS _token_column_format; DESTINATION evn/format) + +pybind11_add_module(_detect_formatted_blocks MODULE evn/format/_detect_formatted_blocks.cpp) +set_target_properties(_detect_formatted_blocks PROPERTIES PREFIX "" OUTPUT_NAME "_detect_formatted_blocks" ) +target_link_libraries(_detect_formatted_blocks PRIVATE pybind11::module) +install(TARGETS _detect_formatted_blocks; DESTINATION evn/format) diff --git a/lib/evn/LICENSE b/lib/evn/LICENSE new file mode 100644 index 00000000..8596462b --- /dev/null +++ b/lib/evn/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), evn if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/lib/evn/README.md b/lib/evn/README.md new file mode 100644 index 00000000..5d07a561 --- /dev/null +++ b/lib/evn/README.md @@ -0,0 +1,28 @@ +# evn +Evn is like ruff, but more even. Why do all python formatters have to be such basic bitches? Ever seen what clang-format does to c++? It's all neat and *even* and lined up in columns. I like the hand-formatted code feel... why not a python formatter that does that? + +``` +operators = { + "+", "-", "*", "/", "%", "**", "//", "==", "!=", + "<", ">", "<=", ">=", "=", "->", "+=", "-=", "*=", + "/=", "%=", "&", "|", "^", ">>", "<<", "~"}; +``` +``` +keywords = { + "False", "None", "True", "and", "as", "assert", + "async", "await", "break", "class", "continue", "def", + "del", "elif", "else", "except", "finally", "for", + "from", "global", "if", "import", "in", "is", + "lambda", "nonlocal", "not", "or", "pass", "raise", + "return", "try", "while", "with", "yield"}; +``` +``` +struct LineInfo { + int lineno; // Line number. + string line; // Original line. + string indent; // Leading whitespace. + string content; // Line without indent. + vector tokens; // Tokenized content. + vector pattern; // Token pattern (wildcards) +}; +``` diff --git a/lib/evn/dodo.py b/lib/evn/dodo.py new file mode 100644 index 00000000..70497394 --- /dev/null +++ b/lib/evn/dodo.py @@ -0,0 +1,143 @@ +import os +import sys +import sysconfig +from pathlib import Path +from concurrent.futures import ThreadPoolExecutor, as_completed +from rich.progress import track +import subprocess + +DOIT_CONFIG = { + 'backend': 'json', + 'dep_file': '.doit.json', +} + +root = Path(__file__).parent.absolute() +build = root / '_build' / f'py3{sys.version_info.minor}' + +def task_cmake(): + return { + 'actions': [f'cmake {find_pybind()} -B {build} -S {root} -GNinja'], + 'file_dep': [root / 'CMakeLists.txt'], + 'targets': [build / 'build.ninja'], + } + +def task_build(): + return { + 'actions': [f'cd {build} && ninja'], + 'file_dep': [build / 'build.ninja'], + } + +def find_pybind(): + pybind = sysconfig.get_paths()['purelib'] + '/pybind11' + pybind = f'-Dpybind11_DIR={pybind}' + # print(pybind) + return pybind + +def task_import_check(): + """Try to import the compiled module to verify it's working""" + + def import_test(): + try: + pass + except Exception as e: + print('โŒ Import failed:', e) + raise + + return { + 'actions': [import_test], + 'task_dep': ['build'], + } + +def task_test(): + """Run tests using pytest""" + return { + 'actions': ['pytest --doctest-modules evn'], + 'task_dep': ['import_check'], + } + +def task_matrix(): + versions = ['3.13', '3.12', '3.11', '3.10'] + log_dir = Path("test-logs") + log_dir.mkdir(exist_ok=True) + + def run_matrix(python, parallel, quiet): + selected = [python] if python in versions else versions + results = [] + print('versions:', selected) + + def run_test(v, results=results): + log_file = log_dir / f'test_py{v}.log' + # cmd = f'uv run --extra dev --python {v} doit test' + subdir = os.path.abspath(f'.test_py{v}') + os.system(f'rm {subdir}/*') + os.makedirs(subdir, exist_ok=True) + for p in ['pyproject.toml', 'evn', 'CMakeLists.txt']: + if not os.path.exists(f'{subdir}/{p}'): + os.symlink(os.path.abspath(p), f'{subdir}/{p}') + cmd = f'cd {subdir} && uv run --extra dev --python {v} pytest' + + with log_file.open('w') as f: + try: + subprocess.run(cmd, shell=True, check=True, stdout=f if quiet else None, stderr=subprocess.STDOUT) + results.append((v, True)) + except subprocess.CalledProcessError: + results.append((v, False)) + + if parallel: + with ThreadPoolExecutor() as executor: + fut = [executor.submit(run_test, v) for v in selected] + list(track(as_completed(fut), total=len(fut), description="Running tests...")) + else: + for v in selected: + run_test(v) + + print(f"\n=== Test Summary: {len(results)} results ===") + for v, passed in results: + status = "โœ… PASS" if passed else "โŒ FAIL" + print(f"Python {v}: {status} (see {log_dir}/python{v}.log)") + + # if not all(passed for _, passed in results): + # raise Exception("One or more Python versions failed") + + return dict( + params=[ + dict(name='python', + long='python', + default='all', + help='Python version to run (e.g., 3.11), or "all"'), + dict(name='parallel', long='parallel', default=True, help='run tests in parallel'), + dict(name='quiet', long='quiet', default=False), + ], + actions=[(run_matrix, )], + verbosity=2, + ) + +def task_wheel(): + os.makedirs('wheelhouse', exist_ok=True) + return dict( + actions=[f'cibuildwheel --only cp3{ver}-manylinux_x86_64' for ver in range(10, 14)], + file_dep=[ + 'evn/format/_common.hpp', + 'evn/format/_detect_formatted_blocks.cpp', + 'evn/format/_token_column_format.cpp', + ], + targets=[ + # 'wheelhouse/evn-0.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl', + 'wheelhouse/evn-0.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl', + 'wheelhouse/evn-0.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl', + 'wheelhouse/evn-0.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl', + 'wheelhouse/evn-0.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl', + ], + ) + +def task_nox(): + return dict( + actions=['nox'], + file_dep=[ + # 'wheelhouse/evn-0.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl', + 'wheelhouse/evn-0.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl', + 'wheelhouse/evn-0.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl', + 'wheelhouse/evn-0.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl', + 'wheelhouse/evn-0.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl', + ], + ) diff --git a/lib/evn/evn/.ninja_deps b/lib/evn/evn/.ninja_deps new file mode 100644 index 00000000..8c01f2e4 Binary files /dev/null and b/lib/evn/evn/.ninja_deps differ diff --git a/lib/evn/evn/.ninja_log b/lib/evn/evn/.ninja_log new file mode 100644 index 00000000..3f509a1f --- /dev/null +++ b/lib/evn/evn/.ninja_log @@ -0,0 +1,19 @@ +# ninja log v6 +5 8 1743263471880486728 clean a31eedcededa8236 +2 3394 1742848350937295409 CMakeFiles/_detect_formatted_blocks.dir/_detect_formatted_blocks.cpp.o 321030c8f6ac06de +1 3137 1742769425983665321 CMakeFiles/_token_column_format.dir/_token_column_format.cpp.o 3fedb39f8e097117 +2 75 1743263495802377607 build.ninja 5135143e80ad25e2 +3394 5404 1742848354329259863 _detect_formatted_blocks.cpython-313-x86_64-linux-gnu.so 79fa6303d8f2ea96 +3137 4577 1742769429119610036 _token_column_format.cpython-313-x86_64-linux-gnu.so ea4f560da7a4d621 +11 3974 1743263503484179822 CMakeFiles/_detect_formatted_blocks.dir/_detect_formatted_blocks.cpp.o 321030c8f6ac06de +1 3983 1743263525384966447 CMakeFiles/_token_column_format.dir/_token_column_format.cpp.o 3fedb39f8e097117 +1 4008 1743263525386517830 CMakeFiles/_detect_formatted_blocks.dir/_detect_formatted_blocks.cpp.o 321030c8f6ac06de +3983 5862 1743263529366927592 _token_column_format.cpython-313-x86_64-linux-gnu.so ea4f560da7a4d621 +4009 6087 1743263529392927338 _detect_formatted_blocks.cpython-313-x86_64-linux-gnu.so 79fa6303d8f2ea96 +1 3909 1743264098392454494 CMakeFiles/_detect_formatted_blocks.dir/_detect_formatted_blocks.cpp.o 321030c8f6ac06de +2 3926 1743264179137423957 CMakeFiles/_detect_formatted_blocks.dir/_detect_formatted_blocks.cpp.o 321030c8f6ac06de +2 2095 1743264330011152905 _detect_formatted_blocks.cpython-313-x86_64-linux-gnu.so 79fa6303d8f2ea96 +2 3799 1743264383543193544 CMakeFiles/_detect_formatted_blocks.dir/_detect_formatted_blocks.cpp.o 321030c8f6ac06de +2 3860 1743264383542338723 CMakeFiles/_token_column_format.dir/_token_column_format.cpp.o 3fedb39f8e097117 +3860 5699 1743264387400299269 _token_column_format.cpython-313-x86_64-linux-gnu.so ea4f560da7a4d621 +3799 5880 1743264387339299892 _detect_formatted_blocks.cpython-313-x86_64-linux-gnu.so 79fa6303d8f2ea96 diff --git a/lib/evn/evn/__init__.py b/lib/evn/evn/__init__.py new file mode 100644 index 00000000..6830b1cb --- /dev/null +++ b/lib/evn/evn/__init__.py @@ -0,0 +1,288 @@ +# __version__ = '0.7.1' + +from time import perf_counter +from pathlib import Path as Path + +_start, _timings = perf_counter(), {} + +pkgroot = Path(__file__).parent.absolute() +projroot = pkgroot.parent +evn_installed = False +projconf = projroot / 'pyproject.toml' +if not (projroot / 'pyproject.toml').exists: + projroot = None + evn_installed = True +show_trace = False +chronometer: 'evn._prelude.chrono.Chrono' = None #type:ignore #noqa + +def evn_init_checkpoint(name): + global _start, _timings + _timings[name] = [perf_counter() - _start] + _start = perf_counter() + +# import os +import typing as t # noqa +import dataclasses as dc # noqa +import functools as ft # noqa +import itertools as it # noqa +import contextlib as cl # noqa +import os as os # noqa +import sys as sys # noqa +from copy import copy as copy, deepcopy as deepcopy +from multipledispatch import dispatch as dispatch +from typing import ( + TYPE_CHECKING as TYPE_CHECKING, + Any as Any, + Callable as Callable, + cast as cast, + Iterator as Iterator, + IO as IO, + TypeVar as TypeVar, + Union as Union, + Iterable as Iterable, + Mapping as Mapping, + MutableMapping as MutableMapping, + Sequence as Sequence, + MutableSequence as MutableSequence, + Optional as Optional, +) +from ninja_import import ninja_import as ninja_import +from icecream import ic as ic + +ic.configureOutput(includeContext=True) +import builtins + +builtins.ic = ic # make ic available globally + +evn_init_checkpoint('INIT evn basic imports') +from evn._prelude.basic_types import ( + NA as NA, + NoOp as NoOp, + isstr as isstr, + isint as isint, + islist as islist, + isdict as isdict, + isseq as isseq, + ismap as ismap, + isseqmut as isseqmut, + ismapmut as ismapmut, + isiter as isiter, + is_free_function as is_free_function, + is_bound_method as is_bound_method, + is_unbound_method as is_unbound_method, + is_member_function as is_member_function, + is_function as is_function, + is_generator as is_generator, +) +from evn._prelude.make_decorator import make_decorator as make_decorator +from evn._prelude.import_util import ( + is_installed as is_installed, + not_installed as not_installed, +) +from evn._prelude.lazy_import import ( + lazyimport as lazyimport, + lazyimports as lazyimports, + maybeimport as maybeimport, + maybeimports as maybeimports, + LazyImportError as LazyImportError, +) +from evn._prelude.lazy_dispatch import lazydispatch as lazydispatch +from evn._prelude.structs import ( + struct as struct, + mutablestruct as mutablestruct, + basestruct as basestruct, + field as field, + asdict as asdict, +) +from evn._prelude.typehints import ( + KW as KW, + T as T, + R as R, + C as C, + P as P, + F as F, + FieldSpec as FieldSpec, + EnumerIter as EnumerIter, + EnumerListIter as EnumerListIter, + basic_typevars as basic_typevars, + annotype as annotype, +) + +from evn._prelude.chrono import ( + Chrono as Chrono, + chrono as chrono, + chrono_enter_scope as chrono_enter_scope, + chrono_exit_scope as chrono_exit_scope, + chrono_checkpoint as chrono_checkpoint +) +from evn.decofunc import ( + iterize_on_first_param as iterize_on_first_param, + iterize_on_first_param_path as iterize_on_first_param_path, + is_iterizeable as is_iterizeable, + safe_lru_cache as safe_lru_cache, +) +from evn.decon import ( + item_wise_operations as item_wise_operations, + subscriptable_for_attributes as subscriptable_for_attributes, +) +from evn.decon.iterables import ( + first as first, + nth as nth, + head as head, + order as order, + reorder as reorder, + reorder_inplace as reorder_inplace, + zipenum as zipenum, + subsetenum as subsetenum, + zipmaps as zipmaps, + zipitems as zipitems, + addreduce as addreduce, # type: ignore + orreduce as orreduce, # type: ignore + andreduce as andreduce, # type: ignore + mulreduce as mulreduce, # type: ignore +) + +# from evn.error import panic as panic +# from evn.meta import kwcheck as kwcheck, kwcall as kwcall, kwcurry as kwcurry +# from evn.metadata import get_metadata as get_metadata, set_metadata as set_metadata +# from evn.functional import map as map, visit as visit +# from evn.format import print_table as print_table, print as print +from evn.decon.bunch import Bunch as Bunch, bunchify as bunchify, unbunchify as unbunchify + +# from evn.observer import hub as hub +# from evn.tolerances import Tolerances as Tolerances +# from evn.iterables import first as first +# from evn.contexts import force_stdio as force_stdio +from evn.meta import kwcall as kwcall, kwcheck as kwcheck +from evn.print import make_table as make_table +from evn.cli import CLI as CLI + +installed = Bunch(_default=is_installed, _frozen=True) + +from evn.dev.contexts import ( + capture_asserts as capture_asserts, + capture_stdio as capture_stdio, + catch_em_all as catch_em_all, + cd as cd, + cd_project_root as cd_project_root, + force_stdio as force_stdio, + just_stdout as just_stdout, + nocontext as nocontext, + redirect as redirect, + set_class as set_class, + trace_writes_to_stdout as trace_writes_to_stdout, + np_printopts as np_printopts, + np_compact as np_compact, +) +from evn._prelude.inspect import ( + inspect as inspect, + show as show, + diff as diff, + summary as summary, + trace as trace, +) + +import evn.ident as ident + +if TYPE_CHECKING: + from evn import ( + config as config, + cli as cli, + dev as dev, + decofunc as decofunc, + decon as decon, + doc as doc, + format as format, + meta as meta, + testing as testing, + tree as tree, + tool as tool, + ) +else: + config = lazyimport('evn.config') + cli = lazyimport('evn.cli') + dev = lazyimport('evn.dev') + decofunc = lazyimport('evn.decofunc') + decon = lazyimport('evn.decon') + doc = lazyimport('evn.doc') + format = lazyimport('evn.format') + meta = lazyimport('evn.meta') + testing = lazyimport('evn.testing') + tree = lazyimport('evn.tree') + tool = lazyimport('evn.tool') + +# optional_imports = ninja_import('evn.contexts.optional_imports') +# capture_stdio = ninja_import('evn.contexts.capture_stdio') +# ic, icm, icv = ninja_imports('evn.debug', 'ic icm icv') +# timed = ninja_import('evn.instrumentation.timer.timed') +# item_wise_operations = ninja_import('evn.item_wise.item_wise_operations') +# subscriptable_for_attributes = ninja_import('evn.decorators.subscriptable_for_attributes') +# iterize_on_first_param = ninja_import('evn.decorators.iterize_on_first_param') + +# _global_chrono = None + +# evn_init_checkpoint('INIT evn prelude imports') + +# evn_init_checkpoint('INIT evn from subpackage imports') +# from evn import dev as dev, homog as homog + +# if typing.TYPE_CHECKING: +# from evn import crud + +# import evn.homog.thgeom as htorch +# import evn.homog.hgeom as hnumpy +# from evn import pdb +# from evn import protocol +# from evn import sel +# from evn import sym +# from evn import ml +# from evn import motif +# from evn import atom +# from evn import tools +# from evn import tests +# # from evn import fit +# # from evn import samp +# # from evn import voxel +# else: +# atom = lazyimport('evn.atom') +# crud = lazyimport('evn.crud') +# cuda = lazyimport('evn.cuda') +# h = lazyimport('evn.homog.thgeom') +# hnumpy = lazyimport('evn.homog.hgeom') +# htorch = lazyimport('evn.homog.thgeom') +# ml = lazyimport('evn.ml') +# motif = lazyimport('evn.motif') +# pdb = lazyimport('evn.pdb') +# protocol = lazyimport('evn.protocol') +# qt = lazyimport('evn.qt') +# sel = lazyimport('evn.sel') +# sym = lazyimport('evn.sym') +# tests = lazyimport('evn.tests') +# tools = lazyimport('evn.tools') +# # fit = lazyimport('evn.fit') +# # samp = lazyimport('>evn.samp') +# # voxel = lazyimport('evn.voxel') +# viz = lazyimport('evn.viz') +# evn_init_checkpoint('INIT evn subpackage imports') + +# with contextlib.suppress(ImportError): +# import builtins + +# setattr(builtins, 'ic', ic) + +# def showme(*a, **kw): +# if all(homog.viz.can_showme(a, **kw)): +# return homog.viz.showme(a, **kw) +# from evn.viz import showme as viz_showme + +# viz_showme(*a, **kw) + +# # from evn.project_config import install_evn_pre_commit_hook +# # install_evn_pre_commit_hook(projdir, '..') +# # evn_init_checkpoint('INIT evn pre commit hook') + +# if _global_chrono: _global_chrono.checkpoints.update(_timings) +# else: _global_chrono = Chrono(checkpoints=_timings) +# dev.global_chrono.checkpoints.update(_timings) + +# caching_enabled = True diff --git a/lib/evn/evn/__main__.py b/lib/evn/evn/__main__.py new file mode 100644 index 00000000..3082ffc3 --- /dev/null +++ b/lib/evn/evn/__main__.py @@ -0,0 +1,7 @@ +import evn + +def main(): + evn.tool.EvnCLI._run_this() + +if __name__ == '__main__': + main() diff --git a/lib/evn/evn/_prelude/__init__.py b/lib/evn/evn/_prelude/__init__.py new file mode 100644 index 00000000..932dbebd --- /dev/null +++ b/lib/evn/evn/_prelude/__init__.py @@ -0,0 +1,9 @@ +from evn._prelude.make_decorator import make_decorator as make_decorator +from evn._prelude import chrono as chrono +from evn._prelude import import_util as import_util +from evn._prelude import lazy_import as lazy_import +from evn._prelude import lazy_dispatch as lazy_dispatch +from evn._prelude import structs as structs +from evn._prelude import typehints as typehints +from evn._prelude import version as version +from evn._prelude import inspect as inspect diff --git a/lib/evn/evn/_prelude/basic_types.py b/lib/evn/evn/_prelude/basic_types.py new file mode 100644 index 00000000..cb83c08a --- /dev/null +++ b/lib/evn/evn/_prelude/basic_types.py @@ -0,0 +1,67 @@ +import inspect +import types +import functools +import typing as t + +@functools.total_ordering +class Missing: + __slots__ = () + + def __repr__(self): + return 'NA' + + def __eq__(self, other): + return isinstance(other, Missing) + + def __lt__(self, other): + return True + +NA = Missing() + +def NoOp(*a, **kw): + None + +def is_free_function(obj: t.Any) -> bool: + return isinstance(obj, types.FunctionType) + +def is_bound_method(obj: t.Any) -> bool: + return inspect.ismethod(obj) and obj.__self__ is not None + +def is_unbound_method(obj: t.Any) -> bool: + return inspect.isfunction(obj) and hasattr(obj, '__qualname__') and '.' in obj.__qualname__ + +def is_member_function(obj: t.Any) -> bool: + return is_bound_method(obj) or is_unbound_method(obj) + +def is_function(obj: t.Any) -> bool: + return is_member_function(obj) or is_free_function(obj) + +def is_generator(obj: t.Any) -> bool: + return isinstance(obj, types.GeneratorType) + +def isstr(s: t.Any) -> bool: + return isinstance(s, str) + +def isint(s: t.Any) -> bool: + return isinstance(s, int) + +def islist(s: t.Any) -> bool: + return isinstance(s, list) + +def isdict(s: t.Any) -> bool: + return isinstance(s, dict) + +def isseq(s: t.Any) -> bool: + return isinstance(s, t.Sequence) + +def ismap(s: t.Any) -> bool: + return isinstance(s, t.Mapping) + +def isseqmut(s: t.Any) -> bool: + return isinstance(s, t.MutableSequence) + +def ismapmut(s: t.Any) -> bool: + return isinstance(s, t.MutableMapping) + +def isiter(s: t.Any) -> bool: + return isinstance(s, t.Iterable) diff --git a/lib/evn/evn/_prelude/chrono.py b/lib/evn/evn/_prelude/chrono.py new file mode 100644 index 00000000..563cd213 --- /dev/null +++ b/lib/evn/evn/_prelude/chrono.py @@ -0,0 +1,219 @@ +from contextlib import contextmanager +import types +import typing as t +from time import perf_counter +from dataclasses import dataclass, field + +import evn +from evn._prelude.make_decorator import make_decorator + +@dataclass +class Chrono: + name: str = 'Chrono' + verbose: bool = False + start_time: float = field(default_factory=perf_counter) + scopestack: list = field(default_factory=list) + times: dict[str, list] = field(default_factory=dict) + times_tot: dict[str, list] = field(default_factory=dict) + entered: bool = False + _pre_checkpoint_name: str | None = None + stopped: bool = False + debug: bool = False + + def __post_init__(self): + self.start() + + def clear(self): + self.times.clear() + self.times_tot.clear() + self.scopestack.clear() + self.start() + + def start(self): + assert not self.stopped + self.scopestack.append(TimerScope(self.name)) + + def stop(self): + """Stop the chrono and store total elapsed time.""" + assert not self.stopped + self.exit_scope(self.name) + self.store_finished_scope(TimerScope('total', 0, self.elapsed())) + self.stopped = True + + def __enter__(self): + if not self.entered: self.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if exc_type: print(f'An exception of type {exc_type} occurred: {exc_val}') + self.stop() + + def scope(self, scopekey) -> t.ContextManager: + name = self.check_scopekey(scopekey, 'scope') + + @contextmanager + def cm(chrono=self): + chrono.enter_scope(name) + yield + chrono.exit_scope(name) + + return cm() + + def store_finished_scope(self, scope): + assert not (self.stopped or scope.stopped) + # print(scope.name, evn.ident.hash(scope), t) + time, tottime = scope.final() + self.times.setdefault(scope.name, []).append(time) + self.times_tot.setdefault(scope.name, []).append(tottime) + + def scope_name(self, obj: 'str|object') -> str: + if isinstance(obj, str): return obj + if hasattr(obj, '__module__'): + return f'{obj.__module__}.{obj.__qualname__}'.replace('.', '') + return f'{obj.__qualname__}'.replace('.', '') + + def enter_scope(self, scopekey): + self._pre_checkpoint_name = None + name = self.check_scopekey(scopekey, 'enter_scope') + if self.scopestack: + self.scopestack[-1].subscope_begins() + self.scopestack.append(TimerScope(name)) + + def exit_scope(self, scopekey: str | object): + self._pre_checkpoint_name = None + name = self.check_scopekey(scopekey, 'exit_scope') + if not self.scopestack: raise RuntimeError('Chrono is not running') + err = f'exiting scope: {name} doesnt match: {self.scopestack[-1].name}' + assert self.scopestack[-1].name == name, err + self.store_finished_scope(self.scopestack.pop()) + if self.scopestack: self.scopestack[-1].subscope_ends() + + def checkpoint(self, scopekey: str | object): + oldname = self.scopestack[-1].name + newname = self.check_scopekey(scopekey, 'checkpoint') + if not self._pre_checkpoint_name: self._pre_checkpoint_name = oldname + self.scopestack[-1].name = newname + self.store_finished_scope(self.scopestack.pop()) + self.scopestack.append(TimerScope(self._pre_checkpoint_name)) + + def check_scopekey(self, scopekey, label): + if self.debug: print(label, scopekey) + assert not self.stopped + return self.scope_name(scopekey) + + def elapsed(self) -> float: + """Return the total elapsed time.""" + return perf_counter() - self.start_time + + def report_dict(self, order='longest', summary=sum, tottime=False): + """ + Generate a report dictionary of + times. + + Args: + order (str): Sorting order ('longest' or 'callorder'). + summary (callable): Function to summarize times (e.g., sum, mean). + + Returns: + dict: Checkpoint times summary. + """ + times = self.times_tot if tottime else self.times + keys = times.keys() + if order == 'longest': + sorted_items = sorted(keys, key=lambda k: times[k], reverse=True) + elif order == 'callorder': + sorted_items = keys + else: + raise ValueError(f'Unknown order: {order}') + return {k: summary(times[k]) for k in sorted_items} + + def report(self, order='longest', summary=sum, printme=True) -> str: + """ + Print or return a report of + profile. + + Args: + order (str): Sorting order ('longest' or 'callorder'). + summary (callable): Function to summarize profile (e.g., sum, mean). + printme (bool): Whether to print the report. + + Returns: + str: Report string. + """ + profile = self.report_dict(order=order, summary=summary) + report_lines = [f'Chrono Report ({self.name})'] + report_lines.extend(f'{name}: {time_:.6f}s' for name, time_ in profile.items()) + report = '\n'.join(report_lines) + if printme: + print(report) + return report + + def find_times(self, name): + return next((v for k, v in self.times.items() if name in k), None) + +@dataclass +class TimerScope: + name: str + start_time: float = field(default_factory=perf_counter) + sub_start: float = field(default_factory=perf_counter) + total: float = 0 + subtotal: float = 0 + stopped: bool = False + debug: bool = False + + def final(self): + if self.debug: print('final', self.name, perf_counter(), self.sub_start, self.subtotal) + assert self.sub_start < 9e8 + self.stopped, subtotal = True, perf_counter() - self.sub_start + self.subtotal + return subtotal, perf_counter() - self.start_time + + def subscope_begins(self): + if self.debug: print('subscope_begins', self.name) + self.subtotal += perf_counter() - self.sub_start + self.sub_start = 9e9 + + def subscope_ends(self): + if self.debug: print('subscope_ends', self.name) + self.sub_start = perf_counter() + +evn.chronometer = Chrono('main') + +def chrono_enter_scope(name, **kw): + global chronometer + chrono = kw.get('chrono', evn.chronometer) + chrono.enter_scope(name, **kw) + +def chrono_exit_scope(name, **kw): + global chronometer + chrono = kw.get('chrono', evn.chronometer) + chrono.exit_scope(name, **kw) + +def chrono_checkpoint(name, **kw): + global chronometer + chrono = kw.get('chrono', evn.chronometer) + chrono.checkpoint(name, **kw) + +@make_decorator(chrono=evn.chronometer) +def chrono(wrapped, *args, chrono=None, **kw): + chrono2: Chrono = kw.get('chrono', chrono) + chrono2.enter_scope(wrapped) + result = wrapped(*args, **kw) + chrono2.exit_scope(wrapped) + if not isinstance(result, types.GeneratorType): + return result + return _generator_proxy(wrapped, result, chrono2) + +def _generator_proxy(gener, wrapped, chrono): + try: + geniter = iter(gener) + while True: + chrono.enter_scope(wrapped) + item = next(geniter) + chrono.exit_scope(wrapped) + yield item + except StopIteration: + pass + finally: + chrono.exit_scope(wrapped) + if hasattr(gener, 'close'): + gener.close() diff --git a/lib/evn/evn/_prelude/import_util.py b/lib/evn/evn/_prelude/import_util.py new file mode 100644 index 00000000..d26dd0af --- /dev/null +++ b/lib/evn/evn/_prelude/import_util.py @@ -0,0 +1,12 @@ +import importlib.util +from typing import TypeVar + +T = TypeVar('T') + + +def is_installed(name): + return importlib.util.find_spec(name) + + +def not_installed(name): + return not importlib.util.find_spec(name) diff --git a/lib/evn/evn/_prelude/inspect.py b/lib/evn/evn/_prelude/inspect.py new file mode 100644 index 00000000..7085e47e --- /dev/null +++ b/lib/evn/evn/_prelude/inspect.py @@ -0,0 +1,112 @@ +import sys +import types as t +import os +from multipledispatch import dispatch +import rich +from evn._prelude.lazy_dispatch import lazydispatch +from evn._prelude.basic_types import is_member_function + +import evn + +def inspect(obj, **kw): + return show_impl(obj, **kw) + +def show(obj, out=print, **kw): + with evn.capture_stdio() as printed: + result = show_impl(obj, **kw) + printed = printed.read() + assert not result + if out and (result or printed): + with evn.force_stdio(): + if 'PYTEST_CURRENT_TEST' in os.environ: print() + kwout = {} + try: + kwout = evn.kwcheck(kw, out) + except ValueError as e: + if sys.version_info.minor > 10: + raise e from None + out(printed or result, **kwout) + return result + +_show = show + +def diff(obj1, obj2, out=print, **kw): + import evn.tree.tree_diff # noqa + result = diff_impl(obj1, obj2, **kw) + if out and result: + _show(result, **kw) + return result + +@lazydispatch(object) +def summary(obj, nest=False, **kw) -> str: + if hasattr(obj, 'summary'): + return obj.summary() + if isinstance(obj, (list, tuple)): + return str([summary(o, nest=True, **kw) for o in obj]) + return obj if nest else str(obj) + +############ impl ################## + +@dispatch(object) +def show_impl(obj, **kw): + """Default show function.""" + import evn.tree.tree_format # noqa + evn.kwcall(kw, rich.inspect, obj) + +@dispatch(object, object) +def diff_impl(obj1, obj2, **kw): + return set(obj1) ^ set(obj2) + +@summary.register(type) +def _(obj): + if hasattr(obj, '__name__'): return f'Type: {obj.__name__}' + return str(obj).replace("'", '') + +@summary.register(t.FunctionType) +def _(obj): + return f'{obj.__module__}.{obj.__qualname__}'.replace('.', '') + +@summary.register(t.MethodType) +def _(obj): + return f'{obj.__module__}.{obj.__qualname__}'.replace('.', '') + +@summary.register('numpy.ndarray') +def _(array, maxnumel=24): + if array.size <= maxnumel: + return str(array) + return f'{array.__class__.__name__}{list(array.shape)}' + +@summary.register('torch.Tensor') +def _(tensor, maxnumel=24): + if tensor.numel <= maxnumel: + return str(tensor) + return f'{tensor.__class__.__name__}{list(tensor.shape)}' + +_trace_indent = 0 + +def trace(func, showargs=True, showreturn=True, **kw): + """Decorator to show function output.""" + + def wrapper(*args, **kwargs): + if not evn.show_trace: + return func(*args, **kwargs) + global _trace_indent + indent = ' ' * _trace_indent + if showargs: + sargs = args[1:] if is_member_function(func) else args + argstr = '' + argstr = [summary(a) for a in sargs] + [f'{k}={summary(v)}' for k, v in kwargs.items()] + argstr = f'({", ".join(argstr)})' + print(f'{indent}call: {func.__name__}{argstr}') + _trace_indent += 1 + result = func(*args, **kwargs) + _trace_indent -= 1 + returnstr = '' + if showreturn and result is not None: + returnstr = f' -> {summary(result, **kw)}' + print(f'{indent}return: {func.__name__}{returnstr}') + if result: + summary(result, **kw) + return result + + return wrapper diff --git a/lib/evn/evn/_prelude/lazy_dispatch.py b/lib/evn/evn/_prelude/lazy_dispatch.py new file mode 100644 index 00000000..a443bb5b --- /dev/null +++ b/lib/evn/evn/_prelude/lazy_dispatch.py @@ -0,0 +1,160 @@ +import re +import contextlib +import sys +from functools import wraps +from importlib import import_module +from typing import Callable, Union, Optional + +GLOBAL_DISPATCHERS: dict[str, 'LazyDispatcher'] = {} + +class NoType: + pass + +def NoPred(pred) -> bool: + return False + +def _qualify(func: Callable, scope: Optional[str]) -> str: + mod = func.__module__ + name = func.__name__ + qname = func.__qualname__ + if scope == 'local': + return f'{mod}.{qname}' + elif scope == 'global': + return name + elif scope == 'project': + parts = mod.split('.') + root = parts[0] if parts else mod + return f'{root}.{name}' + elif scope == 'subpackage': + parts = mod.split('.') + subpkg = '.'.join(parts[:-1]) + return f'{subpkg}.{name}' + else: + return f'{mod}.{qname}' # default to local + +class LazyDispatcher: + + def __init__(self, func: Callable, scope: Optional[str] = None): + self._base_func = func + self._registry: dict[Union[str, type], Callable] = {} + self._predicate_registry: dict[Callable[[type], bool], Callable] = {} + self._resolved_types = {} + self._key = _qualify(func, scope) + wraps(func)(self) + + # GLOBAL_DISPATCHERS[self._key] = self + + def _register( + self, + func: Callable, + typ: Union[str, type] = NoType, + predicate: Callable[[type], bool] = NoPred, + ): + if typ is object: self._base_func = func + elif predicate is NoPred: self._registry[typ] = func + else: self._predicate_registry[predicate] = func + return self + + def register(self, typ: Union[str, type] = NoType, predicate: Callable[[type], bool] = NoPred): + + def decorator(func): + result = self._register(func, typ, predicate) + return result + + return decorator + + def _resolve_lazy_types(self): + for typ in list(self._registry): + if isinstance(typ, str) and typ not in self._resolved_types: + if not is_valid_qualname(typ): + raise ValueError(f'Invalid type name: {typ}') + modname, _, typename = typ.rpartition('.') + # if not evn.installed[modname]: + # raise TypeError(f"Module {modname} is not installed.") + if mod := sys.modules.get(modname): + with contextlib.suppress(AttributeError): + self._resolved_types[typ] = getattr(mod, typename) + self._registry[self._resolved_types[typ]] = self._registry[typ] + + def __call__(self, obj, *args, debug=False, **kwargs): + self._resolve_lazy_types() + + # TODO: make predicate work with obj, eg. floats > 7 + + if (obj_type := type(obj)) in self._registry: + if debug: print('in registery') + return self._registry[obj_type](obj, *args, **kwargs) + + for pred, func in self._predicate_registry.items(): + if pred(obj): + if debug: print('select by predicates', pred, obj) + self._registry[type(obj)] = func + return func(obj, *args, **kwargs) + + for key, func in self._registry.items(): + if debug: print('unfound key', key, obj) + if key := self.check_type(key): + if isinstance(obj, key): + self._registry[type(obj)] = func + return func(obj, *args, **kwargs) + + return self._base_func(obj, *args, **kwargs) + + def check_type(self, key): + if isinstance(key, type): return key + if isinstance(key, str): + modname, _, typename = key.rpartition('.') + try: + import_module(modname) + self._resolve_lazy_types() + return self._resolved_types[key] + except ImportError: + return None + # raise TypeError(f'Key {key} is not a type or str of type') + +def lazydispatch( + arg=None, + *, + predicate: Callable[[type], bool] = NoPred, + scope: Optional[str] = None, +) -> LazyDispatcher: + if not isinstance(arg, type) and callable(arg) and predicate == NoPred and scope is None: + # Case: used as @lazydispatch without arguments + return LazyDispatcher(arg) + + # Case: used as @lazydispatch("type.path", scope=...) + def wrapper(func): + key = _qualify(func, scope) + if key not in GLOBAL_DISPATCHERS: + GLOBAL_DISPATCHERS[key] = LazyDispatcher(func, scope) + dispatcher = GLOBAL_DISPATCHERS[key] + return dispatcher._register(func, arg, predicate=predicate) + + return wrapper + +_QUALNAME_RE = re.compile(r'^[a-zA-Z_][\w\.]*\.[A-Z_a-z]\w*$') + +def is_valid_qualname(s: str) -> bool: + """ + Check if a string looks like a valid qualified name for a type. + + A valid qualname is expected to have: + - one or more dot-separated components + - all parts must be valid identifiers + - the final part (the type name) must start with a letter or underscore + + Examples: + >>> is_valid_qualname('torch.Tensor') + True + >>> is_valid_qualname('numpy.ndarray') + True + >>> is_valid_qualname('builtins.int') + True + >>> is_valid_qualname('not.valid.') + False + >>> is_valid_qualname('1bad.name') + False + >>> is_valid_qualname('no_dot') + False + """ + return bool(_QUALNAME_RE.match(s)) diff --git a/lib/evn/evn/_prelude/lazy_import.py b/lib/evn/evn/_prelude/lazy_import.py new file mode 100644 index 00000000..828798a0 --- /dev/null +++ b/lib/evn/evn/_prelude/lazy_import.py @@ -0,0 +1,171 @@ +import inspect +import sys +from importlib import import_module +from types import ModuleType +import typing +from .import_util import is_installed + +forbid = set() + +def lazyimports( + *names: str, + package: typing.Sequence[str] = (), + **kw, +) -> list[ModuleType]: + """Lazy import of a module. The module will be imported when it is first accessed. + + Args: + names (str): The name(s) of the module(s) to import. + package (str): The package to install if the module cannot be imported. + warn (bool): If True, print a warning if the module cannot be imported. + + """ + assert len(names) + if not names: raise ValueError('package name is required') + if package: assert len(package) == len(names) and not isinstance(package, str) + else: package = ('', ) * len(names) + modules = [lazyimport(name, package=pkg, **kw) for name, pkg in zip(names, package)] + return modules + +def timed_import_module(modnames): + if isinstance(modnames, str): modnames = (modnames, ) + for modname in modnames: + assert modname not in forbid, f'forbbiden import! {modname}' + try: + mod = import_module(modname) + break + except ImportError: + mod = None + if mod is None: import_module(modnames[0]) + return mod + +def lazyimport(name: 'str | tuple[str]', + package: str = '', + pip: bool = False, + mamba: bool = False, + channels: str = '', + warn: bool = True, + maybeimport=False) -> ModuleType: + if typing.TYPE_CHECKING or maybeimport: + try: + return timed_import_module(name) + except ImportError: + return FalseModule(name if isinstance(name, str) else name[0]) + else: + return _LazyModule(name, package, pip, mamba, channels, warn) + +def maybeimport(name) -> ModuleType: + return lazyimport(name, maybeimport=True) + +def maybeimports(*names) -> list[ModuleType]: + return lazyimports(*names, maybeimport=True) + +class LazyImportError(ImportError): + pass + +def _get_package(name): + if isinstance(name, str): return name.split('.', maxsplit=1)[0] + if isinstance(name, tuple): return tuple(n.split('.', maxsplit=1)[0] for n in name) + raise ValueError(f'Invalid name type: {type(name)}') + +class _LazyModule(ModuleType): + """A class to represent a lazily imported module.""" + + # __slots__ = ('_lazymodule_name', '_lazymodule_package', '_lazymodule_pip', '_lazymodule_mamba', '_lazymodule_channels', '_lazymodule_callerinfo', '_lazymodule_warn') + + def __init__(self, name: str, package: str = '', pip=False, mamba=False, channels='', warn=True): + # from ipd.dev.code.inspect import caller_info + self._lazymodule_name = name + self._lazymodule_package = package or _get_package(name) + self._lazymodule_pip = pip + self._lazymodule_mamba = mamba + self._lazymodule_channels = channels + # self._lazymodule_callerinfo = caller_info(excludefiles=[__file__]) + self._lazymodule_warn = warn + # if name not in _DEBUG_ALLOW_LAZY_IMPORT: + # self._lazymodule_now() + # _all_skipped_lazy_imports.add(name) + + def _lazymodule_import_now(self) -> ModuleType: + """Import the module _lazymodule_import_now.""" + try: + return timed_import_module(self._lazymodule_name) + except ImportError as e: + if 'doctest' in sys.modules: + if in_doctest(): + return FalseModule(self._lazymodule_name if isinstance(self._lazymodule_name, str) else self._lazymodule_name[0]) + raise e from None + + + def _lazymodule_is_loaded(self): + return self._lazymodule_name in sys.modules + + def __getattr__(self, name: str): + if name.startswith('_lazymodule_'): return self.__dict__[name] + if name == '_loaded_module': + if '_loaded_module' not in self.__dict__: + self._loaded_module = self._lazymodule_import_now() + return self.__dict__['_loaded_module'] + + return getattr(self._loaded_module, name) + + def __dir__(self) -> list[str]: + return dir(self._loaded_module) + + def __repr__(self) -> str: + return '{t}({n})'.format( + t=type(self).__name__, + n=self._lazymodule_name, + ) + + def __bool__(self) -> bool: + return bool(is_installed(self._lazymodule_name)) + +class FalseModule(ModuleType): + + def __bool__(self): + return False + + +def in_doctest(): + return any('doctest' in frame.filename for frame in inspect.stack()) + +_all_skipped_lazy_imports = set() +_skip_global_install = False +_warned = set() + + # def _try_mamba_install(self): + # mamba = sys.executable.replace('/bin/python', '') + # mamba, env = mamba.split('/') + # # mamba = '/'.join(mamba[:-1])+'/bin/mamba' + # mamba = 'mamba' + # cmd = f'{mamba} activate {env} && {mamba} install {self._lazymodule_channels} {self._lazymodule_package}' + # result = subprocess.check_call(cmd.split(), shell=True) + # assert not isinstance(result, int) and 'error' not in result.lower() + + # def _pipimport(self): + # global _skip_global_install + # try: + # return timed_import_module(self._lazymodule_name) + # except (ValueError, AssertionError, ModuleNotFoundError): + # if self._lazymodule_pip and self._lazymodule_pip != 'user': + # if not _skip_global_install: + # try: + # sys.stderr.write(f'PIPIMPORT {self._lazymodule_package}\n') + # result = subprocess.check_call( + # f'{sys.executable} -mpip install {self._lazymodule_package}'.split()) + # except: # noqa + # pass + # try: + # return timed_import_module(self._lazymodule_name) + # except (ValueError, AssertionError, ModuleNotFoundError): + # if self._lazymodule_pip and self._lazymodule_pip != 'nouser': + # _skip_global_install = True + # sys.stderr.write(f'PIPIMPORT --user {self._lazymodule_package}\n') + # try: + # result = subprocess.check_call( + # f'{sys.executable} -mpip install --user {self._lazymodule_package}'.split()) + # sys.stderr.write(str(result)) + # except: # noqa + # pass + # return timed_import_module(self._lazymodule_name) diff --git a/lib/evn/evn/_prelude/make_decorator.py b/lib/evn/evn/_prelude/make_decorator.py new file mode 100644 index 00000000..16199380 --- /dev/null +++ b/lib/evn/evn/_prelude/make_decorator.py @@ -0,0 +1,175 @@ +""" +`make_decorator` is a utility for creating configurable decorators for functions, methods, +and classes. It wraps a user-defined function with consistent behavior, supports default +configuration, and allows per-use customization. + +It simplifies writing decorators that need to handle: +- Plain functions +- Instance methods and class methods +- Entire classes (auto-wrapping all suitable methods) + +Example: + >>> @make_decorator(prefix='* ') + ... def printer(func, *args, prefix='', **kwargs): + ... print(prefix + func.__name__) + ... return func(*args, **kwargs) + + >>> @printer + ... def hello(): + ... return 'hi' + + >>> hello() + * hello + 'hi' + +Basic usage: + + >>> @make_decorator + ... def trace(func, *args, **kwargs): + ... print('Calling', func.__name__) + ... return func(*args, **kwargs) + + >>> @trace + ... def greet(): + ... return 'hello' + + >>> greet() + Calling greet + 'hello' + +Using default config: + + >>> @make_decorator(msg='start:') + ... def tagged(func, *args, msg, **kwargs): + ... return msg + func(*args, **kwargs) + + >>> @tagged + ... def foo(): + ... return 'bar' + + >>> foo() + 'start:bar' + +Overriding decorator config: + + >>> @tagged(msg='>>') + ... def baz(): + ... return 'boo' + + >>> baz() + '>>boo' + +Decorating a method: + + >>> @make_decorator(extra=10) + ... def add_extra(func, *args, extra, **kwargs): + ... return func(*args, **kwargs) + extra + + >>> class Math: + ... @add_extra(extra=5) + ... def add(self, x, y): + ... return x + y + + >>> Math().add(1, 2) + 8 + +Decorating a class: + + >>> @add_extra(extra=2) + ... class Ops: + ... def mul(self, x, y): + ... return x * y + ... + ... def sub(self, x, y): + ... return x - y + + >>> o = Ops() + >>> o.mul(3, 4) + 14 + >>> o.sub(5, 3) + 4 +""" + +import inspect +import functools +import wrapt +import typing as t + +def make_decorator(userwrap: t.Callable | None = None, strict=True, **decokw): + """ + A decorator factory that simplifies writing configurable function, method, and class decorators. + + `make_decorator` turns a user-supplied wrapper function into a fully-featured decorator. + It can be used directly to decorate functions, methods, or entire classes, and it supports + configurable arguments at decoration time. + + Parameters: + userwrap (callable, optional): A function of the form + `userwrap(func, args, kwargs, **config)` that defines the core behavior of the decorator. + If None, `make_decorator` returns a partially-applied decorator factory. + strict (bool): If True, raises an error when unknown decorator kwargs are passed. + **decokw: Default keyword arguments for configuration. + + Returns: + A decorator that can be applied to functions, methods, or classes. + + Usage: + The wrapped `userwrap` receives: + - `func`: the original function being decorated + - `args`, `kwargs`: arguments with which the function is called + - `**config`: all decorator configuration options passed at creation time + + Raises: + TypeError: If `userwrap` is not callable or if strict mode is on and unexpected + keyword arguments are passed to the decorator. + """ + + if userwrap is None: + return functools.partial(make_decorator, **decokw) + + if not callable(userwrap): + raise TypeError(f'make_decorator first arg {type(userwrap)} is not callable') + + def decorator(userwrapped=None, *, strict=strict, decokwie=decokw, **decokw2) -> t.Callable: + if userwrapped is None: + if strict and not decokw2.keys() <= decokw.keys(): + raise TypeError( + f"Decorator {userwrap.__name__} doesn't accept args: {decokw2.keys() - decokw.keys()}") + return functools.partial(decorator, **(decokw | decokw2)) + + all_kwargs = decokw | decokw2 + + if inspect.isclass(userwrapped): + # Handle class wrapping directly, no wrapt.decorator + cls = userwrapped + for name, attr in vars(cls).items(): + if name.startswith('__'): + continue + if isinstance(attr, staticmethod): + func = attr.__func__ + wrapped_func = decorator(func, **all_kwargs) + setattr(cls, name, staticmethod(wrapped_func)) + elif isinstance(attr, classmethod): + func = attr.__func__ + wrapped_func = decorator(func, **all_kwargs) + setattr(cls, name, classmethod(wrapped_func)) + elif callable(attr) and not inspect.isclass(attr): + wrapped_func = decorator(attr, **all_kwargs) + setattr(cls, name, wrapped_func) + return cls + + # Otherwise, this is a normal function or method + # the default args are for the benefit of the type checker; never used + @wrapt.decorator() + def wrapper( + wrapped: t.Callable, + instance=None, + args=[], + kwargs={}, + ) -> t.Callable: + kwargs = {k: v for k, v in kwargs.items() if k not in all_kwargs} + return userwrap(wrapped, *args, **kwargs, **all_kwargs) + + return wrapper(userwrapped) + + return decorator diff --git a/lib/evn/evn/_prelude/structs.py b/lib/evn/evn/_prelude/structs.py new file mode 100644 index 00000000..65be86b4 --- /dev/null +++ b/lib/evn/evn/_prelude/structs.py @@ -0,0 +1,21 @@ +import sys +import dataclasses as dc + +asdict = dc.asdict +# from typing import final +final = lambda x: x + +if sys.version_info.minor > 9: + struct = lambda cls: final(dc.dataclass(slots=True)(cls)) + basestruct = dc.dataclass(slots=True) +else: + struct = lambda cls: final(dc.dataclass()(cls)) + basestruct = dc.dataclass() + +mutablestruct = lambda cls: final(dc.dataclass()(cls)) +basemutablestruct = dc.dataclass() + +def field(dfac=dc.MISSING, *a, **kw): + if dfac and 'default_factory' in kw: + raise TypeError('default_factory specified twice (as arg0 dfac)') + return dc.field(*a, default_factory=dfac, **kw) diff --git a/lib/evn/evn/_prelude/typehints.py b/lib/evn/evn/_prelude/typehints.py new file mode 100644 index 00000000..61f2911c --- /dev/null +++ b/lib/evn/evn/_prelude/typehints.py @@ -0,0 +1,38 @@ +import sys +from typing import cast as cast + +if sys.version_info.minor >= 10: + import typing as t + # from typing import t.ParamSpec +else: + import typing_extensions as t + # from typing_extensions import t.ParamSpec + +KW = dict[str, t.Any] +IOBytes = t.IO[bytes] +IO = t.IO[str] +"""Type alias for keyword arguments represented as a dictionary with string keys and any type of value.""" + +FieldSpec = t.Union[str, list[str], tuple[str], t.Callable[..., str], tuple] +EnumerIter = t.Iterator[int] +EnumerListIter = t.Iterator[list[t.Any]] + +T = t.TypeVar('T') +R = t.TypeVar('R') +C = t.TypeVar('C') +if sys.version_info.minor >= 10 or t.TYPE_CHECKING: + P = t.ParamSpec('P') + F = t.Callable[P, R] +else: + P = t.TypeVar('P') + P.args = list[t.Any] + P.kwargs = KW + F = t.Callable[[t.Any, ...], R] + + +def basic_typevars(which) -> list[t.Union[t.TypeVar, t.ParamSpec]]: + result = [globals()[k] for k in which] + return result + +def annotype(typ:type, info) -> t.Annotated: + return t.Annotated[typ, info] diff --git a/lib/evn/evn/_prelude/version.py b/lib/evn/evn/_prelude/version.py new file mode 100644 index 00000000..f0788a87 --- /dev/null +++ b/lib/evn/evn/_prelude/version.py @@ -0,0 +1 @@ +__version__ = '0.7.1' diff --git a/lib/evn/evn/cli/__init__.py b/lib/evn/evn/cli/__init__.py new file mode 100644 index 00000000..cb5aa4b5 --- /dev/null +++ b/lib/evn/evn/cli/__init__.py @@ -0,0 +1,48 @@ +""" +evn.cli + +The `evn.cli` package provides a declarative framework for building composable +command-line interfaces using Python classes and Click. + +Key Features: +- Class-based CLI declaration via the `CliMeta` metaclass +- Automatic command registration using method signatures and type annotations +- Support for custom and built-in Click param types via type handlers +- Centralized logging and CLI registry for diagnostics and testing +- Tools for resolving and traversing CLI hierarchies + +Modules: +- `cli_metaclass`: Auto-registers CLI classes and commands +- `auto_click_decorator`: Generates Click decorators from method signatures +- `click_type_handler`: Maps type hints to Click param types +- `basic_click_type_handlers`: Default type handlers (e.g. str, int, Path) +- `cli_logger`: Thread-safe structured logger for CLI execution +- `cli_registry`: Global registry of CLI classes +- `cli_command_resolver`: Introspects and resolves CLI command trees + +Usage Example: + +>>> from evn.cli import CLI +>>> class Hello(CLI): +... def greet(self, name: str = 'world'): +... print(f'Hello, {name}') +>>> cli = Hello.__group__ +>>> from click.testing import CliRunner +>>> result = CliRunner().invoke(cli, ['greet', '--name', 'Alice']) +>>> result.output +'Hello, Alice\\n' + +See Also: +- Click documentation: https://click.palletsprojects.com/ +- test_* modules for validation and examples +""" + +from evn.cli.auto_click_decorator import * +from evn.cli.basic_click_type_handlers import * +from evn.cli.cli_command_resolver import * +from evn.cli.cli_config import * +from evn.cli.cli_logger import * +from evn.cli.cli_metaclass import * +from evn.cli.cli_registry import * +from evn.cli.click_type_handler import * +from evn.cli.click_util import * diff --git a/lib/evn/evn/cli/auto_click_decorator.py b/lib/evn/evn/cli/auto_click_decorator.py new file mode 100644 index 00000000..8bea16d4 --- /dev/null +++ b/lib/evn/evn/cli/auto_click_decorator.py @@ -0,0 +1,175 @@ +""" +auto_click_decorator.py + +This module provides `auto_click_decorate_command()`, which inspects a function's signature +and applies Click decorators for each parameter based on type annotations and defaults. The decorators applied will be either click.option or click.argument, depending on whether the parameter is required or optional. + +It does **not** apply @click.command โ€” this must be added separately by the caller. + +Features: +- Required arguments become `click.argument(...)` +- Optional arguments become `click.option(...)` +- Boolean values become flags (with `is_flag=True`) +- `typing.Annotated` metadata is supported +- Type resolution is handled via `ClickTypeHandlers` + +Raises: +- RuntimeError if passed a function already decorated with `@click.command` +- ValueError if the parameter type cannot be resolved + +Example (doctestable): + +>>> import click +>>> from evn.cli.auto_click_decorator import auto_click_decorate_command +>>> from evn.cli.click_type_handler import ClickTypeHandlers +>>> from click.testing import CliRunner + +>>> def greet(name: str = 'world'): +... print(f'Hello {name}!') + +>>> click_params_greet = auto_click_decorate_command(greet, ClickTypeHandlers()) +>>> click_command_greet = click.command(click_params_greet) +>>> result = CliRunner().invoke(click_command_greet, ['--name', 'Alice']) +>>> assert 'Hello Alice!' in result.output + +See Also: +- click.argument +- click.option +- evn.cli.click_type_handler.ClickTypeHandlers +- test_auto_click_decorator.py +""" + +import contextlib +import inspect +import click +import typing +from functools import wraps + +from evn.doc.docstring import extract_param_help +from evn.cli.click_type_handler import ClickTypeHandlers, HandlerNotFoundError + + +def make_hashable(stuff): + return tuple( + make_hashable(item) if isinstance(item, list) else item + for item in stuff) + + +def _extract_annotation(annotation): + """ + If the annotation is a typing.Annotated, split it into base type and metadata. + Otherwise, return the annotation and None. + """ + if typing.get_origin(annotation) is typing.Annotated: + if args := typing.get_args(annotation): + return args[0], make_hashable(args[1:]) + return annotation, None + + +def _generate_click_decorator(name, param, + type_handlers: list[ClickTypeHandlers], + help: str): + """ + Given a parameter (an inspect.Parameter object) and a list of type handlers, + generate a Click decorator (click.argument for required parameters, click.option for optional ones) + for that parameter. + """ + basetype, metadata = _extract_annotation(param.annotation) + # Determine the Click ParamType via our type handlers. + param_type = None + for handlers in type_handlers: + with contextlib.suppress(HandlerNotFoundError): + param_type = handlers.typehint_to_click_paramtype( + basetype, metadata) + break + has_default = param.default != inspect.Parameter.empty + # For booleans, always use option with is_flag=True. + if basetype is bool and not metadata: + deco = click.option(f"--{name.replace('_', '-')}", + is_flag=True, + default=param.default) + elif not has_default and param_type: + deco = click.argument(name, type=param_type) + elif not has_default: + deco = click.argument(name) + else: + option_names = [f"--{name.replace('_', '-')}"] + kwargs = {'default': param.default, 'show_default': True} + if param_type: + kwargs['type'] = param_type + if basetype is bool: + kwargs['is_flag'] = True + deco = click.option(*option_names, help=help, **kwargs) + # ic(name, type(deco)) + deco + return deco + + +def auto_click_decorate_command(fn, type_handlers: list[ClickTypeHandlers]): + """ + Auto-decorate a function with Click parameter decorators based on its signature. + + This function inspects the signature of fn and automatically generates decorators + (click.argument for required parameters, click.option for parameters with defaults) + for every public parameter (ignoring those whose names start with '_') that is not already + manually decorated. + + Additionally, for parameters whose names start with "_" (internal parameters) that lack a default, + a wrapper is added to supply None when they are missing from the CLI input. + + Raises a RuntimeError if fn is already a full Click command (i.e. if isinstance(fn, click.Command)), + ensuring that our framework controls command creation. + """ + if isinstance(fn, click.Command): + raise RuntimeError( + 'Function is already a full Click command; manual @click.command decorators are not allowed.' + ) + sig = inspect.signature(fn) + # Collect names of parameters that already have manual Click decoration. + manual_params = set() + if hasattr(fn, '__click_params__'): + for p in fn.__click_params__: + if hasattr(p, 'name') and p.name: + manual_params.add(p.name) + + decorators = [] + params = list(sig.parameters.items()) + if params and params[0][0] in ('self', 'cls'): + params = params[1:] # Skip "self" + + arghelp = extract_param_help(fn.__doc__ or '') + for name, param in params: + # Check if the parameter has a docstring help description. + if name.startswith('_'): + continue # We'll handle internals separately. + if name in manual_params: + continue + decorator = _generate_click_decorator(name, + param, + type_handlers, + help=arghelp.get(name)) + decorators.append(decorator) + + # Apply the parameter decorators. + for decorator in reversed(decorators): + fn = decorator(fn) + + # Wrap the function to supply None for any internal parameter missing a default. + orig_sig = inspect.signature(fn) + internal_params = [ + name for name, param in orig_sig.parameters.items() + if name.startswith('_') and param.default == inspect.Parameter.empty + ] + if internal_params: + original_fn = fn # capture the function before wrapping to avoid recursion + + @wraps(original_fn) + def wrapper(*args, **kwargs): + for name in internal_params: + if name not in kwargs: + kwargs[name] = None + return original_fn(*args, **kwargs) + + fn = wrapper + + return fn diff --git a/lib/evn/evn/cli/basic_click_type_handlers.py b/lib/evn/evn/cli/basic_click_type_handlers.py new file mode 100644 index 00000000..73c5189f --- /dev/null +++ b/lib/evn/evn/cli/basic_click_type_handlers.py @@ -0,0 +1,146 @@ +# basic_click_type_handlers.py +import pathlib +import click +import uuid +from evn.cli.click_type_handler import ClickTypeHandler, MetadataPolicy + + +class BasicStringHandler(ClickTypeHandler): + __supported_types__ = {str: MetadataPolicy.FORBID} + __priority_bonus__ = 0 + + def convert(self, value, param, ctx): + try: + preprocessed = self.preprocess_value(value) + # For string, we simply ensure it is a unicode string. + return self.postprocess_value(str(preprocessed)) + except Exception as e: + self.fail(f'BasicStringHandler conversion failed: {e}', param, ctx) + + +class BasicBoolHandler(ClickTypeHandler): + __supported_types__ = {bool: MetadataPolicy.FORBID} + __priority_bonus__ = 0 + + def convert(self, value, param, ctx): + val = self.preprocess_value(value) + if isinstance(val, bool): + result = val + elif isinstance(val, str): + lower = val.lower() + if lower in ['true', '1', 'yes', 'on', 't']: + result = True + elif lower in ['false', '0', 'no', 'off', 'f']: + result = False + else: + self.fail(f'BasicBoolHandler conversion failed for {value}', + param, ctx) + else: + self.fail( + f'BasicBoolHandler conversion got unsupported type: {type(val)}', + param, ctx) + return self.postprocess_value(result) + + +class BasicUUIDHandler(ClickTypeHandler): + __supported_types__ = {uuid.UUID: MetadataPolicy.FORBID} + __priority_bonus__ = 0 + + def convert(self, value, param, ctx): + try: + preprocessed = self.preprocess_value(value) + result = uuid.UUID(preprocessed) + return self.postprocess_value(result) + except Exception as e: + self.fail(f'BasicUUIDHandler conversion failed: {e}', param, ctx) + + +class BasicPathHandler(ClickTypeHandler): + __supported_types__ = {pathlib.Path: MetadataPolicy.FORBID} + __priority_bonus__ = 0 + + def convert(self, value, param, ctx): + try: + preprocessed = self.preprocess_value(value) + path_type = click.Path() + result = path_type.convert(preprocessed, param, ctx) + return self.postprocess_value(result) + except Exception as e: + self.fail(f'BasicPathHandler conversion failed: {e}', param, ctx) + + +class BasicChoiceHandler(ClickTypeHandler): + __supported_types__ = {click.Choice: MetadataPolicy.FORBID} + __priority_bonus__ = 0 + + def convert(self, value, param, ctx): + try: + preprocessed = self.preprocess_value(value) + # Assume that the handler instance has an attribute 'choices' + if not hasattr(self, 'choices'): + self.fail("BasicChoiceHandler missing 'choices' attribute", + param, ctx) + choice_type = click.Choice(self.choices, + case_sensitive=getattr( + self, 'case_sensitive', True)) + result = choice_type.convert(preprocessed, param, ctx) + return self.postprocess_value(result) + except Exception as e: + self.fail(f'BasicChoiceHandler conversion failed: {e}', param, ctx) + + +class BasicIntRangeHandler(ClickTypeHandler): + __supported_types__ = {click.IntRange: MetadataPolicy.FORBID} + __priority_bonus__ = 0 + + def convert(self, value, param, ctx): + try: + preprocessed = self.preprocess_value(value) + int_range = click.IntRange( + min=getattr(self, 'min', None), + max=getattr(self, 'max', None), + min_open=getattr(self, 'min_open', False), + max_open=getattr(self, 'max_open', False), + clamp=getattr(self, 'clamp', False), + ) + result = int_range.convert(preprocessed, param, ctx) + return self.postprocess_value(result) + except Exception as e: + self.fail(f'BasicIntRangeHandler conversion failed: {e}', param, + ctx) + + +class BasicFloatRangeHandler(ClickTypeHandler): + __supported_types__ = {click.FloatRange: MetadataPolicy.FORBID} + __priority_bonus__ = 0 + + def convert(self, value, param, ctx): + try: + preprocessed = self.preprocess_value(value) + float_range = click.FloatRange( + min=getattr(self, 'min', None), + max=getattr(self, 'max', None), + min_open=getattr(self, 'min_open', False), + max_open=getattr(self, 'max_open', False), + clamp=getattr(self, 'clamp', False), + ) + result = float_range.convert(preprocessed, param, ctx) + return self.postprocess_value(result) + except Exception as e: + self.fail(f'BasicFloatRangeHandler conversion failed: {e}', param, + ctx) + + +class BasicDateTimeHandler(ClickTypeHandler): + __supported_types__ = {click.DateTime: MetadataPolicy.FORBID} + __priority_bonus__ = 0 + + def convert(self, value, param, ctx): + try: + preprocessed = self.preprocess_value(value) + dt_type = click.DateTime(formats=getattr(self, 'formats', None)) + result = dt_type.convert(preprocessed, param, ctx) + return self.postprocess_value(result) + except Exception as e: + self.fail(f'BasicDateTimeHandler conversion failed: {e}', param, + ctx) diff --git a/lib/evn/evn/cli/cli_command_resolver.py b/lib/evn/evn/cli/cli_command_resolver.py new file mode 100644 index 00000000..65bb9076 --- /dev/null +++ b/lib/evn/evn/cli/cli_command_resolver.py @@ -0,0 +1,70 @@ +""" +cli_command_resolver.py + +This module provides utilities for inspecting and resolving Click commands within +the EVeN CLI metaclass system. It enables discovery of all registered commands, +resolution by dotted path, and traversal of CLI hierarchies. + +Functions: +- walk_commands(root): Recursively yield all (path, command) pairs from a CLI root. +- find_command(root, path): Resolve a command from a dotted path string. +- get_all_cli_paths(): List all command paths from all registered CLI classes. + +Example: + +>>> from evn.cli import CLI +>>> class Top(CLI): +... def greet(self): +... pass +>>> from evn.cli.cli_command_resolver import walk_commands +>>> commands = list(walk_commands(Top)) +>>> assert any(p == ' top greet' for p, _ in commands) + +See Also: +- test_cli_command_resolver.py +""" + +from typing import Iterator +from click import Command, Group +from evn.cli.cli_registry import CliRegistry +from evn.cli.cli_metaclass import CLI + + +def walk_commands(root: type[CLI], + seenit=None) -> Iterator[tuple[str, Command]]: + if seenit is None: + seenit = set() + if root in seenit: + return + seenit.add(root) + group = getattr(root, '__group__', None) + assert group is not None and isinstance(group, Group) + base_path = f'{root.get_command_path()}' + for name, cmd in group.commands.items(): + # Check if this command is itself a group associated with a registered CLI class + if isinstance(cmd, Group): + for cls in CliRegistry.all_cli_classes(): + if getattr(cls, '__group__', None) is cmd: + yield from walk_commands(cls, seenit) + break + else: + full_path = f'{base_path} {name}' + yield full_path, cmd + + +def find_command(root: type[CLI], path: str) -> Command: + parts = path.split('.') + current = root.__group__ + for part in parts: + if not isinstance(current, Group) or part not in current.commands: + raise KeyError(f'Command path not found: {path}') + current = current.commands[part] + return current + + +def get_all_cli_paths() -> list[str]: + paths = [] + seenit = set() + for cls in CliRegistry.all_cli_classes(): + paths.extend([p for p, _ in walk_commands(cls, seenit)]) + return paths diff --git a/lib/evn/evn/cli/cli_config.py b/lib/evn/evn/cli/cli_config.py new file mode 100644 index 00000000..8bf5816c --- /dev/null +++ b/lib/evn/evn/cli/cli_config.py @@ -0,0 +1,70 @@ +import enum +import evn +from evn.decon.bunch import Bunch, bunchify + + +class Action(enum.Enum): + CONVERT = 443_520 + SET_DEFAULT = 10_200_960 + GET_DEFAULT = 244_823_040 + CUSTOM = 95_040 + + +def get_config_from_app_defaults(app): + """ + Generate a default config structure from a CLI class, including subcommands. + """ + config = Bunch(_split=' ') + return config_app_sync(app, config, Action.GET_DEFAULT) + + +def convert_config_to_app_types(app, config) -> Bunch: + return config_app_sync(app, config, Action.CONVERT) + + +def set_app_defaults_from_config(app, config) -> Bunch: + return config_app_sync(app, config, Action.SET_DEFAULT) + + +def mutate_config(app, config, action_func) -> Bunch: + config2 = config.copy() + return config_app_sync(app, + config2, + Action.CUSTOM, + action_func=action_func) + + +def config_app_sync(app, config, action: Action, action_func=None) -> Bunch: + assert config._conf('split') == ' ', "Config split is not ' '" + + def visit(group, path, action_func=action_func): + group_items = list(group.commands.items()) + if group.callback: + group_items.append(('_callback', group)) + for name, method in group_items: + name = name.replace('-', '_') + conf = config._get_split(f'{path} {name}', + create=Action.GET_DEFAULT == action) + for param in method.params: + assert action == Action.GET_DEFAULT or param.name in conf + if action is Action.CONVERT: + conf[param.name] = param.type.convert( + conf[param.name], param, None) + elif action is Action.SET_DEFAULT: + if param.default != conf[param.name]: + param.default = conf[param.name] + param.default = conf[param.name] + elif action is Action.GET_DEFAULT: + conf[param.name] = param.default + elif action is Action.CUSTOM and action_func: + action_func(conf, name, group, path, param) + else: + raise ValueError( + f'Unknown action {action} with func {action_func}') + + evn.cli.walk_click_group(app.__group__, visitor=visit) + return bunchify(config, _like=config) + + +# def get_config_from_app_defaults(app, config): +# config_app_sync(app, config, Action.SET_DEFAULT) diff --git a/lib/evn/evn/cli/cli_logger.py b/lib/evn/evn/cli/cli_logger.py new file mode 100644 index 00000000..07a5efec --- /dev/null +++ b/lib/evn/evn/cli/cli_logger.py @@ -0,0 +1,117 @@ +""" +cli_logger.py + +A centralized logging system for CLI classes using `CliMeta`. + +This logger stores structured log events keyed by a resolved CLI path, +and is used for debugging, analytics, and tracing command usage. + +Features: +- Supports both CLI classes and instances (via get_full_path()) +- Thread-safe logging and retrieval +- Context manager for logging begin/end event pairs + +Each log entry is a dictionary with: +- timestamp: ISO UTC string +- path: resolved CLI path +- event: string label for the log event +- message: string or custom dictionary +- data: optional structured metadata + +Example: +>>> from evn.cli import CLI +>>> class CLIdummy(CLI): +... __log__ = [] +>>> CliLogger.clear(CLIdummy) +>>> CliLogger.log(CLIdummy, 'started', event='boot') +>>> log = CliLogger.get_log(CLIdummy) +>>> log[0]['event'] +'boot' +>>> log[0]['path'] +' dummy' + +See Also: +- test_cli_logger.py +""" + +from datetime import datetime, timezone +import threading + + +class CliLogger: + """ + Centralized logger for CLI classes. + Supports structured event logging and path resolution. + """ + + _logs = {} + _lock = threading.Lock() + + @classmethod + def log(cls, + target, + message: str, + *, + event: str = None, + data: dict = None): + path = target.get_command_path() + if isinstance(message, dict): + assert event is None and data is None + entry = message + else: + entry = { + 'timestamp': datetime.now(timezone.utc).isoformat(), + 'path': path, + 'event': event or 'log', + 'message': message, + 'data': data or {}, + } + with cls._lock: + cls._logs.setdefault(path, []).append(entry) + target.__log__.append(message) + + @classmethod + def get_log(cls, target) -> list[dict]: + path = target.get_command_path() + return cls._logs.get(path, []) + + @classmethod + def clear(cls, target): + path = target.get_command_path() + with cls._lock: + cls._logs[path] = [] + + # @classmethod + # def _resolve_path(cls, target): + # # if it's an instance with get_full_path, use that + # if hasattr(target, "get_full_path") and not isinstance(target, type): + # return target.get_full_path() + # # if it's a class, use its __name__ + # if hasattr(target, "__name__"): + # return target.__name__ + # return str(target) + + @classmethod + def print_log(cls, target): + for entry in cls.get_log(target): + print(entry) + + @classmethod + def log_event_context(cls, target, event: str, data: dict = None): + """Context manager for logging entry/exit of an event.""" + + class _LogCtx: + + def __enter__(self_): + cls.log(target, f'begin {event}', event=event, data=data) + return self_ + + def __exit__(self_, *exc): + cls.log(target, f'end {event}', event=event, data=data) + + return _LogCtx() + + +# Usage: +# CliLogger.log(self, "message", event="something_happened", data={...}) +# CliLogger.get_log(MyCLIClass) diff --git a/lib/evn/evn/cli/cli_metaclass.py b/lib/evn/evn/cli/cli_metaclass.py new file mode 100644 index 00000000..a29a06f2 --- /dev/null +++ b/lib/evn/evn/cli/cli_metaclass.py @@ -0,0 +1,245 @@ +""" +cli_metaclass.py + +This module defines the `CliMeta` metaclass, which automatically turns classes into Click-based CLI command groups. +It forms the backbone of the EVeN CLI system, enabling command registration via inheritance and minimal boilerplate. + +The key behaviors of this metaclass include: + +- Creating a `click.Group` for each CLI class. +- Registering the group with a parent group (if inherited). +- Registering each public method as a Click command via `auto_click_decorate_command`. +- Injecting a structured logging method using `CliLogger`. +- Composing type handlers from `ClickTypeHandlers`. + +Each CLI class using `CliMeta` gains the following attributes: +- `__group__`: a `click.Group` containing its registered commands. +- `__parent__`: a reference to its parent CLI class (if any). +- `__type_handlers__`: merged parameter conversion handlers. +- `_log`: a callable logger that delegates to `CliLogger`. + +Usage Example: + + >>> class ProjectCLI(CLI): + ... def create(self, name: str): + ... print(f'Creating project {name}') + >>> from click.testing import CliRunner + >>> cli = ProjectCLI.__group__ + >>> runner = CliRunner() + >>> result = runner.invoke(cli, ['create', 'demo']) + >>> assert 'Creating project demo' in result.output + +Dependencies: +- click +- evn.cli.auto_click_decorator +- evn.cli.cli_logger +- evn.cli.cli_registry +- evn.cli.click_type_handler + +See Also: +- test_cli_metaclass.py โ€” for validated examples and coverage. +""" + +import click +import typing +import functools +from evn.cli.auto_click_decorator import auto_click_decorate_command +from evn.cli.cli_registry import CliRegistry +from evn.cli.cli_logger import CliLogger +from evn.cli.click_type_handler import ClickTypeHandler, ClickTypeHandlers + +def cls_to_groupname(cls): + """ + Converts a class name to a Click group name. + This is typically the same as the class name but can be customized if needed. + + Args: + cls (type): The class to convert. + + Returns: + str: The name to be used for the Click group. + """ + if cls.__name__ == 'CLI': + return '' + return cls.__name__.lower().removeprefix('cli').removesuffix('cli') + + +class AliasedGroup(click.Group): + + def get_command(self, ctx, cmd_name): + rv = click.Group.get_command(self, ctx, cmd_name) + if rv is not None: + return rv + matches = [ + x for x in self.list_commands(ctx) if x.startswith(cmd_name) + ] + if not matches: + return None + elif len(matches) == 1: + return click.Group.get_command(self, ctx, matches[0]) + ctx.fail(f"Too many matches: {', '.join(sorted(matches))}") + + def resolve_command(self, ctx, args): + # always return the full command name + _, cmd, args = super().resolve_command(ctx, args) + return cmd.name, cmd, args + + +class CliMeta(type): + # there are NOT ACTUALLY USED just to make the type checker happy + __group__: click.Group + __parent__: 'CLI | None' + __type_handlers__: 'ClickTypeHandlers | list[ClickTypeHandler]' + __all_type_handlers__: list[ClickTypeHandlers] + _config: classmethod + _log: typing.Callable[['dict|str'], None] + + def __new__(mcls, name, bases, namespace): + cls = super().__new__(mcls, name, bases, namespace) + + @classmethod + def log(cls, *a, **kw): + CliLogger.log(cls, *a, **kw) + + cls._log, cls.__log__ = log, [] + cls.__all_type_handlers__ = [] + if hasattr(cls, '__type_handlers__'): + cls.__type_handlers__ = ClickTypeHandlers(cls.__type_handlers__) + else: + cls.__type_handlers__ = [] + for base in cls.__mro__[:-1]: + handlers = ClickTypeHandlers(getattr(base, '__type_handlers__', [])) + if handlers: cls.__all_type_handlers__.append(handlers) + + if callback := cls.__dict__.get('_callback', None): + callback = cls_to_instance_method(cls)(callback) + decorated = auto_click_decorate_command(callback, + cls.__all_type_handlers__) + # print('<<< instantiating partilly created class, may be sketchy >>>') + cls.__group__ = click.command(cls=AliasedGroup, + name=cls_to_groupname(cls), + help=callback.__doc__)(decorated) + else: + cls.__group__ = AliasedGroup(name=cls_to_groupname(cls), + help=cls.__doc__) + CliLogger.log(cls, + f'Registered group: {cls.__group__.name}', + event='group_registered') + + cls.__parent__ = None + for base in cls.__bases__: + if base is object: + break # skip the base object class + assert hasattr(base, '__group__') + assert not cls.__parent__ + cls.__parent__ = base + base.__group__.add_command(cls.__group__, name=cls.__group__.name) + + for name in cls.__dict__: + if cls.__name__ == 'CLI': + break + if name.startswith('_'): + continue + method = getattr(cls, name) + if not callable(method): + continue + decorated = auto_click_decorate_command(method, + cls.__all_type_handlers__) + cls.__group__.command(help=method.__doc__)( + cls_to_instance_method(cls)(decorated)) + + CliRegistry.register(cls) + + return cls + + def __call__(cls, *a, **kw): + if '_instance' in cls.__dict__: + return cls.__dict__['_instance'] + instance = super().__call__(*a, **kw) + cls._instance = instance + CliLogger.log(instance, 'Instance created', event='instance_created') + return instance + + +class CLI(metaclass=CliMeta): + __parent__ = None + + @classmethod + def get_full_path(cls): + if parent := cls.__parent__: + return f'{parent.get_full_path()} {cls.__name__}' + return cls.__name__ + + @classmethod + def get_command_path(cls): + path = cls_to_groupname(cls) + if parent := cls.__parent__: + path = f'{parent.get_command_path()} {path}' + return path + + @classmethod + def _testroot(cls, cmd): + return click.testing.CliRunner().invoke(cls._root().__group__, cmd) + + @classmethod + def _test(cls, cmd): + return click.testing.CliRunner().invoke(cls.__group__, cmd) + + @classmethod + def _run(cls): + cls._root().__group__() + + @classmethod + def _run_this(cls): + cls.__group__() + + @classmethod + def _root(cls): + while cls.__parent__: + cls = cls.__parent__ + return cls + + @classmethod + def _walk_click(cls, visitor=lambda *a: None): + return walk_click_group(cls.__group__, visitor) + + +def walk_click_group(group: click.Group = CLI.__group__, + visitor=lambda *a: None): + """ + Recursively prints all command names in a Click group as a comma-separated list. + + Args: + group (click.Group): The root Click group to inspect. + prefix (str): Internal use for recursive path tracking. + """ + commands = [] + + def walk(g: click.Group, path: str = ''): + path = path or group.name + visitor(g, path) + for name, cmd in g.commands.items(): + full_path = f'{path} {name}' if path else name + if isinstance(cmd, click.Group): + walk(cmd, full_path) + else: + commands.append(full_path) + + walk(group) + return commands + + +def cls_to_instance_method(cls): + """Assumes cls is a singleton, or stateless, or else user knows what they is doing...""" + + def deco(func): + + @functools.wraps(func) + def wrap(*a, **kw): + instance = cls() + # print(cls, func) + return func(instance, *a, **kw) + + return wrap + + return deco diff --git a/lib/evn/evn/cli/cli_registry.py b/lib/evn/evn/cli/cli_registry.py new file mode 100644 index 00000000..f503e47b --- /dev/null +++ b/lib/evn/evn/cli/cli_registry.py @@ -0,0 +1,76 @@ +""" +cli_registry.py + +This module provides `CliRegistry`, a global registry of all CLI classes that use +the `CliMeta` system. + +The registry tracks CLI class registration, provides discovery tools, and includes +reset and diagnostic methods useful for testing and CLI orchestration. + +Features: +- Deduplicated class tracking +- Reset of singleton/log/config state +- Root CLI group discovery +- Registry summary printing + +See Also: +- cli_metaclass.py +- cli_command_resolver.py +- test_cli_registry.py +""" + +from typing import Type, List, Dict +import click +import evn + + +class CliRegistry: + """ + Global registry to track all CLI classes that use CliMeta. + Used for diagnostics, testing, and reset functionality. + """ + + _cli_classes: List[Type] = [] + + @classmethod + def register(cls, cli_class: Type) -> None: + if cli_class not in cls._cli_classes: + cls._cli_classes.append(cli_class) + else: + raise ValueError( + f'CLI class {cli_class.__name__} is already registered.') + + @classmethod + def all_cli_classes(cls) -> List[Type]: + return list(cls._cli_classes) + + @classmethod + def get_root_commands(cls) -> Dict[str, click.Group]: + roots = { + c.__group__.name: c.__group__ + for c in cls._cli_classes if getattr(c, '__parent__') == evn.CLI + } + return roots + + @classmethod + def reset(cls) -> None: + for c in cls._cli_classes: + if hasattr(c, '_instance'): + del c._instance + if hasattr(c, '__log__'): + c.__log__.clear() + + @classmethod + def print_summary(cls) -> None: + print('\n๐Ÿ“ฆ CLI Registry Summary:') + for c in cls._cli_classes: + print(f'- {c.__name__}') + if hasattr(c, '__group__'): + print(f' โ””โ”€โ”€ group: {c.__group__.name}') + if hasattr(c, '__parent__') and c.__parent__: + print(f' โ””โ”€โ”€ parent: {c.__parent__.__name__}') + if hasattr(c, '__type_handlers__'): + print( + f' โ””โ”€โ”€ handlers: {[h.__class__.__name__ for h in c.__type_handlers__]}' + ) + print() diff --git a/lib/evn/evn/cli/click_type_handler.py b/lib/evn/evn/cli/click_type_handler.py new file mode 100644 index 00000000..c7185732 --- /dev/null +++ b/lib/evn/evn/cli/click_type_handler.py @@ -0,0 +1,240 @@ +""" +click_type_handler.py + +Defines the `ClickTypeHandler` interface and the `ClickTypeHandlers` registry, which together +allow parameter types in the CLI framework to be inferred from Python type hints and optional +metadata. + +Each handler subclass (e.g. BasicIntHandler, BasicBoolHandler) provides conversion logic +for a specific base type, and supports preprocessing, postprocessing, and failure reporting +via Click's `BadParameter`. + +Key Classes: +- ClickTypeHandler: abstract base class for param type handlers +- ClickTypeHandlers: a prioritized set of handlers for resolving annotated Python types +- MetadataPolicy: enum to control whether metadata is required, forbidden, or optional + +Features: +- Type resolution via `typehint_to_click_paramtype(basetype, metadata)` +- Automatic fallback if multiple handlers match +- Metadata-aware filtering and priority-based resolution +- Memoization via `get_cached_paramtype()` (for performance) + +Example (doctestable): + +>>> from evn.cli.click_type_handler import ClickTypeHandlers, ClickTypeHandler +>>> class DummyHandler(ClickTypeHandler): +... __supported_types__ = {int: 'optional_metadata'} +... +... def convert(self, value, param, ctx): +... return int(value) + +>>> handlers = ClickTypeHandlers() +>>> handlers.add(DummyHandler()) +>>> param_type = handlers.typehint_to_click_paramtype(int, metadata=None) +>>> assert param_type.convert('42', None, None) == 42 + +See Also: +- evn.cli.basic_click_type_handlers +- evn.cli.auto_click_decorator +- test_click_type_handler.py +""" + +import inspect +import uuid +import contextlib +import enum +from functools import lru_cache +import click + +class MetadataPolicy(str, enum.Enum): + FORBID = 'no_metadata' + OPTIONAL = 'optional_metadata' + REQUIRED = 'require_metadata' + +class HandlerNotFoundError(RuntimeError): + pass + +class ClickTypeHandlers(set): + """ + A container for Click type handlers. + + This class is used to manage a list of type handlers. + It can be used to retrieve the appropriate handler for a given type. + """ + + # @classmethod + # def __new__(cls, val=(), *a, **kw): + # if isinstance(cls, ClickTypeHandlers): + # return val + # return super().__new__(val) + + def __init__(self, *args): + super().__init__() + if len(args) == 1 and isinstance(args[0], (list, tuple)): + args = args[0] + for arg in args: + if isinstance(arg, ClickTypeHandlers): + self.update(arg) + elif issubclass(arg, ClickTypeHandler): + self.add(arg) + else: + raise TypeError( + f"Expected ClickTypeHandler cls or ClickTypeHandlers instance, got {type(arg)}\n{arg}") + + def ordered_handlers(self, basetype, metadata): + [h for h in self if h.metadata_policy(basetype)] + return list(sorted(self, key=lambda x: x.priority(), reverse=True)) + + def typehint_to_click_paramtype(self, basetype, metadata) -> click.ParamType: + """Given a basetype and optional metadata, return the Click ParamType to use.""" + handlers = self.ordered_handlers(basetype, metadata) + if metadata: + for handler_class in handlers: + # ic(handler_class.metadata_policy) + if handler_class.metadata_policy(basetype) == MetadataPolicy.REQUIRED: + with contextlib.suppress(HandlerNotFoundError): + return get_cached_paramtype(handler_class, basetype, metadata) + for handler_class in handlers: + if handler_class.metadata_policy(basetype) == MetadataPolicy.OPTIONAL: + with contextlib.suppress(HandlerNotFoundError): + return get_cached_paramtype(handler_class, basetype) + if not metadata: + for handler_class in handlers: + if handler_class.metadata_policy(basetype) == MetadataPolicy.FORBID: + with contextlib.suppress(HandlerNotFoundError): + return get_cached_paramtype(handler_class, basetype) + if not metadata and basetype in (int, float, str, bool, uuid.UUID): + return basetype + if not metadata and basetype == inspect._empty: + # Special case for empty annotations (e.g., no type hint). + return click.ParamType() + raise HandlerNotFoundError( + f'No suitable Click ParamType found for basetype {basetype} with metadata {metadata} using handlers: {handlers}' + ) + + def __repr__(self): + return f'ClickTypeHandlers({[h.__name__ for h in self]})' + +class ClickTypeHandler(click.ParamType): + """ + Base class for handling conversion of type hints to Click ParamTypes. + + Subclasses should define: + - __supported_types__: a dict mapping types (e.g., int, list[int]) to booleans. + The boolean is True if the handler requires metadata for that type. + - __priority_bonus__: an integer bonus (default 0) that subclasses can override. + - METADATA_BONUS: a fixed bonus (default 10) applied if metadata is used. + + This class provides default no-op implementations for preprocess_value and postprocess_value. + It also defines a method 'typehint_to_click_paramtype' (the conversion function) + and a priority computation function. + """ + + # Dictionary of types this handler applies to. + # Example: {int: False, float: False, list: True} + __supported_types__: dict[type, MetadataPolicy] = {} + __priority_bonus__ = 0 + METADATA_BONUS = 10 + + def __init__(self): + super().__init__() + + @classmethod + def metadata_policy(cls, basetype): + return cls.__supported_types__.get(basetype) + + @classmethod + def typehint_to_click_paramtype(cls, basetype, metadata): + """ + Given a type hint (basetype) and optional metadata, return the Click ParamType to use. + Default behavior is to return cls if this handler handles the type; otherwise, raises HandlerNotFoundError. + """ + if not cls.handles_type(basetype, metadata): + raise HandlerNotFoundError( + f'{cls.__class__.__name__} does not handle type {basetype} with metadata {metadata}') + return cls() + + @classmethod + def handles_type(cls, basetype, metadata=None): + """ + Check whether this handler applies to the given basetype. + Iterates over __supported_types__; if a key matches basetype, then if the boolean flag is True, + metadata must be provided (and non-empty) for a positive result. + """ + for typ, metapol in cls.__supported_types__.items(): + if issubclass(basetype, typ): + if metapol == MetadataPolicy.REQUIRED: + return bool(metadata) + if metapol == MetadataPolicy.FORBID: + return not metadata + return True + return False + + def preprocess_value(self, raw: str): + """ + Preprocess the raw input value before Click's conversion. + Default implementation is a aano-op. + """ + return raw + + def postprocess_value(self, value): + """ + Postprocess the value after Click's conversion. + Default implementation is a no-op. + """ + return value + + def convert(self, value, param, ctx): + """ + Override click.ParamType.convert to incorporate pre- and post-processing. + By default, this implementation simply returns the preprocessed value. + Subclasses should override this method if further conversion is needed. + """ + try: + preprocessed = self.preprocess_value(value) + # Default conversion: return the preprocessed value unchanged. + converted = preprocessed + postprocessed = self.postprocess_value(converted) + return postprocessed + except Exception as e: + self.fail(f'Conversion failed for value {value}: {e}', param, ctx) + + @classmethod + def priority(cls): + return cls.__priority_bonus__ + + # @classmethod + # def type_specificity(cls, basetype): + # """ + # Compute a basic measure of specificity for the basetype. + # # For generic types (with __args__), count the number of arguments that are not typing.Any. + # """ + # if hasattr(basetype, '__args__') and basetype.__args__: + # specificity = sum(1 for arg in basetype.__args__ if arg is not typing.Any) + # return specificity + # return 0 + + # def compute_priority(self, basetype, metadata, mro_rank: int): + # """ + # Compute the overall priority for this handler. + # Lower numeric values are better. + # Priority is computed as: + # mro_rank + __priority_bonus__ + (METADATA_BONUS if metadata is required and provided#) + type_specificity + # """ + # specificity = self.type_specificity(basetype) + # bonus = self.__priority_bonus__ + # if self.handles_type(basetype, metadata): + # for typ, metadata_policy in self.__supported_types__.items(): + # if basetype == typ and metadata_policy: + # bonus += self.METADATA_BONUS + # break + # return mro_rank + bonus + specificity + +# Caching function: cache the computed Click ParamType based on handler class, basetype, and metadata. +@lru_cache(maxsize=None) +def get_cached_paramtype(handler_class, basetype, metadata=None): + """ + Retrieve (or compute and cache) the Click ParamType for the given handler class, basetype, and metadata. + """ + return handler_class.typehint_to_click_paramtype(basetype, metadata) diff --git a/lib/evn/evn/cli/click_util.py b/lib/evn/evn/cli/click_util.py new file mode 100644 index 00000000..78535b1c --- /dev/null +++ b/lib/evn/evn/cli/click_util.py @@ -0,0 +1,48 @@ +import click +from evn import bunchify + + +def extract_command_info(cmd: click.Command): + """ + Extracts the underlying function (callback) and details of all parameters + (arguments and options) from a click.Command object. + + Returns: + A dictionary with: + - 'function': the callback function wrapped by the command. + - 'parameters': a list of dictionaries for each parameter containing: + - name: The parameter's name. + - type: A string representation of the parameter's type. + - help: Help text (if available, mostly for options). + - default: The default value. + - opts: For options, a list of option flags (e.g., ['--count']); absent for arguments. + """ + # Extract the callback function + command_func = cmd.callback + + # Prepare list to hold parameter details + params_info = [] + for param in cmd.params: + params_info.append(param.to_info_dict()) + continue + info = { + 'name': param.name, + 'type': str(param.type), + 'help': getattr(param, 'help', + None), # Only options typically have help text + 'default': param.default, + 'required': param.required, + } + # If it's an option, also record its flag names + if isinstance(param, click.Option): + info['opts'] = param.opts + elif isinstance(param, click.Argument): + print(dir(param)) + info['nargs'] = param.nargs + info['opts'] = param.opts + info['attrs'] = param.attrs + else: + raise TypeError(f'Unsupported parameter type: {type(param)}') + params_info.append(info) + + return bunchify({'function': command_func, 'parameters': params_info}) diff --git a/lib/evn/evn/config/__init__.py b/lib/evn/evn/config/__init__.py new file mode 100644 index 00000000..dff6f178 --- /dev/null +++ b/lib/evn/evn/config/__init__.py @@ -0,0 +1 @@ +from evn.config.confload import * diff --git a/lib/evn/evn/config/confload.py b/lib/evn/evn/config/confload.py new file mode 100644 index 00000000..6e7ad836 --- /dev/null +++ b/lib/evn/evn/config/confload.py @@ -0,0 +1,75 @@ +import socket +import tomli +from pathlib import Path +import os +import evn + +XDG_CONFIG_HOME = Path( + os.environ.get('XDG_CONFIG_HOME', + Path.home() / '.config')) + +CONFIG_PATHS = dict( + defaults=lambda: { + }, # built-in fallback (can point to static defaults later) + user_dev_config=lambda: load_toml_layer(XDG_CONFIG_HOME / 'dev' / + 'dev_config.toml'), + application_defaults=lambda: {}, + pyproject_toml=lambda: load_pyproject_toml(Path('pyproject.toml')), + user_local_dev_config=lambda: load_toml_layer( + XDG_CONFIG_HOME / 'dev' / 'local' / f'{socket.gethostname()}.toml'), + user_project_config=lambda: load_toml_layer( + Path('local/{getpass.getuser()}/userconfig.toml')), + environment_vars=lambda: load_env_layer('EVN_'), +) + + +def get_config_layers(app=None): + layers = {k: v() for k, v in CONFIG_PATHS.items()} + layers['application_defaults'] = load_app_layer(app) + return layers + + +def get_config(app=None, layers=None): + """ + Return merged configuration from all layers using ChainMap. + """ + appname = app.__group__.name if app else '' + layers = layers or get_config_layers(app) + config = evn.Bunch({appname: evn.Bunch(_split=' ')}, _split=' ') + for name, layer in layers.items(): + config[appname]._merge(layer.get(appname, {}), layer=name) + return config + + +def load_toml_layer(path: Path) -> dict: + if not path.exists(): + return evn.Bunch() + with open(path, 'rb') as f: + return evn.bunchify(tomli.load(f)) + + +def load_pyproject_toml(path: Path) -> dict: + if not path.exists(): + return evn.Bunch() + with open(path, 'rb') as f: + data = tomli.load(f) + return evn.bunchify(data.get('tool', {}).get('evn', {})) + + +def load_env_layer(prefix: str) -> dict: + result: dict[str, evn.Any] = {} + for key, val in os.environ.items(): + if key.startswith(prefix): + parts = key[len(prefix):].lower().split('__') + ref = result + for part in parts[:-1]: + ref = ref.setdefault(part, {}) + ref[parts[-1]] = val + # print('ENV', result) + return evn.bunchify(result) + + +def load_app_layer(app): + if not app: + return {} + return evn.cli.get_config_from_app_defaults(app) diff --git a/lib/evn/evn/decofunc/__init__.py b/lib/evn/evn/decofunc/__init__.py new file mode 100644 index 00000000..c706aa1b --- /dev/null +++ b/lib/evn/evn/decofunc/__init__.py @@ -0,0 +1,3 @@ +from evn.decofunc.cache import * +from evn.decofunc.iterize import * +from evn.decofunc.state_management import * diff --git a/lib/evn/evn/decofunc/cache.py b/lib/evn/evn/decofunc/cache.py new file mode 100644 index 00000000..7810e8d4 --- /dev/null +++ b/lib/evn/evn/decofunc/cache.py @@ -0,0 +1,56 @@ +""" +**Safe Caching**: :func:`safe_lru_cache` provides an LRU cache that handles unhashable arguments gracefully. + +""" + +import functools + + +def safe_lru_cache(func=None, *, maxsize=128): + """ + A safe LRU cache decorator that handles unhashable arguments gracefully. + + This decorator wraps a function with an LRU cache. If the arguments are hashable, the cached value + is returned; if unhashable (raising a TypeError), the function is executed normally without caching. + + :param func: The function to decorate. If omitted, the decorator can be used with arguments. + :param maxsize: The maximum size of the cache. Defaults to 128. + :return: The decorated function. + :rtype: callable + + Examples: + Basic usage: + >>> @safe_lru_cache(maxsize=32) + ... def double(x): + ... return x * 2 + >>> double(2) + 4 + >>> double([1, 2, 3]) # Unhashable input; executes without caching. + [1, 2, 3, 1, 2, 3] + + Using without arguments: + >>> @safe_lru_cache + ... def add(x, y): + ... return x + y + >>> add(2, 3) + 5 + """ + if func is not None and callable(func): + # Case when used as @safe_lru_cache without parentheses + return safe_lru_cache(maxsize=maxsize)(func) + + def decorator(func): + cache = functools.lru_cache(maxsize=maxsize)(func) + + @functools.wraps(func) + def wrapper(*args, **kwargs): + try: + hash(args) + frozenset(kwargs.items()) + return cache(*args, **kwargs) + except TypeError: + return func(*args, **kwargs) + + return wrapper + + return decorator diff --git a/lib/evn/evn/decofunc/deconfig.py b/lib/evn/evn/decofunc/deconfig.py new file mode 100644 index 00000000..088237b2 --- /dev/null +++ b/lib/evn/evn/decofunc/deconfig.py @@ -0,0 +1,176 @@ +import inspect +from functools import wraps +from typing import Optional, get_type_hints, Union + + +class Config: + """Hierarchical configuration system.""" + + def __init__(self, parent=None, **kwargs): + self.parent = parent + self.__dict__.update(kwargs) + + def get(self, key, default=None): + if key in self.__dict__: + return self.__dict__[key] + elif self.parent: + return self.parent.get(key, default) + return default + + def __getattr__(self, name): + if name in self.__dict__: + return self.__dict__[name] + if self.parent: + return getattr(self.parent, name) + raise AttributeError( + f"'{self.__class__.__name__}' has no attribute '{name}'") + + +# Global default configuration +default_config = Config(timeout=30, retries=3, log_level='INFO') + + +def with_config(config_param_name='config', default_config=default_config): + """ + Decorator that automatically applies configuration values to function parameters. + + Args: + config_param_name: The name of the parameter that will receive the config object + default_config: The default configuration to use if none is provided + """ + + def decorator(func): + # Get the signature of the function + sig = inspect.signature(func) + parameters = sig.parameters + + @wraps(func) + def wrapper(*args, **kwargs): + # Get the config object from kwargs or use default + config = kwargs.get(config_param_name, default_config) + + # Create a copy of kwargs to avoid modifying the original + new_kwargs = kwargs.copy() + + # Iterate through function parameters + for param_name, param in parameters.items(): + # Skip *args, **kwargs, and the config parameter itself + if (param.kind == param.VAR_POSITIONAL + or param.kind == param.VAR_KEYWORD + or param_name == config_param_name): + continue + + # Skip parameters that are already provided in args or kwargs + if param_name in kwargs: + continue + + # Skip parameters without default values (required params) + if param.default is param.empty: + continue + + # If parameter has a default value of None, try to get it from config + if param.default is None: + config_value = config.get(param_name) + if config_value is not None: + new_kwargs[param_name] = config_value + + return func(*args, **new_kwargs) + + return wrapper + + return decorator + + +# Example usage +@with_config(default_config=Config(timeout=10, retries=5, db_host='localhost')) +def fetch_data(url, *, timeout=None, retries=None, config=None): + """Fetch data from a URL with configurable parameters.""" + print(f'Fetching {url} with timeout={timeout}, retries={retries}') + # Implementation... + + +# More advanced version that considers type hints +def typed_with_config(config_param_name='config', + default_config=default_config): + """ + Version of with_config that respects type hints when applying configuration. + """ + + def decorator(func): + # Get the signature of the function + sig = inspect.signature(func) + parameters = sig.parameters + + # Get type hints + type_hints = get_type_hints(func) + + @wraps(func) + def wrapper(*args, **kwargs): + # Get the config object from kwargs or use default + config = kwargs.get(config_param_name, default_config) + + # Create a copy of kwargs to avoid modifying the original + new_kwargs = kwargs.copy() + + # Iterate through function parameters + for param_name, param in parameters.items(): + # Skip *args, **kwargs, and the config parameter itself + if (param.kind == param.VAR_POSITIONAL + or param.kind == param.VAR_KEYWORD + or param_name == config_param_name): + continue + + # Skip parameters that are already provided in args or kwargs + if param_name in kwargs: + continue + + # Skip parameters without default values (required params) + if param.default is param.empty: + continue + + # If parameter has a default value of None, try to get it from config + if param.default is None: + config_value = config.get(param_name) + if config_value is not None: + # If we have type hints, check if the config value matches + if param_name in type_hints: + expected_type = type_hints[param_name] + # Handle Optional types + if hasattr(expected_type, '__origin__' + ) and expected_type.__origin__ is Union: + if type(None) in expected_type.__args__: + valid_types = [ + t for t in expected_type.__args__ + if t is not type(None) + ] + if len(valid_types) == 1 and isinstance( + config_value, valid_types[0]): + new_kwargs[param_name] = config_value + elif len(valid_types) > 1 and any( + isinstance(config_value, t) + for t in valid_types): + new_kwargs[param_name] = config_value + # Simple type check + elif isinstance(config_value, expected_type): + new_kwargs[param_name] = config_value + else: + # No type hints, just use the value + new_kwargs[param_name] = config_value + + return func(*args, **new_kwargs) + + return wrapper + + return decorator + + +# Example with typed version +@typed_with_config() +def process_item(item_id: str, + *, + timeout: Optional[int] = None, + retries: Optional[int] = None, + config=None): + """Process an item with type-checked configuration parameters.""" + print(f'Processing {item_id} with timeout={timeout}, retries={retries}') + # Implementation... diff --git a/lib/evn/evn/decofunc/iterize.py b/lib/evn/evn/decofunc/iterize.py new file mode 100644 index 00000000..3fa0caaf --- /dev/null +++ b/lib/evn/evn/decofunc/iterize.py @@ -0,0 +1,224 @@ +""" +"Vectorizing" python functions +================================= + +Use :func:`iterize_on_first_param` to automatically vectorize a function over its first parameter. + +Ues :func:`is_iterizeable` to determine if an object should be treated as iterable for vectorization purposes. + +Example: + + >>> @iterize_on_first_param + ... def square(x): + ... return x * x + >>> square(4) + 16 + >>> square([1, 2, 3]) + [1, 4, 9] +""" + +import contextlib +import functools +from pathlib import Path +import evn + + +def NoneFunc(): + """This function does nothing and is used as a default placeholder.""" + pass + + +def is_iterizeable(arg, + basetype: type = str, + splitstr: bool = True, + allowmap: bool = False) -> bool: + """ + Determine if an object should be treated as iterable for vectorization purposes. + + This function checks several conditions: + - Strings with spaces are considered iterable if `splitstr` is True. + - Objects of the type specified by `basetype` are treated as scalars. + - Mapping types are not considered iterable unless `allowmap` is True. + + :param arg: The object to test. + :param basetype: A type (or tuple of types) that should be considered scalar. Defaults to str. + :param splitstr: If True, strings containing spaces are considered iterable. Defaults to True. + :param allowmap: If False, mapping types (e.g. dict) are not treated as iterable. Defaults to False. + :return: True if the object is considered iterable, False otherwise. + :rtype: bool + + Examples: + >>> is_iterizeable([1, 2, 3]) + True + >>> is_iterizeable('hello') + False + >>> is_iterizeable('hello world') + True + >>> is_iterizeable({'a': 1}) + False + >>> is_iterizeable({'a': 1}, allowmap=True) + True + """ + if isinstance(basetype, str): + if basetype == 'notlist': + return isinstance(arg, list) + elif arg.__class__.__name__ == basetype: + basetype = type(arg) + elif arg.__class__.__qualname__ == basetype: + basetype = type(arg) + else: + basetype = type(None) + if isinstance(arg, str) and ' ' in arg: + return True + if basetype and isinstance(arg, basetype): + return False + if not allowmap and isinstance(arg, evn.Mapping): + return False + if hasattr(arg, '__iter__'): + return True + return False + + +def iterize_on_first_param( + func0: evn.F = NoneFunc, + *, + basetype: 'str|type|tuple[type,...]' = str, + splitstr=True, + asdict=False, + asbunch=False, + asnumpy=False, + allowmap=False, + nonempty=False, +) -> evn.F: + """ + Decorator to vectorize a function over its first parameter. + + This decorator allows a function to seamlessly handle both scalar and iterable inputs for its first + parameter. When the first argument is iterable (and not excluded by type), the function is applied + to each element individually. The results are then combined and returned in a format determined by the + decorator options. + + :param func0: The function to decorate. Can be omitted when using decorator syntax with arguments. + :param basetype: Type(s) that should be treated as scalar, even if iterable. Defaults to str. + :param splitstr: If True, strings containing spaces are split into lists before processing. + Defaults to True. + :param asdict: If True, returns results as a dictionary with input values as keys. Defaults to False. + :param asbunch: If True, returns results as a Bunch (a dict-like object with attribute access). + Defaults to False. + :param asnumpy: If True, returns results as a numpy array. Defaults to False. + :param allowmap: If True, allows mapping types (e.g. dict) to be processed iteratively. Defaults to False. + :return: A decorated function that can handle both scalar and iterable inputs for its first parameter. + :rtype: callable + + Examples: + Basic usage: + >>> @iterize_on_first_param + ... def square(x): + ... return x * x + >>> square(4) + 16 + >>> square([1, 2, 3]) + [1, 4, 9] + + Using asdict to return results as a dictionary: + >>> @iterize_on_first_param(asdict=True, basetype=str) + ... def double(x): + ... return x * 2 + >>> double(['a', 'b']) + {'a': 'aa', 'b': 'bb'} + + **Basic usage with default behavior**: + + >>> @iterize_on_first_param + ... def square(x): + ... return x * x + >>> square(5) + 25 + >>> square([1, 2, 3]) + [1, 4, 9] + + **Using `basetype` to prevent iteration over strings**: + + >>> @iterize_on_first_param(basetype=str) + ... def process(item): + ... return len(item) + >>> process('hello') # Treated as scalar despite being iterable + 5 + >>> process(['hello', 'world']) + [5, 5] + + **Using `asdict` to return results as a dictionary**: + + >>> @iterize_on_first_param(asdict=True) + ... def double(x): + ... return x * 2 + >>> double([1, 2, 3]) + {1: 2, 2: 4, 3: 6} + + **Using `asbunch` to return results as a Bunch**: + + >>> @iterize_on_first_param(asbunch=True) + ... def triple(x): + ... return x * 3 + >>> result = triple(['a', 'b']) + >>> result.a + 'aaa' + >>> result.b + 'bbb' + + **Using `allowmap` to enable mapping support**: + + >>> @iterize_on_first_param(allowmap=True) + ... def negate(x): + ... return -x + >>> negate({'a': 1, 'b': 2}) + {'a': -1, 'b': -2} + + Notes: + - The decorator can be applied with or without parentheses. + - If `asdict` and `asbunch` are both `True`, `asbunch` takes precedence. + - If `allowmap` is `True`, the decorator will apply the function to the values + of the mapping and return a new mapping. + """ + + def deco(func: evn.F) -> evn.F: + + @functools.wraps(func) + def wrapper(arg0, *args, **kw): + if is_iterizeable(arg0, + basetype=basetype, + splitstr=splitstr, + allowmap=allowmap): + if splitstr and isinstance(arg0, str) and ' ' in arg0: + arg0 = arg0.split() + if allowmap and isinstance(arg0, evn.Mapping): + result = {k: func(v, *args, **kw) for k, v in arg0.items()} + elif asdict or asbunch: + result = {a0: func(a0, *args, **kw) for a0 in arg0} + else: + result = [func(a0, *args, **kw) for a0 in arg0] + with contextlib.suppress(TypeError, ValueError): + result = type(arg0)(result) + if nonempty and evn.islist(result): + result = list(filter(len, result)) + if nonempty and evn.isdict(result): + {k: v for k, v in result.items() if len(v)} + if asbunch and result and isinstance(evn.first(result.keys()), + str): + result = evn.Bunch(result) + if asnumpy and evn.installed.numpy: + import numpy as np + + result = np.array(result) + return result + return func(arg0, *args, **kw) + + return wrapper + + if func0 is not NoneFunc: # handle case with no call/args + assert callable(func0) + return deco(func0) + return deco + + +iterize_on_first_param_path = iterize_on_first_param(basetype=(str, Path)) diff --git a/lib/evn/evn/decofunc/state_management.py b/lib/evn/evn/decofunc/state_management.py new file mode 100644 index 00000000..552049bc --- /dev/null +++ b/lib/evn/evn/decofunc/state_management.py @@ -0,0 +1,40 @@ +""" +- **Random State Preservation**: Use :func:`preserve_random_state` to temporarily set a random seed + during a function call. +""" + +import functools +import evn + + +def preserve_random_state(func0=None, seed0=None): + """Decorator to preserve the random state during function execution. + + This decorator sets a temporary random seed during the execution of the decorated function. + If a `seed` is passed as a keyword argument to the function, it will override the default seed. + + Args: + func0 (callable, optional): The function to decorate. If provided, the decorator can be used without parentheses. + seed0 (int, optional): The default random seed to use if not overridden by a `seed` keyword argument. + + Returns: + callable: The decorated function. + + Raises: + AssertionError: If `func0` is provided but is not callable or if `seed0` is not None when `func0` is used. + """ + + def deco(func): + + @functools.wraps(func) + def wrapper(*args, **kw): + with evn.temporary_random_seed(seed=kw.get('seed', seed0)): + return func(*args, **kw) + + return wrapper + + if func0: # handle case with no call/args + assert callable(func0) + assert seed0 is None + return deco(func0) + return deco diff --git a/lib/evn/evn/decon/__init__.py b/lib/evn/evn/decon/__init__.py new file mode 100644 index 00000000..bbbf641f --- /dev/null +++ b/lib/evn/evn/decon/__init__.py @@ -0,0 +1,5 @@ +from evn.decon.metadata import * +from evn.decon.attr_access import * +from evn.decon.item_wise import * +from evn.decon.bunch import * +from evn.decon.iterables import * diff --git a/lib/evn/evn/decon/attr_access.py b/lib/evn/evn/decon/attr_access.py new file mode 100644 index 00000000..af794292 --- /dev/null +++ b/lib/evn/evn/decon/attr_access.py @@ -0,0 +1,447 @@ +""" +Decorators and Utilities for Enhanced Container Functionality +=============================================================== + +This module provides a collection of decorators and helper functions designed to extend the +behavior of functions and classes. Key features include: + +- **Vectorization**: Use :func:`iterize_on_first_param` to automatically vectorize a function + over its first parameter. +- **Enhanced Attribute Access**: The :func:`subscriptable_for_attributes` decorator adds support + for subscriptable attribute access, fuzzy matching, enumeration, and grouping. +- **Utility Functions**: Other helpers (e.g. :func:`generic_get_keys`) + provide common functionality for attribute and iterable handling. + +Examples: + Making a class subscriptable for attribute access:: + + >>> @subscriptable_for_attributes + ... class MyClass: + ... def __init__(self): + ... self.a = 1 + ... self.b = 2 + >>> obj = MyClass() + >>> obj['a'] + 1 + >>> obj['a b'] + (1, 2) +""" + +from typing import Any, Iterable +import evn + + +def NoneFunc(): + """This function does nothing and is used as a default placeholder.""" + pass + + +def subscriptable_for_attributes(cls: type[evn.C]) -> type[evn.C]: + """Class decorator to enable subscriptable attribute access and enumeration. + + This decorator adds support for `__getitem__` and `enumerate` methods to a class + using `generic_getitem_for_attributes` and `generic_enumerate`. + + Args: + cls (type): The class to modify. + + Returns: + type: The modified class. + + Example: + >>> @subscriptable_for_attributes + ... class MyClass: + ... def __init__(self): + ... self.x = 1 + ... self.y = 2 + >>> obj = MyClass() + >>> print(obj['x']) + 1 + >>> print(obj['x y']) # (1, 2) + (1, 2) + >>> for i, x, y in obj.enumerate(['x', 'y']): + ... print(i, x, y) + 0 1 2 + + Raises: + TypeError: If the class already defines `__getitem__` or `enumerate`. + """ + for member in 'enumerate fzf groupby'.split(): + if hasattr(cls, member): + raise TypeError(f'class {cls.__name__} already has {member}') + cls.__getitem__ = make_getitem_for_attributes(get=getattr) + cls.fzf = make_getitem_for_attributes(get=getattr_fzf) + cls.enumerate = generic_enumerate + cls.groupby = generic_groupby + cls.pick = make_getitem_for_attributes(provide='item') + return cls + + +# helper functions + + +def generic_get_keys(obj, exclude: evn.FieldSpec = ()): + """ + Retrieve keys or indices from an object. + + This function attempts to extract keys from an object. It checks for a ``keys()`` or ``items()`` + method, or if the object is a list returns its indices. Otherwise, it returns attribute names + that pass the validity checks. + + :param obj: The object from which to extract keys. + :param exclude: An iterable of keys to exclude. Defaults to an empty tuple. + :return: A list of keys or indices. + :rtype: list + + Example: + >>> class A: + ... def __init__(self): + ... self.x = 1 + ... self._y = 2 + >>> a = A() + >>> generic_get_keys(a) + ['x'] + """ + if hasattr(obj, 'keys') and callable(getattr(obj, 'keys')): + return [k for k in obj.keys() if valid_element_name(k, exclude)] + elif hasattr(obj, 'items'): + return [k for k, v in obj.items() if valid_element_name(k, exclude)] + elif isinstance(obj, list): + return list(range(len(obj))) + else: + return [ + k for k in dir(obj) if valid_element_name_thorough(k, exclude) + and not callable(getattr(obj, k)) + ] + raise TypeError(f'dont know how to get elements from {obj}') + + +def generic_get_items(obj, all=False): + """ + Retrieve key-value pairs from an object. + + This function returns a list of (key, value) pairs from the object. It supports objects that + have an ``items()`` or ``keys()`` method, as well as lists (using indices) or attributes. + + :param obj: The object from which to extract items. + :return: A list of (key, value) pairs. + :rtype: list + + Example: + >>> class A: + ... def __init__(self): + ... self.a = 1 + ... self.b = 2 + >>> a = A() + >>> generic_get_items(a) + [('a', 1), ('b', 2)] + """ + if hasattr(obj, 'items'): + return [(k, v) for k, v in obj.items() if all or valid_element_name(k)] + elif hasattr(obj, 'keys') and callable(getattr(obj, 'keys')): + return [(k, getattr(obj, k)) for k in obj.keys() + if all or valid_element_name(k)] + elif isinstance(obj, list): + return list(enumerate(obj)) + else: + return [(k, getattr(obj, k)) for k in dir(obj) + if (all or valid_element_name_thorough(k)) + and not callable(getattr(obj, k))] + raise TypeError(f'dont know how to get elements from {obj}') + + +def valid_element_name(name, exclude=()): + """ + Check if a name is valid based on naming conventions. + + A valid name must not start or end with an underscore and must not be in the excluded list. + + :param name: The name to check. + :type name: str + :param exclude: An iterable of names to exclude. + :return: True if the name is valid, otherwise False. + :rtype: bool + + Example: + >>> valid_element_name('foo') + True + >>> valid_element_name('_bar') + False + """ + return not name[0] == '_' and not name[-1] == '_' and name not in exclude + + +def valid_element_name_thorough(name, exclude=()): + """ + Thoroughly check if a name is valid by applying additional reserved name rules. + + In addition to the checks performed by :func:`valid_element_name`, this function also ensures + that the name is not in a set of reserved element names. + + :param name: The name to check. + :type name: str + :param exclude: An iterable of names to exclude. + :return: True if the name is valid, otherwise False. + :rtype: bool + + Example: + >>> valid_element_name_thorough('mapwise') + False + """ + return valid_element_name(name, + exclude) and name not in _reserved_element_names + + +_reserved_element_names = set('mapwise npwise valwise dictwise'.split()) + + +def get_fields( + obj, fields: evn.FieldSpec, + exclude: evn.FieldSpec = ()) -> tuple[Iterable, bool]: + """ + Determine and return the fields from an object. + + The function returns a tuple containing a list of field names and a boolean indicating whether + multiple fields are expected. + + :param obj: The object from which to extract fields. + :param fields: A field specification that may be a callable, a string, or an iterable. + :param exclude: Fields to exclude from the result. Defaults to an empty tuple. + :return: A tuple (fields, is_plural) where fields is a list of field names and is_plural is a bool. + :rtype: tuple(list, bool) + + Example: + >>> class A: + ... def __init__(self): + ... self.a = 1 + ... self.b = 2 + >>> a = A() + >>> get_fields(a, 'a') + (['a'], False) + >>> get_fields(a, 'a b') + (['a', 'b'], True) + """ + + if callable(fields): + fields = fields(obj) + if fields is None: + return generic_get_keys(obj, exclude=exclude), True + if ' ' in fields: + return evn.cast(str, fields).split(), True + if isinstance(fields, str): + return [fields], False + return fields, True + + +def make_getitem_for_attributes(get=getattr, provide='value') -> 'Any': + if provide not in ('value', 'item'): + raise ValueError(f"provide must be 'value' or 'item', not {provide}") + + def getitem_for_attributes(self, fields: evn.FieldSpec, get=get) -> 'Any': + """Enhanced `__getitem__` method to support attribute access with multiple keys. + + If the field is a string containing spaces, it will be split into a list of keys. + If the field is a list of strings, it will return the corresponding attributes as a tuple. + + Args: + field (list[str] | str): A single attribute name or a list of attribute names. + + Returns: + Any: The attribute value(s) corresponding to the field(s). + + Example: + >>> obj = MyClass() + >>> value = obj['x'] # Single field + >>> values = obj['x y z'] # Multiple keys as a string + >>> values = obj[['x', 'y', 'z']] # Multiple keys as a list + """ + # try: + field, plural = get_fields(self, fields) + if provide == 'value': + if plural: + return tuple(get(self, k) for k in field) + else: + return get(self, field[0]) + if provide == 'item': + if plural: + return evn.Bunch((k, get(self, k)) for k in field) + return (field[0], get(self, field[0])) + + # except AttributeError as e: + # # print(fields) + # if isinstance(self, evn.Bunch): + # if ' ' in self._conf('split'): + # print(fields) + # return self.__getitem__(fields) + # raise e from None + + return getitem_for_attributes + + +def generic_enumerate(self, + fields: evn.FieldSpec = None, + order=lambda x: x) -> evn.EnumerIter: + """ + Enhanced enumerate method to iterate over multiple attributes simultaneously. + + This method retrieves the specified fields from the object and yields an enumeration of the field values. + If the fields are provided as a string with spaces, they will be split into a list of field names. + + :param fields: A field specification (string or list of strings) indicating which attributes to enumerate. + If None, all valid attributes are enumerated. + :param order: A function to order the enumeration indices and values. Defaults to identity. + :return: An iterator yielding tuples containing the index and the corresponding attribute values. + :rtype: iterator + + Example: + >>> class A: + ... def __init__(self): + ... self.x = [1, 2] + ... self.y = [3, 4] + ... + ... __getitem__ = make_getitem_for_attributes() + ... enumerate = generic_enumerate + >>> a = A() + >>> list(a.enumerate('x y')) + [(0, 1, 3), (1, 2, 4)] + >>> @evn.subscriptable_for_attributes + ... class MyClass: + ... def __init__(self): + ... self.x = range(5) + ... self.y = range(5, 10) + >>> obj = MyClass() + >>> for i, x, y in obj.enumerate('x y'): + ... print(i, x, y) + 0 0 5 + 1 1 6 + 2 2 7 + 3 3 8 + 4 4 9 + """ + if fields is None: + fields = generic_get_keys(self) + vals = self[fields] + try: + fields = list(zip(*vals)) + except TypeError: + fields = [vals] + idx = range(len(fields)) + for i, vals in zip(order(idx), order(fields)): + yield i, *vals + + +def generic_groupby( + self, + groupby: evn.FieldSpec, + fields: evn.FieldSpec = None, + convert=None, +) -> evn.EnumerListIter: + """ + Group object attributes by a specified key. + + This method groups attributes based on the values obtained from the `groupby` field specification. + Optionally, only a subset of fields may be selected and a conversion function applied to the grouped values. + + :param groupby: A field specification (or callable) to determine group keys. + :param fields: A field specification indicating which fields to group. Defaults to None (all keys). + :param convert: An optional function to convert the grouped values. + :return: An iterator over grouped data. Each iteration yields a group key and the corresponding grouped values. + :rtype: iterator + + Example: + >>> class A: + ... def __init__(self): + ... self.a = [1, 2, 3, 4] + ... self.group = ['x', 'x', 'y', 'y'] + ... + ... __getitem__ = make_getitem_for_attributes() + ... groupby = generic_groupby + >>> a = A() + >>> list(a.groupby('group', 'a')) + [('x', (1, 2)), ('y', (3, 4))] + """ + exclude = None + splat = isinstance(fields, str) + if callable(groupby): + groupby = groupby(self) + else: + groupby, plural = get_fields(self, groupby) + exclude = groupby + if not plural: + groupby = groupby[0] + groupby = self[groupby] + fields, _ = get_fields(self, fields, exclude=exclude) + vals = self[fields] + groups = dict() + for k, v in zip(groupby, zip(*vals)): + groups.setdefault(k, []).append(v) + for group, vals in groups.items(): + vals = zip(*vals) + if convert: + vals = map(convert, vals) + if splat: + yield group, *vals + else: + yield group, evn.Bunch(zip(fields, vals)) + + +def is_fuzzy_match(sub, string): + """ + Check if one string is a fuzzy subsequence of another. + + This function checks that the first two characters of `sub` match those of `string` + and then verifies that all characters in `sub` appear in order in `string`. + + :param sub: The subsequence to check. + :param string: The string to search within. + :return: True if `sub` is a fuzzy match of `string`, False otherwise. + :rtype: bool + + Example: + >>> is_fuzzy_match('abc', 'ab2c3') + True + >>> is_fuzzy_match('acb', 'ab2c3') + False + """ + if sub[:2] != string[:2]: + return False + i, j = 0, 0 + while i < len(sub) and j < len(string): + if sub[i] == string[j]: + i += 1 + j += 1 + return i == len(sub) + + +def getattr_fzf(obj, field): + """ + Retrieve an attribute from an object using fuzzy matching. + + This function uses fuzzy matching to find attribute names that are similar to the given field. + If a single match is found, its value is returned. If multiple matches are found, an error is raised. + + :param obj: The object to search. + :param field: The field name (or fuzzy substring) to search for. + :return: The attribute value corresponding to the matched field. + :rtype: Any + :raises AttributeError: If no matching attribute is found or if multiple ambiguous matches exist. + + Example: + >>> class A: + ... def __init__(self): + ... self.abc = 1 + ... self.xyz = 2 + ... + ... fzf = make_getitem_for_attributes(get=getattr_fzf) + >>> a = A() + >>> a.fzf('ab') + 1 + """ + fields = generic_get_keys(obj, exclude=()) + candidates = [f for f in fields if is_fuzzy_match(field, f)] + if not candidates: + raise AttributeError(f'no attribute found for {field}') + if len(candidates) == 1: + return getattr(obj, candidates[0]) + raise AttributeError( + f'multiple attributes found for {field}: {candidates}') diff --git a/lib/evn/evn/decon/bunch.py b/lib/evn/evn/decon/bunch.py new file mode 100644 index 00000000..1f050831 --- /dev/null +++ b/lib/evn/evn/decon/bunch.py @@ -0,0 +1,635 @@ +import typing as t +import contextlib +import hashlib +import os +import shutil +from rapidfuzz import fuzz +from pathlib import Path +from typing import Generic, TypeVar, Mapping, Iterable +from evn.decon.item_wise import item_wise_operations +from evn.decon.attr_access import subscriptable_for_attributes +from evn._prelude.inspect import summary +from evn import NA +import evn + +__all__ = ('Bunch', 'bunchify', 'unbunchify', 'make_autosave_hierarchy', + 'unmake_autosave_hierarchy') + +T = TypeVar('T', bound=t.Any) + + +def strmatch(a, b, fuzzy=0.2, partial='auto'): + if not fuzzy: + return a in b + func = fuzz.ratio + if partial is True or (partial == 'auto' and max(len(a), len(b)) > 10): + func = fuzz.partial_ratio + return func(a, b) >= 1 - fuzzy + + +def bunchfind(haystack, + needle, + fuzzy=0, + partial='auto', + path='', + seenit=None, + matcher=fuzz.partial_ratio): + seenit = seenit or set() + found = {} + if id(haystack) in seenit: + return found + seenit.add(id(haystack)) + items = enumerate(haystack) + if isinstance(haystack, Mapping): + found |= { + f'{path}{k}': v + for k, v in haystack.items() if strmatch(needle, k, fuzzy, partial) + } + items = haystack.items() + for k, v in items: + if isinstance(v, (Mapping, Iterable)): + found |= bunchfind(v, needle, fuzzy, partial, f'{path}{k}.', + seenit, matcher) + return found + + +@subscriptable_for_attributes +@item_wise_operations +class Bunch(dict, Generic[T]): + """ + a dot-accessable dict subclass with defaultdict and chainmap functionallity + + keys must be strings. Can autosync with a .yaml file on disk. supports parent-child relationships. has considerable runtime overhead compared to a normal dict, so dont use in place of one. can + """ + + def __init__( + self, + __arg_or_ns=None, + _strict='__STRICT', + _default: t.Any = '__NODEFALT', + _storedefault=True, + _autosave=None, + _autoreload=None, + _parent=None, + _split='', + _like=None, + _frozen=False, + _flagmode=False, + **kw, + ): + if __arg_or_ns is not None: + try: + super().__init__(__arg_or_ns) + except TypeError: + super().__init__(vars(__arg_or_ns)) + self.update(kw) + + if _default == '__NODEFALT': + _default = None + self.__dict__['_config'] = {} + conf = self.__dict__['_config'] + conf['strict_lookup'] = _strict is True or _strict == '__STRICT' + conf['default'] = _default + conf['storedefault'] = _storedefault + conf['autosave'] = str(_autosave) if _autosave else None + conf['autoreload'] = _autoreload + conf['split'] = _split + conf['frozen'] = _frozen + conf['flagmode'] = _flagmode + if conf['autoreload']: + Path(conf['autoreload']).touch() + with open(conf['autoreload'], 'rb') as inp: + self._conf('autoreloadhash', + hashlib.md5(inp.read()).hexdigest()) + conf['parent'] = _parent + for k in list(self.keys()): + if hasattr(super(), k): + self[f'{k}_'] = super().__getitem__(k) + del self[k] + # print(f'WARNING {k} is a reserved name for dict, renaming to {k}_') + if _like: + conf |= _like.__dict__['_config'] + + _find = bunchfind + + def _conf(self, k, v: t.Any = NA) -> t.Any: + if '_config' not in self.__dict__: + return '' + if v == NA: + return self.__dict__['_config'][k] + else: + self.__dict__['_config'][k] = v + + def _autoreload_check(self): + if not self._conf('autoreload'): + return + import yaml + + with open(self._conf('autoreload'), 'rb') as inp: + newhash = hashlib.md5(inp.read()).hexdigest() + # print('_autoreload_check', newhash, self._conf('autoreloadhash')) + if self._conf('autoreloadhash') == newhash: + return + self._conf('autoreloadhash', newhash) + # disable autosave + orig = self._conf('autosave') + self._conf('autosave', None) + # print('RELOAD FROM FILE', self._conf('autoreload')) + with open(self._conf('autoreload')) as inp: + new = yaml.load(inp, yaml.Loader) + special = self._conf + super().clear() + for k, v in new.items(): + self[k] = make_autosave_hierarchy( + v, + _parent=(self, None), + _default=self._conf('default'), + _strict=self._conf('strict_lookup')) + self._conf('autosave', orig) + assert self._conf == special + + def _notify_changed(self, k=None, v=None): # sourcery skip: extract-method + if self._conf('parent'): + parent, selfkey = self._conf('parent') + return parent._notify_changed(f'{selfkey}.{k}', v) + if self._conf('autosave'): + import yaml + + if k: + k = k.split('.')[0] + if isinstance(v, (list, set, tuple, Bunch)): + self[k] = make_autosave_hierarchy( + self[k], + _parent=(self, None), + _default=self._conf('default'), + _strict=self._conf('strict_lookup'), + ) + os.makedirs(os.path.dirname(self._conf('autosave')), exist_ok=True) + with open(self._conf('autosave') + '.tmp', 'w') as out: + yaml.dump(unmake_autosave_hierarchy(self), out) + shutil.move( + self._conf('autosave') + '.tmp', self._conf('autosave')) + with open(self._conf('autoreload'), 'rb') as inp: + self._conf('autoreloadhash', + hashlib.md5(inp.read()).hexdigest()) + # print('SAVE TO ', self._conf('autosave')) + + def _merge(self, other, layer: str = ''): + for key in other: + if key in self: + if isinstance(self[key], dict) and isinstance( + other[key], dict): + if not isinstance(self[key], Bunch): + self[key] = Bunch(self[key], _like=self) + self[key]._merge(other[key]) + else: + self[key] = other[key] + else: + self[key] = other[key] + return self + + def default(self, key): + default = self._conf('default') + if default == 'bunchwithparent': + new = Bunch(_parent=(self, None), + _default='bunchwithparent', + _strict=self._conf('strict_lookup')) + special = new._config.copy() + special['parent'] = id(special['parent']) + # print('new child bunch:', key) #, '_config:', special) + return new + if default == Bunch: + return Bunch(_like=self) + if not callable(default): + return default + try: + return default() + except TypeError: + return default(key) + + def __eq__(self, other): + self._autoreload_check() + if hasattr(other, '_autoreload_check'): + other._autoreload_check() + return super().__eq__(other) + + def reduce(self, func, strict=True): + "reduce all contained iterables using " + self._autoreload_check() + for k in self: + try: + self[k] = func(self[k]) + except TypeError as ex: + if not strict: + raise ex + return self + + def accumulate(self, other, strict=True): + "accumulate all keys in other, adding empty lists if k not in self, extend other[k] is list" + if self._conf('frozen'): + raise ValueError('Bunch is frozen') + self._autoreload_check() + if isinstance(other, list): + for b in other: + self.accumulate(b) + return self + if not isinstance(other, dict): + raise TypeError('Bunch.accumulate needs Bunch or dict type') + not_empty = len(self) + for k in other: + if k not in self: + if strict and not_empty: + raise ValueError(f'{k} not in this Bunch') + self[k] = [] + if not isinstance(self[k], list): + self[k] = [self[k]] + o = other[k] + if not isinstance(o, list): + o = [o] + self[k].extend(o) + return self + + def __contains__(self, k): + self._autoreload_check() + if k == '_conf': + return False + if self._conf('flagmode'): + return self._contains_flagmode(k) + return self._contains(k) + + def _contains(self, k): + try: + return dict.__contains__(self, k) or k in self.__dict__ + except KeyError: + return False + + def _contains_flagmode(self, k): + try: + return self[k] + except (KeyError, AttributeError): + assert 0 + self[k] = self._default(k) + return self[k] + + def __getattr__(self, k: str) -> T: + self._autoreload_check() + if k == '_config': + raise ValueError('_config is a reseved name for Bunch') + if k == '__deepcopy__': + return None + if self.__dict__['_config']['strict_lookup'] and not self._contains(k): + if self._conf('default'): + self.__dict__[k] = self.default(k) + return self[k] + raise AttributeError(f'Bunch is missing value for key {k}') + try: + # Throws exception if not in prototype chain + return object.__getattribute__(self, k) + except AttributeError: + try: + return super().__getitem__(k) + except KeyError as e: + if self.__dict__['_config']['strict_lookup']: + raise e + if self._conf('storedefault'): + self[k] = self.default(k) + return self[k] + return self[k] + + def __getitem__(self, key: str) -> T: + if not isinstance(key, str): + return [getattr(self, k) for k in key] + return self.__getattr__(key) + + def _get_split(self, keys, create=False): + for split in self._conf('split'): + if split in keys and split: + obj = self + for k in keys.split(): + if create: + obj = obj.setdefault(k, Bunch(_like=self)) + else: + obj = obj[k] + return obj + return self.__getattr__(keys) + + def __setitem__(self, k: str, v: T): + if self._conf('frozen'): + raise ValueError('Bunch is frozen') + for split in self._conf('split'): + if split in k and split: + obj, keys, klast = self, *k.rsplit(split, 1) + for k in keys.split(): + obj[k] = obj = obj[k] if k in obj else Bunch(_like=self) + obj[klast] = v + return + super().__setitem__(k, v) + + def __setattr__(self, k: str, v: T): + if self._conf('frozen'): + raise ValueError('Bunch is frozen') + if hasattr(super(), k): + raise ValueError(f'{k} is a reseved name for Bunch') + if k.startswith('__'): + self.__dict__[k] = v + return + try: + # Throws exception if not in prototype chain + object.__getattribute__(self, k) + except AttributeError: + try: + self[k] = v + self._notify_changed(k, v) + except KeyError as e: + raise AttributeError(k) from e + else: + object.__setattr__(self, k, v) + self._notify_changed(k, v) + + def __delattr__(self, k): + if self._conf('frozen'): + raise ValueError('Bunch is frozen') + try: + # Throws exception if not in prototype chain + object.__getattribute__(self, k) + except AttributeError: + try: + del self[k] + self._notify_changed(k) + except KeyError as e: + raise AttributeError(k) from e + else: + object.__delattr__(self, k) + self._notify_changed(k) + + # def __setitem__(self, k:str, v): + # super().__setitem__(k, v) + # self._notify_changed(k, v) + + def __delitem__(self, k): + if self._conf('frozen'): + raise ValueError('Bunch is frozen') + super().__delitem__(k) + self._notify_changed(k) + + def copy(self): + self._autoreload_check() + return Bunch.from_dict(super().copy(), _like=self) + + def set_if_missing(self, k: str, v): + self._autoreload_check() + if k not in self: + self[k] = v + self._notify_changed(k, v) + + def sub(self, __BUNCH_SUB_ITEMS=None, _onlynone=False, exclude=[], **kw): + if self._conf('frozen'): + raise ValueError('Bunch is frozen') + self._autoreload_check() + if not kw: + if isinstance(__BUNCH_SUB_ITEMS, dict): + kw = __BUNCH_SUB_ITEMS + else: + kw = vars(__BUNCH_SUB_ITEMS) + newbunch = self.copy() + newbunch._config = self.__dict__['_config'] + for k, v in kw.items(): + if v is None and k in newbunch: + del newbunch[k] + elif not _onlynone or k not in self or self[k] is None: + if k not in exclude: + newbunch.__setattr__(k, v) + return newbunch + + def only(self, keys): + self._autoreload_check() + newbunch = Bunch() + newbunch._config = self.__dict__['_config'] + for k in keys: + if k in self: + newbunch[k] = self[k] + return newbunch + + def without(self, *dropkeys): + self._autoreload_check() + newbunch = Bunch() + newbunch._config = self.__dict__['_config'] + for k in self.keys(): + if k not in dropkeys: + newbunch[k] = self[k] + return newbunch + + def visit_remove_if(self, func, recurse=True, depth=0): + self._autoreload_check() + toremove = [] + for k, v in self.__dict__.items(): + if k == '_config': + continue + if func(k, v, depth): + toremove.append(k) + elif isinstance(v, Bunch) and recurse: + v.visit_remove_if(func, recurse, depth=depth + 1) + for k, v in self.items(): + if func(k, v, depth): + toremove.append(k) + elif isinstance(v, Bunch) and recurse: + v.visit_remove_if(func, recurse, depth=depth + 1) + for k in toremove: + self.__delattr__(k) + + def __add__(self, addme): + self._autoreload_check() + newbunch = self.copy() + for k, v in addme.items(): + if k in self: + newbunch.__setattr__(k, self[k] + v) + else: + newbunch.__setattr__(k, v) + return newbunch + + def __getstate__(self): + return self.__dict__ + + def __setstate__(self, d): + self.__dict__.update(d) + + def __str__(self): + self._autoreload_check() + s = 'Bunch(' + ', '.join([f'{k}={v}' for k, v in self.items()]) + s += ')' + if len(s) > 120: + s = f'Bunch({os.linesep}' + if len(self) == 0: + return 'Bunch()' + w = int(min(40, max(len(str(k)) for k in self))) + for k, v in self.items(): + s += f' {k:{f"{w}"}} = {summary(v)}{os.linesep}u' + s += ')' + return s + + def printme(self): + self._autoreload_check() + + def short(thing): + s = str(thing) + if len(s) > 80: + with contextlib.suppress(ImportError): + np = evn.lazyimport('numpy') + + if isinstance(thing, np.ndarray): + s = f'shape {thing.shape}' + else: + s = str(s)[:67].replace('\n', '') + '...' + + return s + + s = 'Bunch(' + ', '.join([f'{k}={v}' for k, v in self.items()]) + ')' + if len(s) > 120: + s = f'Bunch({os.linesep}' + if len(self) == 0: + return 'Bunch()' + w = int(min(40, max(len(str(k)) for k in self))) + for k, v in self.items(): + s += f' {k:{f"{w}"}} = {short(v)}{os.linesep}' + s += ')' + print(s, flush=True) + return s + + def __repr__(self): + self._autoreload_check() + # args = ["%s=%r" % (k, v) for k, v in self.items()] + # args = str.join(',\n ', args) + # return rf"{self.__class__.__name__}(\n {args})" + return str(self) + + def asdict(self): + return unbunchify(self) + + @staticmethod + def from_dict(d, _like=None): + return bunchify(d, _like=_like) + + +class BunchChild: + + def __init__(self, *a, _parent, **kw): + super().__init__(*a, **kw) + assert isinstance(_parent[0], Bunch) + self._parent = _parent + + # def __str__(self): + # return f'{self.__class__.__name__}<{super().__str__()}>' + + # def __repr__(self): + # return f'{self.__class__.__name__}<{super().__repr__()}>' + + +class BunchChildList(BunchChild, list): + + def append(self, elmnt): + super().append(elmnt) + self._parent[0]._notify_changed(self._parent[1], elmnt) + + def __setitem__(self, index, elem): + super().__setitem__(index, elem) + self._parent[0]._notify_changed(f'{self._parent[1]}[{index}]', elem) + + +class BunchChildSet(BunchChild, set): + + def add(self, elem): + super().add(elem) + self._parent[0]._notify_changed(self._parent[1], elem) + + def remove(self, elem): + super().remove(elem) + self._parent[0]._notify_changed(self._parent[1], elem) + + +@t.overload +def bunchify(data: dict[str, t.Any], _like: t.Optional[Bunch] = None) -> Bunch: + ... + + +@t.overload +def bunchify(data: T, _like: t.Optional[Bunch]) -> T: + ... + + +def bunchify(data: t.Any, _like=None): + if isinstance(data, dict): + return Bunch(_like=_like, + **{ + k: bunchify(v, _like=_like) + for k, v in data.items() + }) # type: ignore + elif isinstance(data, (list, tuple)): + return type(data)(bunchify(v, _like=_like) for v in data) + else: + return data + + +def make_autosave_hierarchy(x, + _parent=None, + seenit=None, + _strict=True, + _autosave=None, + _default=None): + seenit = seenit or set() + assert id(x) not in seenit, 'x must be a Tree' + kw = dict(seenit=seenit, + _parent=_parent, + _default=_default, + _strict=_strict) + assert _parent is None or isinstance(_parent[0], Bunch) + if isinstance(x, dict): + x = Bunch(**x, + _parent=_parent, + _autosave=_autosave, + _autoreload=_autosave, + _default=_default, + _strict=_strict) + for k, v in x.items(): + kw['_parent'] = (x, k) + x[k] = make_autosave_hierarchy(v, **kw) + elif isinstance(x, list): + val = (make_autosave_hierarchy(v, **kw) for v in x) + x = BunchChildList(val, _parent=_parent) + elif isinstance(x, set): + val = (make_autosave_hierarchy(v, **kw) for v in x) + x = BunchChildSet(val, _parent=_parent) + elif isinstance(x, (tuple, )): + x = type(x)(make_autosave_hierarchy(v, **kw) for v in x) + seenit.add(id(x)) + return x + + +def unmake_autosave_hierarchy(x, + seenit=None, + depth=0, + verbose=False, + _autosave=None): + seenit = seenit or set() + assert id(x) not in seenit, 'x must be a Tree' + kw = dict(seenit=seenit, depth=depth + 1, verbose=verbose) + if isinstance(x, dict): + x = dict(**x) + for k, v in x.items(): + x[k] = unmake_autosave_hierarchy(v, **kw) + elif isinstance(x, list): + x = [unmake_autosave_hierarchy(v, **kw) for v in x] + elif isinstance(x, set): + x = {unmake_autosave_hierarchy(v, **kw) for v in x} + elif isinstance(x, (tuple, )): + x = type(x)(unmake_autosave_hierarchy(v, **kw) for v in x) + seenit.add(id(x)) + return x + + +def unbunchify(x): + if isinstance(x, dict): + return {k: unbunchify(v) for k, v in x.items()} + elif isinstance(x, (list, tuple)): + return type(x)(unbunchify(v) for v in x) + else: + return x diff --git a/lib/evn/evn/decon/item_wise.py b/lib/evn/evn/decon/item_wise.py new file mode 100644 index 00000000..2b6a2650 --- /dev/null +++ b/lib/evn/evn/decon/item_wise.py @@ -0,0 +1,292 @@ +"""Element-wise operations for collections. + +This module provides a decorator and supporting classes to enable element-wise +operations on custom collection classes. It allows applying methods or functions +to each element in a collection and collecting the results. +""" + +from collections.abc import Mapping +import itertools +from functools import partial +import operator +from evn._prelude.lazy_import import lazyimport + +np = lazyimport('numpy') + +import evn + +generic_get_items = evn.ninja_import( + 'evn.decon.attr_access','generic_get_items') + + +def get_available_result_types(): + return dict( + map=BunchAccumulator, + dict=DictAccumulator, + val=ListAccumulator, + np=NumpyAccumulator, + ) + + +class Missing: + pass + + +def item_wise_operations(cls0: evn.Optional[type[evn.C]] = Missing, + result_types='map val') -> type[evn.C]: + """Decorator that adds element-wise operation capabilities to a class. + + Adds up to four attributes to the decorated class: + - dictwise: Returns results as a dict + - mapwise: Returns results as a mapping (evn.Bunch) + - valwise: Returns results as a list + - npwise: Returns results as a numpy array + + Args: + cls: The class to decorate + result_types: selects which attributes to add + + Returns: + The decorated class + """ + if evn.is_installed('numpy'): + result_types = f'np {result_types}' + if cls0 is Missing: + return partial(item_wise_operations, result_types=result_types) + orig = result_types + if isinstance(result_types, str): + result_types = result_types.split() if ' ' in result_types else [ + result_types + ] + result_types = set(result_types) + available_result_types = get_available_result_types() + if not set(available_result_types) & result_types: + raise TypeError(f'result_types {orig} is invalid') + + def decorate(cls: type[evn.C]) -> type[evn.C]: + for rtype in result_types: + setattr(cls, f'{rtype}wise', + ElementWise(available_result_types[rtype])) + return cls + + return decorate(cls0) + + +class ElementWise: + """Descriptor that creates and caches an ElementWiseDispatcher. + + When accessed from an instance, returns a dispatcher that applies + operations element-wise and collects results using the specified + accumulator. + """ + + def __init__(self, Accumulator): + self.Accumulator = Accumulator + + def __get__(self, parent, _parenttype): + # if parent is None: return None + if not hasattr(parent, '_ewise_dispatcher'): + parent.__dict__['_ewise_dispatcher'] = dict() + if self.Accumulator not in parent._ewise_dispatcher: + new = ElementWiseDispatcher(parent, self.Accumulator) + parent._ewise_dispatcher[self.Accumulator] = new + return parent._ewise_dispatcher[self.Accumulator] + + def __set__(self, parent, values): + items = values.items() if isinstance(values, Mapping) else zip( + parent.keys(), values) + for k, v in items: + parent[k] = v + + +class ElementWiseDispatcher: + """Dispatcher that applies operations to each element in a collection. + + Dynamically creates methods that apply an operation to each element + and collect the results using the specified accumulator. + """ + + def __init__(self, parent, Accumulator): + """Initialize with a parent collection and an accumulator class. + + Args: + parent: The collection to operate on + Accumulator: Class that implements the accumulator interface + """ + self._parent = parent + self._Accumulator = Accumulator + self._parent.__dict__['_ewise_method'] = dict() + + # create wrappers for binary operators and their 'r' right versions + for name, op in vars(operator).items(): + if not (name.startswith('__')) and not name.startswith('i'): + locals( + )[f'__{name}__'] = lambda self, other, op=op: self.__getattr__(op)( + other) + locals( + )[f'__r{name}__'] = lambda self, other, op=op: self.__getattr__( + op)(other) + + def __getattr__(self, method): + """Get or create a method that applies the operation element-wise. + + Args: + method: Name of method to call on each element, or a callable + + Returns: + Function that applies the operation and collects results + """ + cachekey = (method, self._Accumulator) + if cachekey not in self._parent._ewise_method: + + def apply_method(*args, **kw): + """Apply method to each element with the given arguments. + + If no positional args are provided, applies the method to each element. + If one arg is provided, applies it to each element. + If multiple args are provided, they must match the number of elements. + + Args: + *args: Arguments to pass to the method + **kw: Keyword arguments to pass to the method + + Returns: + Accumulated results using the configured accumulator + + Raises: + ValueError: If the number of args doesn't match requirements + """ + accum = self._Accumulator() + items = generic_get_items(self._parent) + if not args: + try: + for name, member in items: + if callable(method): + accum.add(name, method(member, **kw)) + else: + accum.add(name, getattr(member, method)(**kw)) + return accum.result() + except TypeError: + # kw forwarding to method failed, use kw ar elementwise args + args = [kw] + kw = {} + if len(args) == 1: + arg = args[0] + itemkeys = set(item[0] for item in items) + if isinstance(arg, dict): + assert arg.keys( + ) == itemkeys, f'{itemkeys} != {arg.keys()}' + # elemwise args passed as single dict + args = [arg[k] for k, _ in items] + else: + args = itertools.repeat(arg) + elif len(args) != len(items): + raise ValueError( + f'ElementWiseDispatcher arg must be len 1 or len(items) == {len(items)}' + ) + for arg, (name, member) in zip(args, items): + if callable(method): + accum.add(name, method(member, arg, **kw)) + else: + accum.add(name, getattr(member, method)(arg, **kw)) + return accum.result() + + self._parent._ewise_method[cachekey] = apply_method + + return self._parent._ewise_method[cachekey] + + def __call__(self, func, *a, **kw): + """call a function on each element of self._parent, forwarding any arguments""" + assert callable(func) + result = self.__getattr__(func)(*a, **kw) + for k, v in generic_get_items(self._parent, all=True): + if k[0] == '_' or k[-1] == '_': + with evn.cl.suppress(AttributeError): + setattr(result, k, v) + return result + + def contains(self, other): + contains_check = getattr(other, 'contains', operator.contains) + return self.__getattr__(contains_check)(other) + + def contained_by(self, other): + if contained_by_check := getattr(other, 'contained_by', None): + return self.__getattr__(contained_by_check)(other) + contained = lambda s, o: operator.contains(o, s) + return self.__getattr__(contained)(other) + + def __contains__(self, other): + raise ValueError( + 'a in foo.*wise is invalid, use .contains or .contained_by') + + def __rsub__(self, other): + """generic wrapper is reversed""" + return generic_negate(self.__getattr__(operator.sub)(other)) + + def __neg__(self): + return self.__getattr__(operator.neg)() + + +class DictAccumulator: + """Accumulator that collects results into an dict. + + Results are stored with their original keys from the parent collection. + """ + + def __init__(self): + self.value = evn.Bunch() + + def add(self, name, value): + self.value[name] = value + + def result(self): + return self.value + + +class BunchAccumulator(DictAccumulator): + """Accumulator that collects results into an evn.Bunch (dict-like object). + + Results are stored with their original keys from the parent collection. + """ + + def __init__(self): + self.value = evn.Bunch() + + +class ListAccumulator: + """Accumulator that collects results into a list. + + Results are stored in the order they are added. + """ + + def __init__(self): + self.value = [] + + def add(self, name, value): + self.value.append(value) + + def result(self): + return self.value + + +class NumpyAccumulator(ListAccumulator): + """Accumulator that collects results into a numpy array. + + Results are stored in the order they are added. + """ + + def result(self): + try: + return np.array(self.value) + except ValueError: + return np.array(self.value, dtype=object) + + +def generic_negate(thing): + if isinstance(thing, np.ndarray): + return -thing + if isinstance(thing, list): + return [-x for x in thing] + for k, v in thing.items(): + thing[k] = -v + return thing diff --git a/lib/evn/evn/decon/iterables.py b/lib/evn/evn/decon/iterables.py new file mode 100644 index 00000000..0fa59c87 --- /dev/null +++ b/lib/evn/evn/decon/iterables.py @@ -0,0 +1,162 @@ +from typing import Sequence, Any +import typing +import operator +import evn + +np = evn.lazyimport('numpy') + +T = typing.TypeVar('T') + + +def nth(thing: evn.Iterable[T], n: int = 0) -> T: + iterator = iter(thing) + try: + for _ in range(n): + next(iterator) + return next(iterator) + except StopIteration: + return None + + +first = nth + + +def head(thing: evn.Iterable[T], n=5, *, requireall=False, start=0) -> list[T]: + iterator, result = iter(thing), [] + try: + for _ in range(start): + next(iterator) + for _ in range(n): + result.append(next(iterator)) + except StopIteration: + if requireall: + return None + return result + + +def order(seq: Sequence[Any], key=None) -> list[int]: + return [a[1] for a in sorted(((s, i) for i, s in enumerate(seq)), key=key)] + + +def reorder(seq: Sequence[T], order: Sequence[int]) -> Sequence[T]: + return [seq[i] for i in order] + + +def reorder_inplace(seq: list[Any], order: Sequence[int]) -> None: + result = reorder(seq, order) + for i, v in enumerate(result): + seq[i] = v + + +def reorderer(order: Sequence[int]) -> evn.Callable[[T], T]: + + def reorder_func(*seqs: list[Any]): + for seq in seqs: + reorder_inplace(seq, order) + + return reorder_func + + +def zipenum(*args): + for i, z in enumerate(zip(*args)): + yield i, *z + + +def subsetenum(n_or_set): + if isinstance(n_or_set, int): + n_or_set = range(n_or_set) + input_set = set(n_or_set) + tot = 0 + for size in range(len(input_set), 0, -1): + for subset in evn.it.combinations(input_set, size): + yield tot, subset + tot += 1 + + +def zipmaps(*args: dict[str, T], + order='key', + intersection=False) -> dict[str, tuple[T, ...]]: + if not args: + raise ValueError('zipmaps requires at lest one argument') + if intersection: + keys = evn.andreduce(set(map(str, a.keys())) for a in args) + else: + keys = evn.decon.orreduce(set(map(str, a.keys())) for a in args) + if order == 'key': + keys = sorted(keys) + if order == 'val': + keys = sorted(keys, key=lambda k: args[0].get(k, evn.NA)) + result = type(args[0])({ + k: tuple(a.get(k, evn.NA) for a in args) + for k in keys + }) + return result + + +def zipitems(*args, **kw): + zipped = zipmaps(*args, **kw) + for k, v in zipped.items(): + yield k, *v + + +@evn.dc.dataclass +class ContiguousTokens: + tokens: Sequence = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz' + idmap: dict = evn.dc.field(default_factory=dict) + _offset: int = 0 + + def __call__(self, ids: 'np.ndarray', reset=False): + if reset: + self._offset += len(self.idmap) + self.idmap.clear() + uniq = set(np.unique(ids)) + for cid in uniq - set(self.idmap): + self.idmap[str(cid)] = self.tokens[(len(self.idmap) - self._offset) + % len(self.tokens)] + newids = evn.copy(ids) + for u in uniq: + newids[ids == u] = self.idmap[u] + # ic(self.idmap) + return newids + + +def contiguous(ids, reset: bool = False, tokens: Sequence['int|str'] = None): + idmaker = ContiguousTokens(*([tokens] if tokens else [])) + return idmaker(ids, reset) + + +def opreduce(op, iterable): + """Reduces an iterable using a specified operator or function. + + This function applies a binary operator or callable across the elements of an iterable, reducing it to a single value. + If `op` is a string, it will look up the corresponding operator in the `operator` module. + + Args: + op (str | callable): A callable or the name of an operator from the `operator` module (e.g., 'add', 'mul'). + iterable (iterable): The iterable to reduce. + + Returns: + Any: The reduced value. + + Raises: + AttributeError: If `op` is a string but not a valid operator in the `operator` module. + TypeError: If `op` is not a valid callable or operator. + + Example: + >>> from operator import add, mul + >>> print(opreduce(add, [1, 2, 3, 4])) # 10 + 10 + >>> print(opreduce('mul', [1, 2, 3, 4])) # 24 + 24 + >>> print(opreduce(lambda x, y: x * y, [1, 2, 3, 4])) # 24 + 24 + """ + if isinstance(op, str): + op = getattr(operator, op) + return evn.ft.reduce(op, iterable) + + +for op in 'add mul matmul or_ and_'.split(): + opname = op.strip('_') + globals()[f'{opname}reduce'] = evn.ft.partial(opreduce, + getattr(operator, op)) diff --git a/lib/evn/evn/decon/metadata.py b/lib/evn/evn/decon/metadata.py new file mode 100644 index 00000000..d2cd4884 --- /dev/null +++ b/lib/evn/evn/decon/metadata.py @@ -0,0 +1,116 @@ +import copy +import evn +from evn.dev.copy import shallow_copy + + +@evn.iterize_on_first_param(basetype='notlist') +def get_metadata(obj): + """ + Retrieve metadata from an object. + + Args: + obj (Any): The object from which to retrieve metadata. + + Returns: + evn.Bunch: The metadata stored in the object, or an empty `evn.Bunch` if no metadata exists. + """ + return vars(obj).get('__evn_metadata__', evn.Bunch()) + + +def set_metadata(obj, data: 'dict|None' = None, **kw): + """ + Set metadata on an object. + + If both `data` and keyword arguments are provided, they are merged. + If `obj` is a list, metadata can be applied to each element in the list. + If both `obj` and `data` are lists, they are zipped together, and metadata is applied pairwise. + + Args: + obj (Any): The object or list of objects to which metadata should be added. + data (Optional[dict]): A dictionary of metadata. + **kw: Additional keyword arguments to include as metadata. + + Returns: + Any: The modified object or list of objects. + """ + data = data | kw if data else kw + if isinstance(obj, list): + if isinstance(data, list): + assert len(obj) == len(data) + [set_metadata(o, d) for o, d in zip(obj, data)] + else: + [set_metadata(o, data) for o in obj] + return + meta = obj.__dict__.setdefault( + '__evn_metadata__', evn.Bunch(_strict=False, _default=evn.Bunch)) + meta.update(data) + return obj + + +def sync_metadata(*objs): + """ + Synchronize metadata across multiple objects. + + Merges metadata from all objects and applies the merged result to each object. + + Args: + *objs (Any): A variable number of objects to synchronize metadata between. + """ + data = evn.orreduce(get_metadata(obj) for obj in objs) + for obj in objs: + set_metadata(obj, data) + return objs + + +def holds_metadata(cls): + """ + Class decorator to enable metadata storage and retrieval. + + Adds `get_metadata`, `set_metadata`, and `sync_metadata` as class methods. + Also modifies the class `__init__` method to accept metadata at initialization. + + Args: + cls (type): The class to decorate. + + Returns: + type: The modified class with metadata support. + """ + + def newinit(self, *a, **kw): + initkw = evn.kwcheck(kw, cls.__init_after_ipd_metadata__) + metadata = { + k: v + for k, v in kw.items() if k not in initkw and k[0] == '_' + } + extra = {k for k in kw if k not in initkw and k not in metadata} + if extra: + raise TypeError( + f"__init__() got an unexpected keyword argument(s): {', '.join(extra)}" + ) + metadata = {k[1:]: v for k, v in metadata.items()} + + self.set_metadata(metadata) + cls.__init_after_ipd_metadata__(self, *a, **initkw) + + def newcopy(self): + if self.__copy_after_ipd_metadata__: + new = self.__copy_after_ipd_metadata__() + else: + new = shallow_copy(self) + new.__evn_metadata__ = copy.copy(self.__evn_metadata__) + return new + + cls.__init_after_ipd_metadata__ = cls.__init__ + cls.__init__ = newinit + cls.__copy_after_ipd_metadata__ = getattr(cls, '__copy__', None) + cls.__copy__ = newcopy + + assert not any( + hasattr(cls, name) + for name in 'set_metadata get_metadata sync_metadata meta'.split()) + cls.set_metadata = set_metadata + cls.get_metadata = get_metadata + cls.sync_metadata = sync_metadata + cls.meta = property(lambda self: get_metadata(self)) + + return cls diff --git a/lib/evn/evn/dev/__init__.py b/lib/evn/evn/dev/__init__.py new file mode 100644 index 00000000..20a3ce2f --- /dev/null +++ b/lib/evn/evn/dev/__init__.py @@ -0,0 +1,2 @@ +from evn.dev.contexts import * +from evn.dev.copy import * diff --git a/lib/evn/evn/dev/contexts.py b/lib/evn/evn/dev/contexts.py new file mode 100644 index 00000000..7757fcfa --- /dev/null +++ b/lib/evn/evn/dev/contexts.py @@ -0,0 +1,301 @@ +""" +=============================== +Context Managers Utility Module +=============================== + +This module provides a collection of useful and versatile **context managers** +for handling various runtime behaviors. These include: + +- **Redirection of stdout/stderr:** Easily capture or redirect print output. +- **Dynamic class casting:** Temporarily change an object's class. +- **Automatic file handling:** Open multiple files and ensure proper cleanup. +- **Temporary working directory changes:** Change directory and automatically revert. +- **Capturing asserts and exceptions:** Capture exceptions for later inspection. +- **Random seed state preservation:** Temporarily set a random seed for reproducibility. +- **Debugging tools:** Trace print statements with stack traces and capture stdio. +- **Suppressing optional imports:** Cleanly handle optional imports without crashing. + +### **๐Ÿ’ก Why Use These Context Managers?** +Context managers allow you to **manage resources safely and concisely**, +ensuring proper cleanup regardless of errors. This module provides **custom utilities** +not found in Python's standard library, which can be extremely useful in testing, +debugging, and experimental setups. + +--- + +## **๐Ÿ“Œ Usage Examples** + +### **Redirect stdout and stderr** +```python +with redirect(stdout=open('output.log', 'w')): + print('This will be written to output.log') +``` + +### **Temporarily Change Working Directory** +```python +import os + +print('Current directory:', os.getcwd()) +with cd('/tmp'): + print('Inside /tmp:', os.getcwd()) +print('Reverted directory:', os.getcwd()) +``` + +### **Capture Standard Output** +```python +with capture_stdio() as captured: + print('Captured output') +# Use captured.getvalue() to retrieve the captured text. +print('Captured text:', captured.getvalue()) +``` + +### **Capture Assertion Errors** +```python +with capture_asserts() as errors: + assert False, 'This assertion error will be captured' +print('Captured errors:', errors) +``` + +### **Suppress Optional Imports** +```python +with optional_imports(): + import some_optional_module # Will not raise ImportError if module is absent. +``` +""" + +import atexit +import io +import os +import sys +import traceback +import contextlib + +__the_real_stdout__ = sys.__stdout__ +__the_real_stderr__ = sys.__stderr__ +import evn + + +def onexit(func, msg=None, **metakw): + + def wrapper(*args, **kw): + if msg is not None: + print(msg) + return func(*args, **(metakw | kw)) + + atexit.register(wrapper) + return wrapper + + +@contextlib.contextmanager +def set_class(cls, self): + try: + orig, self.__class__ = self.__class__, cls + yield self + finally: + self.__class__ = orig # type: ignore + + +@contextlib.contextmanager +def force_stdio(): + """useful as temporary escape hatch with io capuring contexts""" + with redirect(__the_real_stdout__, __the_real_stderr__) as (out, err): + try: + yield out, err + finally: + pass + + +@contextlib.contextmanager +def nocontext(): + try: + yield None + finally: + pass + + +class TraceWrites(object): + + def __init__(self, preset): + self.stdout = sys.stdout + self.preset = preset + self.log = [] + + def write(self, s): + stack = os.linesep.join(traceback.format_stack()) + stack = evn.filter_python_output(stack, + preset=self.preset, + arrows=False) + self.log.append(f'\nA WRITE TO STDOUT!: "{s}"{os.linesep}') + self.log.append(stack) + + def flush(self): + self.stdout.flush() + + def printlog(self): + self.stdout.write(os.linesep.join(self.log)) + + +@contextlib.contextmanager +def trace_writes_to_stdout(preset='aggressive'): + tp = TraceWrites(preset) + with redirect(stdout=tp, after=lambda: tp.printlog()): + yield tp + + +@contextlib.contextmanager +def catch_em_all(): + errors = [] + try: + yield errors + except Exception as e: + errors.append(e) + finally: + pass + + +@contextlib.contextmanager +def redirect( + stdout: evn.IO = sys.stdout, + stderr: evn.IO = sys.stderr, + after: evn.Callable = evn.NoOp, +): + """ + Temporarily redirect the stdout and stderr streams. + + Parameters: + stdout (file-like or None): Target for stdout (default: sys.stdout). + stderr (file-like, 'stdout', or None): Target for stderr (default: sys.stderr). + + Yields: + tuple: (stdout, stderr) during redirection. + """ + _out, _err = sys.stdout, sys.stderr + try: + sys.stdout.flush(), sys.stderr.flush() + if stdout is None: + stdout = io.StringIO() + if stderr == 'stdout': + stderr = stdout + elif stderr is None: + stderr = io.StringIO() + sys.stdout, sys.stderr = stdout, stderr + yield stdout, stderr + finally: + sys.stdout.flush(), sys.stderr.flush() + sys.stdout, sys.stderr = _out, _err + if after: + after() + + +@contextlib.contextmanager +def cd(path): + """ + Temporarily change the working directory. + + Parameters: + path (str): Target directory. + + Yields: + None + """ + oldpath = os.getcwd() + try: + os.chdir(path) + yield None + finally: + os.chdir(oldpath) + + +@contextlib.contextmanager +def just_stdout(): + try: + yield sys.stdout + finally: + pass + + +@contextlib.contextmanager +def capture_stdio(): + """ + Capture standard output and error. + + Yields: + io.StringIO: The captured stdout buffer. + """ + with redirect(None, 'stdout') as (out, err): + try: + yield out + finally: + out.seek(0) + err.seek(0) + + +@contextlib.contextmanager +def capture_asserts(): + """ + Capture AssertionErrors. + + Yields: + list: A list of captured AssertionErrors. + """ + errors = [] + try: + yield errors + except AssertionError as e: + errors.append(e) + finally: + pass + + +def optional_imports(): + """ + Suppress ImportError. + + Returns: + contextlib.suppress(ImportError) + """ + return contextlib.suppress(ImportError) + + +@contextlib.contextmanager +def modloaded(pkg): + try: + if pkg in sys.modules: + yield sys.modules[pkg] + else: + yield None + finally: + pass + + +@contextlib.contextmanager +def cd_project_root(): + """ + Change to the project root directory. + + Yields: + bool: True if the project root exists, False otherwise. + """ + if root := evn.projroot: + with cd(root): + yield True + else: + yield False + + +@contextlib.contextmanager +def np_printopts(**kw): + np = evn.maybeimport('numpy') + if not np: + yield None + return + npopt = np.get_printoptions() + try: + np.set_printoptions(**kw) + yield None + finally: + np.set_printoptions(**{k: npopt[k] for k in kw}) + + +def np_compact(precision=4, suppress=True, **kw): + return np_printopts(precision=precision, suppress=suppress, **kw) diff --git a/lib/evn/evn/dev/copy.py b/lib/evn/evn/dev/copy.py new file mode 100644 index 00000000..6dd3054d --- /dev/null +++ b/lib/evn/evn/dev/copy.py @@ -0,0 +1,12 @@ +import copy + + +def shallow_copy(obj): + origcopy = getattr(obj.__class__, '__copy__', None) + try: + if hasattr(obj.__class__, '__copy__'): + delattr(obj.__class__, '__copy__') + return copy.copy(obj) + finally: + if origcopy: + setattr(obj.__class__, '__copy__', origcopy) diff --git a/lib/evn/evn/doc/__init__.py b/lib/evn/evn/doc/__init__.py new file mode 100644 index 00000000..a752f0cb --- /dev/null +++ b/lib/evn/evn/doc/__init__.py @@ -0,0 +1 @@ +from evn.doc.docstring import * diff --git a/lib/evn/evn/doc/docstring.py b/lib/evn/evn/doc/docstring.py new file mode 100644 index 00000000..b144f259 --- /dev/null +++ b/lib/evn/evn/doc/docstring.py @@ -0,0 +1,23 @@ +import re + + +def extract_param_help(docstring: str) -> dict[str, str]: + """ + Extract parameter descriptions from a Sphinx-style docstring. + + :param docstring: The docstring to parse. + :type docstring: str + :return: Mapping from parameter name to help text. + :rtype: dict[str, str] + """ + if not docstring: + return {} + + param_help = {} + pattern = re.compile(r'^\s*:param (\w+)\s*:\s*(.+)$', re.MULTILINE) + + for match in pattern.finditer(docstring): + name, desc = match.groups() + param_help[name] = desc.strip() + + return param_help diff --git a/lib/evn/evn/format/.ninja_log b/lib/evn/evn/format/.ninja_log new file mode 100644 index 00000000..5bdbde1e --- /dev/null +++ b/lib/evn/evn/format/.ninja_log @@ -0,0 +1 @@ +# ninja log v6 diff --git a/lib/evn/evn/format/__init__.py b/lib/evn/evn/format/__init__.py new file mode 100644 index 00000000..17937967 --- /dev/null +++ b/lib/evn/evn/format/__init__.py @@ -0,0 +1,24 @@ +import os +import sys +import evn + +with evn.cd_project_root() as project_exists: + using_local_build = False + if project_exists and os.path.exists('_build'): + assert os.path.exists('pyproject.toml') + os.system('doit build') + try: + sys.path.append(f'_build/py3{sys.version_info.minor}') + print(f'_build/py3{sys.version_info.minor}') + from _detect_formatted_blocks import * # type: ignore + from _token_column_format import * # type: ignore + using_local_build = True + except ImportError: + pass + finally: + sys.path.pop(0) # Remove the build path so it doesn't interfere with import + if not using_local_build: + from evn.format._detect_formatted_blocks import * # type: ignore + from evn.format._token_column_format import * # type: ignore + +from evn.format.formatter import * diff --git a/lib/evn/evn/format/_common.hpp b/lib/evn/evn/format/_common.hpp new file mode 100644 index 00000000..f1b68b1b --- /dev/null +++ b/lib/evn/evn/format/_common.hpp @@ -0,0 +1,374 @@ +// format_identifier.cpp +#include +#include +#include +#include +#include +#include +// #include +#include +#include +#include +#include +#include +#include +#include + +namespace py = pybind11; +using namespace std; +bool debug = false; + +enum class TokenType { + Identifier, + String, + Numeric, + Exact // Keywords, punctuation, comments, etc. +}; +// Get indentation level of a line +string get_indentation(string const &line) { + auto nonWhitespace = line.find_first_not_of(" \t"); + if (nonWhitespace == string::npos) { return ""; } + return line.substr(0, nonWhitespace); +} + +bool is_whitespace(const std::string &str) { + return str.empty() || std::all_of(str.begin(), str.end(), + [](unsigned char c) { return std::isspace(c); }); +} + +// Returns the index of the first non-whitespace character from the end of the +// string or std::string::npos if the string contains only whitespace. +size_t find_last_non_whitespace(const std::string &str) { + for (std::size_t i = str.size(); i > 0; --i) { + if (!std::isspace(static_cast(str[i - 1]))) { return i - 1; } + } + return std::string::npos; +} + +bool is_multiline(string const &line) { + size_t i = find_last_non_whitespace(line); + if (i == string::npos) return false; // Empty line + return line[i] == '\\'; +} + +bool is_opener(const string &token) { + return token == "(" || token == "[" || token == "{"; +} + +bool is_closer(const string &token) { + return token == ")" || token == "]" || token == "}"; +} + +bool is_operator(const string &token) { + static const unordered_set operators = { + "+", "-", "*", "/", "%", "**", "//", "==", "!=", "<", ">", "<=", ">=", + "=", "->", "+=", "-=", "*=", "/=", "%=", "&", "|", "^", ">>", "<<", "~"}; + return operators.find(token) != operators.end(); +} + +bool is_keyword(const string &token) { + static const unordered_set python_keywords = { + "False", "None", "True", "and", "as", "assert", "async", + "await", "break", "class", "continue", "def", "del", "elif", + "else", "except", "finally", "for", "from", "global", "if", + "import", "in", "is", "lambda", "nonlocal", "not", "or", + "pass", "raise", "return", "try", "while", "with", "yield"}; + return python_keywords.find(token) != python_keywords.end(); +} + +string rstrip(const string &str) { + string trimmed_str = str; + auto it = find_if(trimmed_str.rbegin(), trimmed_str.rend(), + [](unsigned char ch) { return !isspace(ch); }); + trimmed_str.erase(it.base(), trimmed_str.end()); + return trimmed_str; +} + +// Helper functions for token type checking. +bool is_string_literal(const string &token) { + if (token.empty()) return false; + if (token.at(0) == '\'' || token.at(0) == '"') return true; + if (token.size() >= 2 && (token.at(0) == 'f' || token.at(0) == 'F') && + (token.at(1) == '\'' || token.at(1) == '"')) + return true; + return false; +} + +bool is_identifier(const string &token) { + if (token.empty()) return false; + if (!isalpha(static_cast(token.at(0))) && token.at(0) != '_') + return false; + for (size_t i = 1; i < token.size(); i++) { + if (!isalnum(static_cast(token.at(i))) && token.at(i) != '_') + return false; + } + return true; +} +TokenType get_token_type(const string &token) { + if (is_string_literal(token)) return TokenType::String; + if (is_identifier(token)) { + if (is_keyword(token)) return TokenType::Exact; + return TokenType::Identifier; + } + if (!token.empty() && isdigit(static_cast(token.at(0)))) + return TokenType::Numeric; + return TokenType::Exact; +} + +bool is_identifier_or_literal(const string &token) { + TokenType t = get_token_type(token); + return (t == TokenType::Identifier || t == TokenType::String || + t == TokenType::Numeric); +} + +bool is_oneline_statement_string(string const &line) { + if (line.empty()) { return false; } + string trimmed = line; + size_t firstNonSpace = trimmed.find_first_not_of(" \t"); + if (firstNonSpace == string::npos) return false; // Empty line + trimmed = trimmed.substr(firstNonSpace); + if (trimmed[0] == '#') { return false; } + const vector keywords = {"if ", "elif ", "else:", "for ", + "while ", "def ", "class ", "with "}; + bool foundKeyword = false; + string keywordFound; + for (const auto &keyword : keywords) { + if (trimmed.compare(0, keyword.length(), keyword) == 0) { + foundKeyword = true; + keywordFound = keyword; + break; + } + } + if (!foundKeyword) return false; + + // Now we need to find the colon that ends the statement header + size_t colonPos = 0; + bool inString = false; + char stringDelimiter = 0; + bool escaped = false; + int parenLevel = 0; + + // For else:, we already know the colon position + if (keywordFound == "else:") { + colonPos = firstNonSpace + 4; // "else" length + } else { + // For other keywords, we need to find the colon + for (size_t i = 0; i < trimmed.length(); i++) { + char c = trimmed[i]; + + // Handle string delimiters + if ((c == '"' || c == '\'') && !escaped) { + if (!inString) { + inString = true; + stringDelimiter = c; + } else if (c == stringDelimiter) { + inString = false; + } + } + + // Handle escaping + if (c == '\\' && !escaped) { + escaped = true; + continue; + } else { + escaped = false; + } + + // Track parentheses level (ignore if in string) + if (!inString) { + if (c == '(' || c == '[' || c == '{') { + parenLevel++; + } else if (c == ')' || c == ']' || c == '}') { + parenLevel--; + } else if (c == ':' && parenLevel == 0) { + colonPos = firstNonSpace + i; + break; + } + } + } + } + + // If we couldn't find a proper colon, it's not a valid statement + if (colonPos == 0 || colonPos >= line.length() - 1) { return false; } + + // Now check if there's an action after the colon + string afterColon = line.substr(colonPos + 1); + size_t actionStart = afterColon.find_first_not_of(" \t"); + + // If there's nothing after the colon or just a comment, it's not an inline + // action + if (actionStart == string::npos || afterColon[actionStart] == '#') { return false; } + + return true; +} + +bool is_oneline_statement(vector const &tokens) { + if (tokens.empty()) return false; + static const vector keywords = {"if", "elif", "else", "for", + "while", "def", "class", "with"}; + if (find(keywords.begin(), keywords.end(), tokens[0]) == keywords.end()) return false; + for (int i = 1; i < tokens.size(); ++i) + if (tokens[i] == ":") { + if (i == tokens.size() - 1) return false; + if (tokens[i + 1][0] == '#') return false; + return true; + } + return false; +} + +// Delimiter helper: returns the delimiter to insert before the current +// token. +string delimiter(size_t prev_index, size_t curr_index, const vector &tokens, + bool in_param_context, int depth) { + const string &prev = tokens.at(prev_index); + const string &next = tokens.at(curr_index); + if (in_param_context && (prev == "=" || next == "=")) return ""; + if (is_operator(prev) || is_operator(next)) { + if (depth > 1 && (prev == "+" || prev == "-" || next == "+" || next == "-")) + return ""; + return " "; + } + if (is_opener(prev)) return ""; + if (is_closer(next)) return ""; + if (next == "," || next == ":" || next == ";") return ""; + if (next == "(" && is_identifier_or_literal(prev) && !is_keyword(prev)) return ""; + return " "; +} + +// Parses a string literal from the given line starting at index i. +string parse_string_literal(const string &line, size_t &i, bool is_f_string) { + size_t start = i; + if (is_f_string) ++i; // skip the 'f' or 'F' + if (i >= line.size()) throw out_of_range("String literal start index out of range"); + char quote = line.at(i); + bool triple = false; + if (i + 2 < line.size() && line.at(i) == line.at(i + 1) && + line.at(i) == line.at(i + 2)) { + triple = true; + i += 3; + } else { + ++i; + } + while (i < line.size()) { + if (line.at(i) == '\\') { + i += 2; + } else if (triple) { + if (i + 2 < line.size() && line.at(i) == quote && line.at(i + 1) == quote && + line.at(i + 2) == quote) { + i += 3; + break; + } else { + ++i; + } + } else { + if (line.at(i) == quote) { + ++i; + break; + } else { + ++i; + } + } + } + return line.substr(start, i - start); +} + +// Tokenizes a single line of Python code. +vector tokenize(const string &line) { + vector tokens; + size_t i = 0; + while (i < line.size()) { + // Skip whitespace. + if (isspace(static_cast(line.at(i)))) { + ++i; + continue; + } + // Handle comments: rest of the line is one token. + if (line.at(i) == '#') { + tokens.push_back(line.substr(i)); + break; + } + // Check for an f-string literal. + if ((line.at(i) == 'f' || line.at(i) == 'F') && (i + 1 < line.size()) && + (line.at(i + 1) == '\'' || line.at(i + 1) == '"')) { + tokens.push_back(parse_string_literal(line, i, true)); + continue; + } + // Check for a normal string literal. + if (line.at(i) == '\'' || line.at(i) == '"') { + tokens.push_back(parse_string_literal(line, i, false)); + continue; + } + // Check for an identifier or keyword. + if (isalpha(static_cast(line.at(i))) || line.at(i) == '_') { + size_t start = i; + while (i < line.size() && (isalnum(static_cast(line.at(i))) || + line.at(i) == '_')) { + ++i; + } + tokens.push_back(line.substr(start, i - start)); + continue; + } + // Handle numeric literals in a basic way. + if (isdigit(static_cast(line.at(i)))) { + size_t start = i; + while (i < line.size() && + (isdigit(static_cast(line.at(i))) || + line.at(i) == '.' || line.at(i) == 'e' || line.at(i) == 'E' || + line.at(i) == '+' || line.at(i) == '-')) { + ++i; + } + tokens.push_back(line.substr(start, i - start)); + continue; + } + // Check for multi-character punctuation/operators. + bool multi_matched = false; + static const vector multi_tokens = {"...", "==", "!=", "<=", ">=", "//", + "**", "->", "+=", "-=", "*=", "/=", + "%=", "&=", "|=", "^=", ">>", "<<"}; + for (const auto &tok : multi_tokens) { + if (line.compare(i, tok.size(), tok) == 0) { + tokens.push_back(tok); + i += tok.size(); + multi_matched = true; + break; + } + } + if (multi_matched) continue; + // Single-character punctuation. + try { + tokens.push_back(string(1, line.at(i))); + } catch (const out_of_range &e) { + throw runtime_error("Index error in tokenize at position " + to_string(i)); + } + ++i; + } + return tokens; +} + +// Returns a token pattern for grouping. +vector get_token_pattern(const vector &tokens) { + vector pattern; + for (const auto &tok : tokens) { + if (is_string_literal(tok)) + pattern.push_back("STR"); + else if (is_identifier(tok) && !is_keyword(tok)) + pattern.push_back("ID"); + else if (!tok.empty() && isdigit(static_cast(tok.at(0)))) + pattern.push_back("NUM"); + else + pattern.push_back(tok); + } + return pattern; +} + +// Compares two token vectors using wildcard rules. +bool tokens_match(const vector &tokens1, const vector &tokens2) { + if (tokens1.size() != tokens2.size()) return false; + for (size_t i = 0; i < tokens1.size(); i++) { + TokenType type1 = get_token_type(tokens1.at(i)); + TokenType type2 = get_token_type(tokens2.at(i)); + if (type1 != type2) return false; + if (type1 == TokenType::Exact && tokens1.at(i) != tokens2.at(i)) return false; + } + return true; +} diff --git a/lib/evn/evn/format/_detect_formatted_blocks.cpp b/lib/evn/evn/format/_detect_formatted_blocks.cpp new file mode 100644 index 00000000..6544873d --- /dev/null +++ b/lib/evn/evn/format/_detect_formatted_blocks.cpp @@ -0,0 +1,352 @@ +#include "_common.hpp" + +// Character group indices for substitution matrix +enum CharGroup { + UPPERCASE = 0, + LOWERCASE = 1, + DIGIT = 2, + WHITESPACE = 3, + // All Python punctuation characters as separate groups + PAREN_OPEN = 4, // ( + PAREN_CLOSE = 5, // ) + BRACKET_OPEN = 6, // [ + BRACKET_CLOSE = 7, // ] + BRACE_OPEN = 8, // { + BRACE_CLOSE = 9, // } + DOT = 10, // . + COMMA = 11, // , + COLON = 12, // : + SEMICOLON = 13, // ; + PLUS = 14, // + + MINUS = 15, // - + ASTERISK = 16, // * + SLASH = 17, // / + BACKSLASH = 18, // + VERTICAL_BAR = 19, // | + AMPERSAND = 20, // & + LESS_THAN = 21, // < + GREATER_THAN = 22, // > + EQUAL = 23, // = + PERCENT = 24, // % + HASH = 25, // # + AT_SIGN = 26, // @ + EXCLAMATION = 27, // ! + QUESTION = 28, // ? + CARET = 29, // ^ + TILDE = 30, // ~ + BACKTICK = 31, // ` + QUOTE_SINGLE = 32, // ' + QUOTE_DOUBLE = 33, // " + UNDERSCORE = 34, // _ + DOLLAR = 35, // $ + OTHER = 36, // Other characters + NUM_GROUPS +}; + +// Get character group for substitution matrix +CharGroup get_char_group(char c) { + if (isupper(c)) return UPPERCASE; + if (islower(c)) return LOWERCASE; + if (isdigit(c)) return DIGIT; + if (isspace(c)) return WHITESPACE; + + // Check for specific punctuation + switch (c) { + case '(': return PAREN_OPEN; + case ')': return PAREN_CLOSE; + case '[': return BRACKET_OPEN; + case ']': return BRACKET_CLOSE; + case '{': return BRACE_OPEN; + case '}': return BRACE_CLOSE; + case '.': return DOT; + case ',': return COMMA; + case ':': return COLON; + case ';': return SEMICOLON; + case '+': return PLUS; + case '-': return MINUS; + case '*': return ASTERISK; + case '/': return SLASH; + case '\\': return BACKSLASH; + case '|': return VERTICAL_BAR; + case '&': return AMPERSAND; + case '<': return LESS_THAN; + case '>': return GREATER_THAN; + case '=': return EQUAL; + case '%': return PERCENT; + case '#': return HASH; + case '@': return AT_SIGN; + case '!': return EXCLAMATION; + case '?': return QUESTION; + case '^': return CARET; + case '~': return TILDE; + case '`': return BACKTICK; + case '\'': return QUOTE_SINGLE; + case '"': return QUOTE_DOUBLE; + case '_': return UNDERSCORE; + case '$': return DOLLAR; + default: return OTHER; + } +} + +// Default substitution matrix (higher score = more similar) +array, NUM_GROUPS> create_default_submatrix() { + array, NUM_GROUPS> matrix{}; + + // Initialize with zeroes + for (int i = 0; i < NUM_GROUPS; i++) { + for (int j = 0; j < NUM_GROUPS; j++) { matrix[i][j] = 0.0f; } + } + + // Exact matches get 1.0 + for (int i = 0; i < NUM_GROUPS; i++) matrix[i][i] = 1.0f; + + const vector keyGroups = {EQUAL, COLON, COMMA, BRACKET_OPEN, PAREN_OPEN, + PLUS, MINUS, ASTERISK, SLASH, UPPERCASE}; + + for (const auto &group : keyGroups) matrix[group][group] = 5.0; + matrix[EQUAL][EQUAL] = 10.0; + + // Letter case transitions get 0.9 + matrix[UPPERCASE][LOWERCASE] = 0.3f; + matrix[LOWERCASE][UPPERCASE] = 0.3f; + + // Letters to digits get 0.5 + matrix[UPPERCASE][DIGIT] = 0.2f; + matrix[LOWERCASE][DIGIT] = 0.2f; + matrix[DIGIT][UPPERCASE] = 0.2f; + matrix[DIGIT][LOWERCASE] = 0.2f; + + // Brackets/parentheses/braces are somewhat similar (0.3) + matrix[PAREN_OPEN][BRACKET_OPEN] = 0.3f; + matrix[PAREN_OPEN][BRACE_OPEN] = 0.3f; + matrix[BRACKET_OPEN][PAREN_OPEN] = 0.3f; + matrix[BRACKET_OPEN][BRACE_OPEN] = 0.3f; + matrix[BRACE_OPEN][PAREN_OPEN] = 0.3f; + matrix[BRACE_OPEN][BRACKET_OPEN] = 0.3f; + + matrix[PAREN_CLOSE][BRACKET_CLOSE] = 0.3f; + matrix[PAREN_CLOSE][BRACE_CLOSE] = 0.3f; + matrix[BRACKET_CLOSE][PAREN_CLOSE] = 0.3f; + matrix[BRACKET_CLOSE][BRACE_CLOSE] = 0.3f; + matrix[BRACE_CLOSE][PAREN_CLOSE] = 0.3f; + matrix[BRACE_CLOSE][BRACKET_CLOSE] = 0.3f; + + // Operators have some similarity (0.4) + matrix[PLUS][MINUS] = 0.4f; + matrix[MINUS][PLUS] = 0.4f; + matrix[ASTERISK][SLASH] = 0.4f; + matrix[SLASH][ASTERISK] = 0.4f; + matrix[LESS_THAN][GREATER_THAN] = 0.4f; + matrix[GREATER_THAN][LESS_THAN] = 0.4f; + + // Quotes have similarity + // matrix[QUOTE_SINGLE][QUOTE_DOUBLE] = 0.7f; + // matrix[QUOTE_DOUBLE][QUOTE_SINGLE] = 0.7f; + + return matrix; +} + +class IdentifyFormattedBlocks { + public: + array, NUM_GROUPS> sub_matrix; + bool in_formatted_block = false; + vector lines, output; + vector scores; + size_t consecutive_high_scores = 0; + float threshold = 5.0f; + + IdentifyFormattedBlocks(float threshold = 5.0f) : threshold(threshold) { + sub_matrix = create_default_submatrix(); + } + + void set_substitution_matrix(CharGroup i, CharGroup j, float val) { + sub_matrix[i][j] = val; + } + + // Compute similarity score between two lines + float compute_similarity_score(string const &line1, string const &line2) { + if (debug) cerr << "compute_similarity_score " << line1 << " " << line2 << endl; + if (line1.empty() || line2.empty()) return 0.0f; + size_t indent1 = line1.find_first_not_of(" \t"); + size_t indent2 = line2.find_first_not_of(" \t"); + if (indent1 != indent2) return 0.0f; + float alignmentScore = 0.0f; + size_t len1 = line1.size(); + size_t len2 = line2.size(); + + // Score character by character for alignment + for (size_t i = 0; i < min(len1, len2); i++) { + if (isalnum(static_cast(line1[i])) && + isalnum(static_cast(line2[i])) && line1[i] != line2[i]) + continue; + CharGroup g1 = get_char_group(line1[i]); + CharGroup g2 = get_char_group(line2[i]); + if (debug) cerr << i << " g1 " << g1 << " g2 " << g2 << endl; + alignmentScore += sub_matrix[g1][g2]; + } + if (debug) cerr << "adject for len" << endl; + float maxlen = static_cast(max(line1.size(), line2.size())); + alignmentScore = alignmentScore / sqrt(maxlen); + float lengthPenalty = + 1.0f - (abs(static_cast(len1) - static_cast(len2)) / + static_cast(max(len1, len2))); + if (debug) + cerr << "alignmentScore " << alignmentScore << " lengthPenalty " + << lengthPenalty << endl; + return 0.7f * alignmentScore + 0.3f * lengthPenalty; + } + + string unmark(string const &code) { + start_new_code(code); + if (lines.empty()) return code; + + for (string const &line : lines) { + if (line.find("# fmt:") != string::npos) continue; + if (is_whitespace(line) && output.size() && is_whitespace(output.back())) + continue; + output.push_back(line); + } + ostringstream result; + for (string const &line : output) { result << line << endl; } + return result.str(); + } + + void start_new_code(string const &code) { + lines.clear(); + output.clear(); + scores.clear(); + istringstream stream(code); + string line; + while (getline(stream, line)) lines.push_back(line); + in_formatted_block = false; + } + string finish_code() { + ostringstream result; + for (const string &line : output) { result << line << endl; } + return result.str(); + } + + // Process code to identify and mark well-formatted blocks + string mark_formtted_blocks(string const &code, float thresh = 0) { + start_new_code(code); + if (thresh > 0) threshold = thresh; + if (lines.empty()) return code; + output.push_back(lines[0]); + + consecutive_high_scores = 0; + for (size_t i = 1; i < lines.size(); i++) { + if (is_multiline(lines[i - 1]) || is_multiline(lines[i])) { + if (debug) cerr << "multiline " << lines[i] << endl; + maybe_close_formatted_block(); + output.push_back(lines[i]); + continue; + } + string i_indent = get_indentation(lines[i]); + if (!in_formatted_block && is_oneline_statement_string(lines[i])) { + if (debug) cerr << "oneline " << lines[i] << endl; + maybe_close_formatted_block(); + // cout << "single " << lines[i] << endl; + output.push_back(i_indent + "# fmt: off"); + output.push_back(lines[i]); + output.push_back(i_indent + "# fmt: on"); + continue; + } + scores.push_back(compute_similarity_score(lines[i - 1], lines[i])); + if (scores.back() >= threshold) { + if (debug) cerr << "block " << scores.back() << " " << lines[i] << endl; + consecutive_high_scores++; + if (consecutive_high_scores >= 1 && !in_formatted_block) { + in_formatted_block = true; + string tmp = output.back(); + output.back() = i_indent + "# fmt: off"; + output.push_back(tmp); + output.push_back(lines[i]); + continue; + } + } else { + maybe_close_formatted_block(); + } + output.push_back(lines[i]); + } + maybe_close_formatted_block(true); + return finish_code(); + } + void maybe_close_formatted_block(bool at_end = false) { + if (!in_formatted_block) return; + if (debug) cerr << "maybe close block" << endl; + consecutive_high_scores = 0; + in_formatted_block = false; + string indent = "!!"; + assert(output.size()); + for (size_t i = output.size() - 1; i > 0; --i) { + if (output[i].find("# fmt:") == string::npos) { + indent = get_indentation(output[i]); + break; + } + } + output.push_back(indent + "# fmt: on"); + if (debug) cerr << "block closed" << endl; + } +}; + +PYBIND11_MODULE(_detect_formatted_blocks, m) { + m.doc() = "Identifies and marks well-formatted code blocks with fmt: off/on " + "markers"; + + py::class_(m, "IdentifyFormattedBlocks") + .def(py::init<>(), "Default constructor which initializes the " + "substitution matrix.") + .def("set_substitution_matrix", &IdentifyFormattedBlocks::set_substitution_matrix, + py::arg("i"), py::arg("j"), py::arg("val"), + "Set a value in the substitution matrix at indices (i, j).") + .def("compute_similarity_score", + &IdentifyFormattedBlocks::compute_similarity_score, py::arg("line1"), + py::arg("line2"), "Compute similarity score between two lines") + .def("mark_formtted_blocks", &IdentifyFormattedBlocks::mark_formtted_blocks, + py::arg("code"), py::arg("threshold") = 0.7f, + "Process the input code and mark formatted blocks based on a " + "similarity threshold.") + .def("unmark", &IdentifyFormattedBlocks::unmark, py::arg("code"), + "remove marks."); + + py::enum_(m, "CharGroup") + .value("UPPERCASE", UPPERCASE) + .value("LOWERCASE", LOWERCASE) + .value("DIGIT", DIGIT) + .value("WHITESPACE", WHITESPACE) + .value("PAREN_OPEN", PAREN_OPEN) + .value("PAREN_CLOSE", PAREN_CLOSE) + .value("BRACKET_OPEN", BRACKET_OPEN) + .value("BRACKET_CLOSE", BRACKET_CLOSE) + .value("BRACE_OPEN", BRACE_OPEN) + .value("BRACE_CLOSE", BRACE_CLOSE) + .value("DOT", DOT) + .value("COMMA", COMMA) + .value("COLON", COLON) + .value("SEMICOLON", SEMICOLON) + .value("PLUS", PLUS) + .value("MINUS", MINUS) + .value("ASTERISK", ASTERISK) + .value("SLASH", SLASH) + .value("BACKSLASH", BACKSLASH) + .value("VERTICAL_BAR", VERTICAL_BAR) + .value("AMPERSAND", AMPERSAND) + .value("LESS_THAN", LESS_THAN) + .value("GREATER_THAN", GREATER_THAN) + .value("EQUAL", EQUAL) + .value("PERCENT", PERCENT) + .value("HASH", HASH) + .value("AT_SIGN", AT_SIGN) + .value("EXCLAMATION", EXCLAMATION) + .value("QUESTION", QUESTION) + .value("CARET", CARET) + .value("TILDE", TILDE) + .value("BACKTICK", BACKTICK) + .value("QUOTE_SINGLE", QUOTE_SINGLE) + .value("QUOTE_DOUBLE", QUOTE_DOUBLE) + .value("UNDERSCORE", UNDERSCORE) + .value("DOLLAR", DOLLAR) + .value("OTHER", OTHER) + .value("NUM_GROUPS", NUM_GROUPS) + .export_values(); // Export values to the module scope +} diff --git a/lib/evn/evn/format/_token_column_format.cpp b/lib/evn/evn/format/_token_column_format.cpp new file mode 100644 index 00000000..442a310e --- /dev/null +++ b/lib/evn/evn/format/_token_column_format.cpp @@ -0,0 +1,235 @@ +#include "_common.hpp" + +// Helper struct to store perโ€“line data. +struct LineInfo { + int lineno; // Line number. + string line; // Original line. + string indent; // Leading whitespace. + string content; // Line without indent. + vector tokens; // Tokenized content. + vector pattern; // Token pattern (wildcards) +}; + +class PythonLineTokenizer { + public: + // Reformat the given code buffer (as a string) into a new string. + // Each line is processed, and consecutive lines that share the same + // token pattern (by wildcard) and the same indentation are grouped and + // aligned. If add_fmt_tag is true, formatting tags are added. + string reformat_buffer(const string &code, bool add_fmt_tag = false, + bool debug = false) { + istringstream stream(code); + string line; + vector lines; + while (getline(stream, line)) lines.push_back(line); + vector output = reformat_lines(lines, add_fmt_tag, debug); + ostringstream result; + for (const auto &outline : output) result << outline << "\n"; + return result.str(); + } + + // Process a vector of lines. + vector reformat_lines(const vector &lines, bool add_fmt_tag = false, + bool debug = false) { + vector infos = line_info(lines); + vector output; + vector block; + const size_t length_threshold = 10; + for (const auto &info : infos) { + if (debug) cout << "reformat " << info.lineno << info.line << endl; + // Blank lines are output as-is. + if (info.content.empty()) { + flush_block(block, output); + output.push_back(rstrip(info.line)); + continue; + } + if (block.empty()) { + block.push_back(info); + } else { + // Group lines if indent and token pattern match, and if lengths + // are similar. + try { + if (info.indent != block.at(0).indent || + abs(static_cast(info.line.size()) - + static_cast(block.at(0).line.size())) > + length_threshold || + info.pattern != block.at(0).pattern) { + flush_block(block, output, add_fmt_tag, debug); + } + } catch (const out_of_range &e) { + throw runtime_error("Error grouping lines: " + string(e.what())); + } + block.push_back(info); + } + } + flush_block(block, output, add_fmt_tag, debug); + return output; + } + + // Formats tokens by computing a delimiter for each token (except the + // first). (This implementation is largely unchanged; error checking can be + // added as needed.) + vector format_tokens(const vector &tokens) { + vector formatted; + if (tokens.empty()) return formatted; + formatted.resize(tokens.size()); + formatted.at(0) = tokens.at(0); // first token: no preceding delimiter + + bool in_param_context = false; + bool is_def = (tokens.at(0) == "def"); + bool is_lambda = (tokens.at(0) == "lambda"); + if (is_def) { + in_param_context = false; + } else if (is_lambda) { + in_param_context = true; + } + + int depth = 0; + for (size_t i = 1; i < tokens.size(); i++) { + string prev = tokens.at(i - 1); + if (prev == "(") { + depth++; + if (is_def) in_param_context = true; + } else if (prev == ")") { + depth--; + if (is_def && depth == 0) in_param_context = false; + } + if (is_lambda && tokens.at(i) == ":") { in_param_context = false; } + string delim = delimiter(i - 1, i, tokens, in_param_context, depth); + formatted.at(i) = delim + tokens.at(i); + } + return formatted; + } + + // Joins tokens into a single string. + // If skip_formatting is true, assumes tokens are already formatted. + string join_tokens(const vector &tokens, + const vector &widths = vector(), + const vector &justifications = vector(), + bool skip_formatting = false) { + vector formatted_tokens(tokens); + if (!skip_formatting) formatted_tokens = format_tokens(tokens); + if (!widths.empty() && widths.size() == formatted_tokens.size() && + !justifications.empty() && justifications.size() == formatted_tokens.size()) { + for (size_t i = 0; i < formatted_tokens.size(); i++) { + if (widths.at(i) > 0) { + int token_len = static_cast(formatted_tokens.at(i).size()); + int padding = static_cast(widths.at(i)) - token_len; + if (padding > 0) { + char just = justifications.at(i); + if (just == 'L' || just == 'l') { + formatted_tokens.at(i).append(padding, ' '); + } else if (just == 'R' || just == 'r') { + formatted_tokens.at(i).insert(0, padding, ' '); + } else if (just == 'C' || just == 'c') { + int pad_left = padding / 2; + int pad_right = padding - pad_left; + formatted_tokens.at(i).insert(0, pad_left, ' '); + formatted_tokens.at(i).append(pad_right, ' '); + } + } + } + } + } + string result; + for (const auto &tok : formatted_tokens) result += tok; + return rstrip(result); + } + + // Returns a vector of LineInfo for each line. + vector line_info(const vector &lines) { + vector infos; + for (int i = 0; i < lines.size(); i++) { + LineInfo info; + info.lineno = i; + info.line = lines[i]; + size_t pos = info.line.find_first_not_of(" \t"); + info.indent = (pos == string::npos) ? info.line : info.line.substr(0, pos); + info.content = (pos == string::npos) ? "" : info.line.substr(pos); + if (!info.content.empty()) { + info.tokens = tokenize(info.content); + info.pattern = get_token_pattern(info.tokens); + } + infos.push_back(info); + } + return infos; + } + + // Flushes a block of LineInfo objects into output. + void flush_block(vector &block, vector &output, + bool add_fmt_tag = false, bool debug = false) { + if (block.empty()) return; + if (block.size() == 1) { + LineInfo const &info = block.at(0); + if (is_oneline_statement(info.tokens)) { + output.push_back(info.indent + "# fmt: off"); + output.push_back(rstrip(info.line)); + output.push_back(info.indent + "# fmt: on"); + } else { + output.push_back(rstrip(info.line)); + } + } else { + vector> token_lines; + for (const auto &info : block) token_lines.push_back(info.tokens); + vector> formatted_lines; + for (auto &tokens : token_lines) + formatted_lines.push_back(format_tokens(tokens)); + size_t nTokens = 0; + for (auto &tokens : formatted_lines) nTokens = max(nTokens, tokens.size()); + vector max_width(nTokens, 0); + for (auto &tokens : formatted_lines) { + for (size_t j = 0; j < tokens.size(); j++) { + max_width.at(j) = + max(max_width.at(j), static_cast(tokens.at(j).size())); + } + } + vector justifications(nTokens, 'L'); + if (add_fmt_tag) + output.push_back(block.at(0).indent + "# fmt: off"); + for (auto &tokens : formatted_lines) { + string joined = join_tokens(tokens, max_width, justifications, true); + output.push_back(block.at(0).indent + joined); + } + if (add_fmt_tag) + output.push_back(block.at(0).indent + "# fmt: on"); + } + block.clear(); + } +}; + +PYBIND11_MODULE(_token_column_format, m) { + m.doc() = "A module that wraps PythonLineTokenizer using pybind11"; + py::class_(m, "PythonLineTokenizer") + .def(py::init<>()) + .def("format_tokens", &PythonLineTokenizer::format_tokens, + "Format tokens by prepending delimiters based on Black-like " + "spacing heuristics") + .def( + "join_tokens", + static_cast &, const vector &, const vector &, bool)>( + &PythonLineTokenizer::join_tokens), + py::arg("tokens"), py::arg("widths") = vector(), + py::arg("justifications") = vector(), + py::arg("skip_formatting") = false, + "Join tokens into a valid Python code line using Black-like " + "heuristics. If skip_formatting is true, assume tokens are already " + "formatted.") + .def("reformat_buffer", &PythonLineTokenizer::reformat_buffer, py::arg("code"), + py::arg("add_fmt_tag") = false, py::arg("debug") = false, + "Reformat a code buffer, grouping lines with matching token " + "patterns and indentation into blocks and aligning them into evn " + "columns.") + .def("reformat_lines", &PythonLineTokenizer::reformat_lines, py::arg("lines"), + py::arg("add_fmt_tag") = false, py::arg("debug") = false, + "Reformat a code buffer (given as a vector of lines) by grouping " + "lines with matching token patterns and indentation into blocks " + "and inorkeywords.begin(), keywords.end(), str: + """Retrieve the original code.""" + return self.buffers[filename]['original'] + + def get_formatted(self, filename: str) -> str: + """Retrieve the formatted code.""" + return self.buffers[filename]['formatted'] + + +@dataclass +class FormatStep(ABC): + """Abstract base class for formatting steps in the processing pipeline.""" + + formatter: Optional['CodeFormatter'] = None + + @abstractmethod + def apply_formatting(self, + code: str, + history: Optional[FormatHistory] = None) -> str: + """Apply a transformation to the given code buffer.""" + pass + + +@dataclass +class CodeFormatter: + """Formats Python files using a configurable pipeline of FormatStep actions.""" + + actions: list[FormatStep] + history: FormatHistory = field(default_factory=FormatHistory) + cpp_mark: IdentifyFormattedBlocks = field( + default_factory=IdentifyFormattedBlocks) + cpp_aln: PythonLineTokenizer = field(default_factory=PythonLineTokenizer) + + def __post_init__(self): + for action in self.actions: + action.formatter = self + + def run(self, + files: dict[str, str], + dryrun=False, + debug=False) -> FormatHistory: + """Process in-memory Python file contents and return formatted buffers.""" + + # Initialize history with original files + for filename, code in files.items(): + self.history.add(filename, code) + + # Process each file through the pipeline + for filename in self.history.buffers: + code = self.history.get_original(filename) + if debug: + print('*************************************') + if debug: + print(code, '\n************ orig ****************') + for action in self.actions: + if debug: + print(action.__class__.__name__, flush=True) + if dryrun: + print( + f'Dry run: {action.__class__.__name__} on {filename}') + else: + code = action.apply_formatting(code, self.history) + if debug: + print( + code, + f'\n************ {action.__class__.__name__} ****************' + ) + self.history.update(filename, code) + + return self.history + + +no_format_pattern = re.compile( + r'^(\s*)(class|def|for|if|elif|else)\s+?.*: [^#].*') + + +@dataclass +class MarkHandFormattedBlocksCpp(FormatStep): + """Adds `# fmt: off` / `# fmt: on` markers around "human-formatted" constructs""" + + def apply_formatting(self, + code: str, + history: Optional[FormatHistory] = None) -> str: + return self.formatter.cpp_mark.mark_formtted_blocks(code, 5) + + +@dataclass +class UnmarkCpp(FormatStep): + """Adds `# fmt: off` / `# fmt: on` markers around "human-formatted" constructs""" + + def apply_formatting(self, + code: str, + history: Optional[FormatHistory] = None) -> str: + return self.formatter.cpp_mark.unmark(code) + + +@dataclass +class AlignTokensCpp(FormatStep): + """Aligns on tokens in the code buffer.""" + + def apply_formatting(self, + code: str, + history: Optional[FormatHistory] = None) -> str: + return self.formatter.cpp_aln.reformat_buffer(code, add_fmt_tag=True) + + +@dataclass +class RuffFormat(FormatStep): + """Runs `ruff format` on the in-memory code buffer.""" + + def apply_formatting(self, + code: str, + history: Optional[FormatHistory] = None) -> str: + try: + cmd = (['ruff', '--config', evn.projconf, 'format', '-'], + ) # `-` tells ruff to read from stdin + process = subprocess.run(*cmd, + input=code, + text=True, + capture_output=True, + check=True) + return process.stdout + except subprocess.CalledProcessError as e: + print('Error running ruff format:', e.stderr) + print('Original code:\n', code, flush=True) + # return code # Return original if formatting fails + raise e from None + + +re_two_blank_lines = re.compile(r'\n\s*\n\s*\n') + + +@dataclass +class RemoveExtraBlankLines(FormatStep): + """Replaces multiple consecutive blank lines with a single blank line.""" + + def apply_formatting(self, code: str, history=None) -> str: + return re.sub(re_two_blank_lines, '\n\n', code).strip() + + +# def format_files(root_path: Path, dryrun: bool = False): +# """Reads files, runs CodeFormatter, and writes formatted content back.""" +# file_map = {} + +# # Read all .py files +# if root_path.is_file(): +# file_map[str(root_path)] = root_path.read_text(encoding="utf-8") +# else: +# for file in root_path.rglob("*.py"): +# file_map[str(file)] = file.read_text(encoding="utf-8") + +# # Format files +# formatter = CodeFormatter([ +# MarkHandFormattedBlocksCpp(), +# RuffFormat(), +# UnmarkCpp(), +# ]) +# formatted_history = formatter.run(file_map, dryrun=dryrun) + +# # Write back results +# for filename, history in formatted_history.buffers.items(): +# if history["original"] != history["formatted"]: +# Path(filename).write_text(history["formatted"], encoding="utf-8") +# print(f"Formatted: {filename}") + + +def format_buffer(buf, **kw): + formatter = CodeFormatter([ + # MarkHandFormattedBlocksCpp(), + AlignTokensCpp(), + RuffFormat(), + UnmarkCpp(), + ]) + formatted_history = formatter.run(dict(buffer=buf), **kw) + return formatted_history.buffers['buffer']['formatted'] diff --git a/lib/evn/evn/ident/__init__.py b/lib/evn/evn/ident/__init__.py new file mode 100644 index 00000000..6987d1cc --- /dev/null +++ b/lib/evn/evn/ident/__init__.py @@ -0,0 +1 @@ +from evn.ident.codes import * diff --git a/lib/evn/evn/ident/codes.py b/lib/evn/evn/ident/codes.py new file mode 100644 index 00000000..53f99264 --- /dev/null +++ b/lib/evn/evn/ident/codes.py @@ -0,0 +1,17 @@ +import hashlib + +alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + +def hash(obj, letters=3) -> str: + if isinstance(obj, str): s = obj + elif isinstance(obj, (int, float)): s = str(obj) + else: s = str(id(obj)) + h = hashlib.sha256(s.encode()).digest() + num = int.from_bytes(h[:2], 'big') # or use more bytes if needed + num = num % (26**letters) + chars = [] + for _ in range(letters): + chars.append(alphabet[num % 26]) + num //= 26 + + return ''.join(reversed(chars)) diff --git a/lib/evn/evn/meta/__init__.py b/lib/evn/evn/meta/__init__.py new file mode 100644 index 00000000..d710caba --- /dev/null +++ b/lib/evn/evn/meta/__init__.py @@ -0,0 +1,2 @@ +from evn.meta.inspect import * +from evn.meta.kwcall import * diff --git a/lib/evn/evn/meta/inspect.py b/lib/evn/evn/meta/inspect.py new file mode 100644 index 00000000..e9b74d7c --- /dev/null +++ b/lib/evn/evn/meta/inspect.py @@ -0,0 +1,62 @@ +import collections +from difflib import get_close_matches +import inspect +from typing import Optional +import types + + +def current_frame() -> types.FrameType: + frame = inspect.currentframe() + if frame is None: + raise ValueError('frame is None') + return frame + + +def frame_parent(frame: Optional[types.FrameType]) -> types.FrameType: + if frame is None: + raise ValueError('frame is None') + frame = frame.f_back + if frame is None: + raise ValueError('frame is None') + return frame + + +CallerInfo = collections.namedtuple('CallerInfo', 'filename lineno code') + + +def caller_info(excludefiles=None) -> CallerInfo: + excludefiles = excludefiles or [] + excludefiles.append(__file__) + frame: types.FrameType = current_frame() + assert frame is not None + if excludefiles: + while frame.f_code.co_filename in excludefiles: + frame = frame_parent(frame) + lines, no = inspect.getsourcelines(frame) + module = inspect.getmodule(frame) + code = 'unknown source code' + if module is not None: + code = lines[frame.f_lineno - no - 1].strip() + return CallerInfo(frame.f_code.co_filename, frame.f_lineno, code) + + +def find_close_argnames(word, string_list, n=3, cutoff=0.6): + """Find close matches to a given word from a list of strings. + + Args: + word (str): The word to find close matches for. + string_list (list of str): A list of strings to search within. + n (int): The maximum number of close matches to return. + cutoff (float): The minimum similarity score (0-1) for a string to be considered a match. + + Returns: + list: A list of close matches. + + Example: + >>> find_close_argnames('apple', ['apple', 'ape', 'apply', 'banana'], n=2) + ['apple', 'apply'] + """ + candidates = get_close_matches(word, string_list, n=n, cutoff=cutoff) + candidates = filter(lambda s: abs(len(s) - len(word)) < len(word) // 5, + candidates) + return list(candidates) diff --git a/lib/evn/evn/meta/kwargs_tunnel.py b/lib/evn/evn/meta/kwargs_tunnel.py new file mode 100644 index 00000000..31e67822 --- /dev/null +++ b/lib/evn/evn/meta/kwargs_tunnel.py @@ -0,0 +1,251 @@ +import inspect +from functools import wraps +from typing import Any, Callable, Dict, Optional, Set, TypeVar, get_type_hints, Union, ParamSpec + +# Type variables for generic typing +T = TypeVar('T') +P = ParamSpec('P') +R = TypeVar('R') + +# Type alias for keyword arguments dictionary +KW = Dict[str, Any] + + +def kwcheck(kw: KW, + func: Optional[Callable] = None, + checktypos: bool = True) -> KW: + """Filter keyword arguments to match those accepted by a function. + + Args: + kw: Dictionary of keyword arguments to filter + func: The function whose signature defines accepted parameters + checktypos: If True, raises TypeError for arguments that look like typos + + Returns: + Filtered dictionary containing only accepted keyword arguments + """ + if func is None: + return kw + + # Get the signature of the function + sig = inspect.signature(func) + params = sig.parameters + + # Check if the function accepts arbitrary keyword arguments + accepts_kwargs = any(p.kind == inspect.Parameter.VAR_KEYWORD + for p in params.values()) + + if accepts_kwargs: + # If the function accepts **kwargs, keep all arguments + return kw + + # Get the names of all keyword parameters + valid_params = { + name + for name, param in params.items() + if param.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY) + } + + # Filter the keyword arguments + filtered_kw = {k: v for k, v in kw.items() if k in valid_params} + + # Check for possible typos if enabled + if checktypos: + invalid_keys = set(kw.keys()) - valid_params + if invalid_keys: + # Check for possible typos (keys that look similar to valid params) + for invalid_key in invalid_keys: + close_matches = [ + valid for valid in valid_params + if _similar_strings(invalid_key, valid) + ] + if close_matches: + suggestions = ', '.join(f"'{match}'" + for match in close_matches) + raise TypeError( + f"'{invalid_key}' is not a valid parameter for {func.__name__}. " + f'Did you mean {suggestions}?') + + return filtered_kw + + +def _similar_strings(a: str, b: str, threshold: float = 0.8) -> bool: + """Check if two strings are similar using a simple metric. + + This is a basic implementation that could be replaced with more + sophisticated algorithms like Levenshtein distance. + """ + # Simple implementation - just check if one string is contained in the other + # or if they share many characters + if a in b or b in a: + return True + + # Count common characters + common = set(a) & set(b) + # Calculate similarity as ratio of common chars to average string length + similarity = len(common) / ((len(a) + len(b)) / 2) + + return similarity >= threshold + + +def kwcall(kw: KW, func: Callable[P, R], *args: Any, **kwargs: Any) -> R: + """Call a function with filtered keyword arguments. + + Args: + kw: Primary dictionary of keyword arguments + func: The function to call + *args: Positional arguments to pass to the function + **kwargs: Additional keyword arguments that will be merged with kw + + Returns: + The return value from calling func + """ + # Merge kwargs with kw, with kwargs taking precedence + merged_kwargs = {**kw, **kwargs} + # Filter to only accepted keyword arguments + filtered_kwargs = kwcheck(merged_kwargs, func) + # Call the function + return func(*args, **filtered_kwargs) + + +def kwargs_tunnel( + *, + allowed_keys: Optional[Set[str]] = None, + type_check: bool = True, + debug: bool = False) -> Callable[[Callable[P, R]], Callable[P, R]]: + """Decorator that enables kwargs tunneling for a function. + + This decorator makes a function automatically forward unused kwargs to any + callable parameters that accept **kwargs. + + Args: + allowed_keys: If provided, only these keys will be tunneled through + type_check: If True, validate type hints when tunneling kwargs + debug: If True, log information about tunneled kwargs + + Returns: + A decorator function + """ + + def decorator(func: Callable[P, R]) -> Callable[P, R]: + # Get function signature + sig = inspect.signature(func) + params = sig.parameters + + # Get parameter types if type checking is enabled + param_types = get_type_hints(func) if type_check else {} + + @wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> R: + # Make a copy of kwargs to avoid modifying the original + remaining_kwargs = kwargs.copy() + used_kwargs: Dict[str, Any] = {} + + # Filter kwargs for the wrapped function itself + for name, param in params.items(): + if (param.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY) + and name in remaining_kwargs): + # Check type if enabled + if type_check and name in param_types: + value = remaining_kwargs[name] + expected_type = param_types[name] + if not _is_type_compatible(value, expected_type): + raise TypeError( + f"Argument '{name}' has type {type(value).__name__}, " + f'but {func.__name__} expects {expected_type}') + + # Move the kwarg to used_kwargs + used_kwargs[name] = remaining_kwargs.pop(name) + elif param.kind == inspect.Parameter.VAR_KEYWORD: + # The function itself accepts **kwargs + # Add all remaining kwargs if allowed + if allowed_keys is not None: + # Only pass through allowed keys + for key in list(remaining_kwargs.keys()): + if key in allowed_keys: + used_kwargs[key] = remaining_kwargs.pop(key) + else: + # Pass all remaining kwargs + used_kwargs.update(remaining_kwargs) + remaining_kwargs.clear() + + # Call the function with filtered kwargs + result = func(*args, **used_kwargs) + + # Handle tunneling to callable results + if remaining_kwargs and callable(result): + # Check if the result accepts **kwargs + try: + result_sig = inspect.signature(result) + result_accepts_kwargs = any( + p.kind == inspect.Parameter.VAR_KEYWORD + for p in result_sig.parameters.values()) + + if result_accepts_kwargs: + # If we have kwargs to tunnel and result can accept them + if debug: + print( + f'Tunneling {len(remaining_kwargs)} kwargs to {result.__name__}' + ) + + # Filter and tunnel the kwargs to the callable result + tunneled_kwargs = kwcheck(remaining_kwargs, result) + return result(**tunneled_kwargs) + except (ValueError, TypeError): + # If we can't inspect the result signature, don't tunnel + pass + + return result + + # Add an attribute to indicate this function supports tunneling + wrapper._supports_kwargs_tunnel = True + + return wrapper + + return decorator + + +def _is_type_compatible(value: Any, expected_type: Any) -> bool: + """Check if a value is compatible with an expected type.""" + # Handle Union types + if hasattr(expected_type, + '__origin__') and expected_type.__origin__ is Union: + return any( + _is_type_compatible(value, arg) for arg in expected_type.__args__) + + # Handle Optional types (Union[Type, None]) + if (hasattr(expected_type, '__origin__') + and expected_type.__origin__ is Union + and type(None) in expected_type.__args__): + if value is None: + return True + other_types = [ + t for t in expected_type.__args__ if t is not type(None) + ] + return any(_is_type_compatible(value, t) for t in other_types) + + # Basic isinstance check + try: + return isinstance(value, expected_type) + except TypeError: + # Some types like List[int] will cause TypeError with isinstance + # In these cases, we just return True to avoid false positives + return True + + +# Example usage +def example(): + + @kwargs_tunnel(type_check=True) + def create_user(name: str, age: int, **extra): + print(f'Creating user: {name}, {age}') + if extra: + print(f'Extra info: {extra}') + + # Return a function that will receive tunneled kwargs + return lambda **kwargs: print(f'User details: {kwargs}') + + # Call with tunneled kwargs + create_user(name='Alice', age=30, email='alice@example.com', role='admin') diff --git a/lib/evn/evn/meta/kwcall.py b/lib/evn/evn/meta/kwcall.py new file mode 100644 index 00000000..a2139f3d --- /dev/null +++ b/lib/evn/evn/meta/kwcall.py @@ -0,0 +1,456 @@ +""" +Utility functions for working with Python callables, frames, and arguments. + +This module provides a collection of utility functions to simplify common Python tasks related to: +- Inspecting and manipulating function arguments. +- Reducing iterables using operators. +- Handling local variables in the call stack. +- Filtering namespaces. +- Working with pytest marks. + +Functions: +---------- +- `picklocals` โ€“ Access local variables from the caller's caller frame. +- `opreduce` โ€“ Reduce an iterable using a specified operator. +- `kwcall` โ€“ Call a function with filtered keyword arguments. +- `kwcheck` โ€“ Filter keyword arguments to match accepted function parameters. +- `filter_namespace_funcs` โ€“ Filter functions in a namespace by patterns. +- `param_is_required` โ€“ Check if a function parameter is required. +- `func_params` โ€“ Retrieve parameters of a function. +- `has_pytest_mark` โ€“ Check if an object has a specific pytest mark. +- `no_pytest_skip` โ€“ Check if an object does not have the `skip` pytest mark. + +Examples: +--------- +Example for `picklocals`: + >>> def example(): + ... x = [10, 20, 30] + ... print(evn.meta.picklocals('x')) # [10, 20, 30] + ... print(evn.meta.picklocals('x', 1)) # 20 + >>> example() + [10, 20, 30] + 20 + +Example for `opreduce`: + >>> from operator import add, mul + >>> opreduce(add, [1, 2, 3, 4]) + 10 + >>> opreduce('mul', [1, 2, 3, 4]) + 24 + +Example for `kwcall`: + >>> def example_function(x, y, z=3): + ... return x + y + z + >>> args = {'x': 1, 'y': 2, 'extra_arg': 'ignored'} + >>> kwcall(args, example_function) + 6 + >>> kwcall({'x': 1}, example_function, y=2, z=10) + 13 + +Example for `kwcheck`: + >>> def my_function(a, b, c=3): + ... pass + >>> kwargs = {'a': 1, 'b': 2, 'd': 4} + >>> filtered_kwargs = kwcheck(kwargs, my_function) + >>> filtered_kwargs + {'a': 1, 'b': 2} + +Example for `filter_namespace_funcs`: + >>> def test_func1(): + ... pass + >>> def test_func2(): + ... pass + >>> def helper_func(): + ... pass + >>> ns = dict(test_func1=test_func1, test_func2=test_func2, helper_func=helper_func) + >>> filtered_ns = filter_namespace_funcs(ns, prefix='test_', only=('test_func1',)) + >>> print(filtered_ns.keys()) + dict_keys(['helper_func', 'test_func1']) + +See Also: +--------- +- `inspect` โ€“ Pythonโ€™s standard library for introspecting live objects. +- `functools` โ€“ Higher-order functions and operations on callable objects. +- `operator` โ€“ Standard library for functional-style operators. +- `pytest.mark` โ€“ Markers for controlling pytest test execution. + +""" + +import dis +import re +import functools +import inspect +import operator + +import evn + +T, P, R, F = evn.basic_typevars('TPRF') + + +def instanceof(obj_or_types, types=None): + """wrapper so isinstane can be called with kwargs""" + if types: + return isinstance(obj_or_types, types) + return lambda obj: isinstance(obj, obj_or_types) + + +def picklocals(name, idx=None, asdict=False): + """Accesses a local variable from the caller's caller frame. + + This function retrieves the value of a local variable from the frame two levels up in the call stack. + If `idx` is provided, it will index into the value (if it's indexable). + + Args: + name (str): The name of the local variable to retrieve. + idx (int, optional): If provided, returns `val[idx]` instead of `val`. Defaults to None. + + Returns: + Any: The value of the local variable or the indexed value if `idx` is provided. + + Example: + >>> def example(): + ... x = [10, 20, 30] + ... print(evn.meta.picklocals('x')) # [10, 20, 30] + ... print(evn.meta.picklocals('x', 1)) # 20 + >>> example() + [10, 20, 30] + 20 + + """ + single = False + if isinstance(name, str): + name, single = name.split(), True + single &= len(name) == 1 + + result = {} + for n in name: + # if sys.version_info.minor < 12: + # val = inspect.currentframe().f_back.f_locals[n] # type: ignore + # else: + val = inspect.currentframe().f_back.f_locals[n] # type: ignore + result[n] = val if idx is None else val[idx] + if asdict: return result + result = list(result.values()) + return result[0] if single else result + + +def opreduce(op, iterable): + """Reduces an iterable using a specified operator or function. + + This function applies a binary operator or callable across the elements of an iterable, reducing it to a single value. + If `op` is a string, it will look up the corresponding operator in the `operator` module. + + Args: + op (str | callable): A callable or the name of an operator from the `operator` module (e.g., 'add', 'mul'). + iterable (iterable): The iterable to reduce. + + Returns: + Any: The reduced value. + + Raises: + AttributeError: If `op` is a string but not a valid operator in the `operator` module. + TypeError: If `op` is not a valid callable or operator. + + Example: + >>> from operator import add, mul + >>> print(opreduce(add, [1, 2, 3, 4])) # 10 + 10 + >>> print(opreduce('mul', [1, 2, 3, 4])) # 24 + 24 + >>> print(opreduce(lambda x, y: x * y, [1, 2, 3, 4])) # 24 + 24 + """ + if isinstance(op, str): + op = getattr(operator, op) + return functools.reduce(op, iterable) + + +for op in 'add mul matmul or_ and_'.split(): + opname = op.strip('_') + globals()[f'{opname}reduce'] = functools.partial(opreduce, + getattr(operator, op)) + + +def kwcall(kw: evn.KW, func: F, *a: P.args, **kwargs: P.kwargs) -> R: + """Call a function with filtered keyword arguments. + + This function merges provided keyword arguments, filters them to match only those + accepted by the target function using kwcheck, and then calls the function with + these filtered arguments. **kwargs take precedence over kw args. + + Args: + func (callable): The function to call. + kw (dict): Primary dictionary of keyword arguments. + *a: Positional arguments to pass to the function. + **kwargs: Additional keyword arguments that will be merged with kw. + + Returns: + Any: The return value from calling func. + + Examples: + >>> def example_function(x, y, z=3): + ... return x + y + z + >>> args = {'x': 1, 'y': 2, 'extra_arg': 'ignored'} + >>> kwcall(args, example_function) + 6 + >>> kwcall({'x': 1}, example_function, y=2, z=10) + 13 + + Note: + This function is useful for calling functions with dictionaries that may contain + extraneous keys not accepted by the target function. It combines the functionality + of dictionary merging and keyword argument filtering. + + See Also: + kwcheck: The underlying function used to filter keyword arguments. + """ + return func(*a, **kwcheck(kw | kwargs, func)) + + +def kwcheck(kw: evn.KW, func=None, checktypos=True) -> evn.KW: + """ + Filter keyword arguments to match only those accepted by the target function. + + This function examines a dictionary of keyword arguments and returns a new + dictionary containing only the keys that are valid parameter names for the + specified function. When no function is explicitly provided, it automatically + detects the function for which this kwcheck call is being used as an argument. + + Parameters + ---------- + kw : dict + Dictionary of keyword arguments to filter. + func : callable, optional + Target function whose signature will be used for filtering. + If None, automatically detects the calling function. + checktypos : bool, default=True + Whether to check for possible typos in argument names. + If True, raises TypeError for arguments that closely match valid parameters. + + Returns + ------- + dict + Filtered dictionary containing only valid keyword arguments for the target function. + + Raises + ------ + ValueError + If func is None and the automatic detection of the calling function fails. + TypeError + If checktypos is True and a likely typo is detected in the argument names. + + Examples + -------- + >>> def my_function(a, b, c=3): + ... pass + >>> kwargs = {'a': 1, 'b': 2, 'd': 4} + >>> filtered_kwargs = kwcheck(kwargs, my_function) + >>> filtered_kwargs + {'a': 1, 'b': 2} + + >>> # Using it directly in a function call + >>> my_function(**kwcheck(kwargs)) + + Notes + ----- + When used with checktypos=True, this function helps detect possible misspelled + parameter names, improving developer experience by providing helpful error messages. + """ + func = func or get_function_for_which_call_to_caller_is_argument() + if not callable(func): + raise TypeError( + "Couldn't get function for which kwcheck(kw) is an argument") + params = func_params(func) + takeskwargs = any(param.kind == inspect.Parameter.VAR_KEYWORD + for param in params.values()) + if takeskwargs: + return kw + newkw = {k: v for k, v in kw.items() if k in params} + if checktypos: + unused = kw.keys() - newkw.keys() + unset = params - newkw.keys() + for arg in unused: + if typo := evn.meta.find_close_argnames(arg, unset, cutoff=0.8): + raise TypeError( + f'{func.__name__} got unexpected arg {arg}, did you mean {typo}' + ) + return newkw + + +def get_function_for_which_call_to_caller_is_argument(): + """ + returns the function being called with an arg that is a call to enclosing function, if any + + >>> def FIND_THIS_FUNCTION(*a, **kw): ... + >>> def CALLED_TO_PRODUCE_ARGUMENT(**kw): + ... uncle_func = get_function_for_which_call_to_caller_is_argument() + ... print('detected caller:', uncle_func.__name__) + ... assert uncle_func == FIND_THIS_FUNCTION + ... ... + ... return kw + >>> FIND_THIS_FUNCTION(1, 2, CALLED_TO_PRODUCE_ARGUMENT(), 3) + detected caller: FIND_THIS_FUNCTION + """ + frame = inspect.currentframe().f_back.f_back # grandparent + # frame_info = inspect.getframeinfo(frame) + code = frame.f_code + bytecode = dis.Bytecode(code) + current_offset = frame.f_lasti + for instr in bytecode: + if instr.offset < current_offset and (instr.opname == 'LOAD_GLOBAL' + or instr.opname == 'LOAD_NAME'): + potential_func_name = instr.argval + if potential_func_name != 'kwcheck' or True: + func = frame.f_globals.get( + potential_func_name) or frame.f_locals.get( + potential_func_name) + if func: + return func + + +def filter_namespace_funcs(namespace, + prefix='test_', + only=(), + re_only=(), + exclude=(), + re_exclude=()): + """Filters functions in a namespace based on specified inclusion and exclusion rules. + + This function filters out functions from the given `namespace` based on: + - A `prefix` that functions must start with. + - Explicit names (`only`) or regex patterns (`re_only`) to include. + - Explicit names (`exclude`) or regex patterns (`re_exclude`) to exclude. + + Args: + namespace (dict): The namespace (usually `globals()` or `locals()`) to filter. + prefix (str, optional): Only keep functions that start with this prefix. Defaults to `'test_'`. + only (tuple, optional): Names of functions to explicitly keep. Defaults to (). + re_only (tuple, optional): Regex patterns to match function names to keep. Defaults to (). + exclude (tuple, optional): Names of functions to explicitly remove. Defaults to (). + re_exclude (tuple, optional): Regex patterns to match function names to remove. Defaults to (). + + Returns: + dict: The filtered namespace with functions matching the specified criteria. + + Example: + def test_func1(): pass + def test_func2(): pass + def helper_func(): pass + + ns = globals() + filtered_ns = filter_namespace_funcs(ns, only=('test_func1',)) + print(filtered_ns) # {'test_func1': } + """ + if only or re_only: + allfuncs = [k for k, v in namespace.items() if callable(v)] + allfuncs = list(filter(lambda s: s.startswith(prefix), allfuncs)) + namespace_copy = namespace.copy() + for func in allfuncs: + del namespace[func] + for func in only: + namespace[func] = namespace_copy[func] + for func_re in re_only: + for func in allfuncs: + if re.match(func_re, func): + namespace[func] = namespace_copy[func] + for func in exclude: + if func in namespace: + del namespace[func] + allfuncs = [k for k, v in namespace.items() if callable(v)] + allfuncs = list(filter(lambda s: s.startswith(prefix), allfuncs)) + for func_re in re_exclude: + for func in allfuncs: + if re.match(func_re, func): + del namespace[func] + return namespace + + +def param_is_required(param): + """Checks if a function parameter is required. + + A parameter is considered required if: + - It has no default value. + - It is not a `*args` or `**kwargs` type parameter. + + Args: + param (inspect.Parameter): The parameter to check. + + Returns: + bool: True if the parameter is required, False otherwise. + + Example: + >>> def my_func(x, y=2, *args, **kwargs): + ... pass + >>> params = inspect.signature(my_func).parameters + >>> for name, param in params.items(): + ... print(name, param_is_required(param)) + x True + y False + args False + kwargs False + + """ + return param.default is param.empty and param.kind not in ( + param.VAR_POSITIONAL, param.VAR_KEYWORD) + + +@functools.lru_cache +def func_params(func, required_only=False): + """Gets the parameters of a function. + + Uses `inspect.signature` to retrieve function parameters. + Can optionally return only required parameters. + + Args: + func (callable): The function to inspect. + required_only (bool, optional): If True, returns only required parameters. Defaults to False. + + Returns: + dict: A dictionary mapping parameter names to `inspect.Parameter` objects. + + Example: + >>> def my_func(a, b, c=1): + ... pass + >>> print(dict(func_params(my_func))) + {'a': , 'b': , 'c': } + >>> print(func_params(my_func, required_only=True)) + {'a': , 'b': } + """ + signature = inspect.signature(func) + params = signature.parameters + if required_only: + params = { + k: param + for k, param in params.items() if param_is_required(param) + } + return params + + +def list_classes(data): + seenit = set() + + def visitor(x): + seenit.add(x.__class__) + + visit(data, visitor) + return seenit + + +def change_class(data, clsmap) -> None: + + def visitor(x): + if x.__class__ in clsmap: + x.__class__ = clsmap[x.__class__] + + visit(data, visitor) + + +def visit(data, func) -> None: + if isinstance(data, dict): + visit(list(data.keys()), func) + visit(list(data.values()), func) + elif isinstance(data, list): + for x in data: + visit(x, func) + else: + func(data) diff --git a/lib/evn/evn/print/__init__.py b/lib/evn/evn/print/__init__.py new file mode 100644 index 00000000..10c586ba --- /dev/null +++ b/lib/evn/evn/print/__init__.py @@ -0,0 +1 @@ +from evn.print.table import * diff --git a/lib/evn/evn/print/table.py b/lib/evn/evn/print/table.py new file mode 100644 index 00000000..89e9adbf --- /dev/null +++ b/lib/evn/evn/print/table.py @@ -0,0 +1,214 @@ +from collections.abc import Mapping, Iterable +import difflib +import re + +import rich +from rich.table import Table +from rich.console import Console + +import evn + +np = evn.lazyimport('numpy') + +console = Console() + + +def print(*args, **kw): + rich.print(*args, **kw) + + +def make_table(thing, precision=3, expand=False, **kw): + kw['precision'] = precision + kw['expand'] = expand + with evn.np_printopts(precision=precision, suppress=True): + # if evn.homog.is_tensor(thing): return make_table_list(thing, **kw) + if isinstance(thing, evn.Bunch): + return make_table_bunch(thing, **kw) + if isinstance(thing, dict): + return make_table_dict(thing, **kw) + # if isinstance(thing, (list, tuple)): return make_table_list(thing, **kw) + xr = evn.maybeimport('xarray') + if xr and isinstance(thing, xr.Dataset): + return make_table_dataset(thing, **kw) + raise TypeError(f'cant make table for {type(thing)}') + + +def print_table(table, **kw): + if not isinstance(table, Table): + if table is None or not len(table): + return '' + table = make_table(table, **kw) + console.print(table) + + +def make_table_list(lst, title=None, header=[], **kw): + t = evn.kwcall(kw, Table, title=title, show_header=bool(header)) + for k in header: + evn.kwcall(kw, t.add_column, k) + for v in lst: + row = [to_renderable(f, **kw) for f in v] + t.add_row(*row) + return t + + +def make_table_bunch(bunch, **kw): + return make_table_dict(bunch, **kw) + + +def make_table_dict(mapping, **kw): + assert isinstance(mapping, Mapping) + vals = list(mapping.values()) + # assert all(type(v)==type(vals[0]) for v in vals) + try: + if isinstance(vals[0], Mapping): + return make_table_dict_of_dict(mapping, **kw) + if isinstance(vals[0], Iterable) and not isinstance(vals[0], str): + return make_table_dict_of_iter(mapping, **kw) + except AssertionError: + return make_table_dict_of_any(mapping, **kw) + + +def _keys(mapping, exclude=(), **kw): + return [ + k for k in mapping if k[0] != '_' and k[-1] != '_' and k not in exclude + ] + + +def _items(mapping, exclude=(), **kw): + return [(k, v) for k, v in mapping.items() + if k[0] != '_' and k[-1] != '_' and k not in exclude] + + +def make_table_dict_of_dict(mapping, title=None, key='key', **kw): + assert all(isinstance(m, Mapping) for m in mapping.values()) + vals = list(mapping.values()) + assert all(_keys(v, **kw) == _keys(vals[0], **kw) for v in vals) + t = evn.kwcall(kw, Table, title=title) + if key: + evn.kwcall(kw, t.add_column, to_renderable(key, **kw)) + for k in _keys(vals[0], **kw): + evn.kwcall(kw, t.add_column, to_renderable(k, **kw)) + for k, submap in _items(mapping): + row = [k] * bool(key) + [ + to_renderable(f, **kw) for f in submap.values() + ] + t.add_row(*row) + return t + + +def make_table_dict_of_iter(mapping, title=None, **kw): + vals = list(mapping.values()) + assert all(len(v) == len(vals[0]) for v in vals) + t = evn.kwcall(kw, Table, title=title) + for k in _keys(mapping, **kw): + evn.kwcall(kw, t.add_column, to_renderable(k, **kw)) + for i in range(len(vals[0])): + row = [to_renderable(v[i], **kw) for k, v in _items(mapping)] + t.add_row(*row) + return t + + +def make_table_dict_of_any(mapping, title=None, **kw): + # vals = list(mapping.values()) + table = evn.kwcall(kw, Table, title=title) + for k in _keys(mapping, **kw): + evn.kwcall(kw, table.add_column, to_renderable(k, **kw)) + row = [to_renderable(v, **kw) for k, v in _items(mapping)] + table.add_row(*row) + return table + + +def make_table_dataset(dataset, title=None, **kw): + table = evn.kwcall(kw, Table, title=title) + cols = list(dataset.coords) + list(dataset.keys()) + for c in cols: + evn.kwcall(kw, table.add_column, to_renderable(c, **kw)) + for nf in np.unique(dataset['nfold']): + ds = dataset.sel(index=dataset['nfold'] == nf) + for i in ds.index: + row = [] + for c in cols: + d = ds[c].data[i].round(1 if c == 'cen' else 4) + if d.shape and d.shape[-1] == 4: + d = d[..., :3] + row.append(to_renderable(d, **kw)) + table.add_row(*row) + return table + + +def to_renderable(obj, + textmap=None, + strip=True, + nohomog=False, + precision=3, + **kw): + textmap = textmap or {} + if isinstance(obj, float): + return f'{obj:7.{precision}f}' + if isinstance(obj, bool): + return str(obj) + if isinstance(obj, int): + return f'{obj:4}' + if isinstance(obj, Table): + return obj + # if nohomog and evn.homog.is_tensor(obj): obj = obj[..., :3] + s = str(evn.summary(obj)) + assert "'" not in s, s + for pattern, replace in textmap.items(): + if '__REGEX__' in textmap and textmap['__REGEX__']: + s = re.sub(pattern, replace, s) + else: + s = s.replace(pattern, str(replace)) + if strip: + s = s.strip() + return s + + +def diff(ref: str, new: str) -> None: + # Use difflib to create a unified diff + diff = difflib.unified_diff(ref.splitlines(), + new.splitlines(), + fromfile='Original', + tofile='Got', + lineterm='') + return '\n'.join(diff) + + +def compare_multiline_strings(ref, got): + """ + Compare two multi-line strings line by line. + For lines that are different, show a character-level diff using SequenceMatcher. + + Parameters: + ref (str): The ref multi-line string. + got (str): The got multi-line string. + """ + ref_lines = ref.splitlines() + got_lines = got.splitlines() + total_lines = max(len(ref_lines), len(got_lines)) + + for i in range(total_lines): + # Retrieve each line or mark as empty if one string is shorter. + e_line = ref_lines[i] if i < len(ref_lines) else '' + g_line = got_lines[i] if i < len(got_lines) else '' + + # If the lines are exactly equal, print that they match. + if e_line == g_line: + # print(f"Line {i+1}: OK") + pass + else: + # print(f"Line {i+1}: DIFFERENCE") + # Use SequenceMatcher to get a detailed diff. + matcher = difflib.SequenceMatcher(None, e_line, g_line) + diff_line = [] + for tag, i1, i2, j1, j2 in matcher.get_opcodes(): + if tag == 'equal': + diff_line.append(e_line[i1:i2]) + elif tag == 'insert': + diff_line.append(f'[+{g_line[j1:j2]}+]') + elif tag == 'delete': + diff_line.append(f'[-{e_line[i1:i2]}-]') + elif tag == 'replace': + diff_line.append(f'[-{e_line[i1:i2]}-]') + diff_line.append(f'[+{g_line[j1:j2]}+]') + print(f'{i:3}', ''.join(diff_line)) diff --git a/lib/evn/evn/testing/__init__.py b/lib/evn/evn/testing/__init__.py new file mode 100644 index 00000000..64812337 --- /dev/null +++ b/lib/evn/evn/testing/__init__.py @@ -0,0 +1,7 @@ +from evn.testing.pytest import * +import evn.testing.quicktest as qt +from evn.testing.quicktest import * +from evn.testing.gen_tests import * +from evn.testing.testapp import * + +__all__ = ['quicktest', 'TestApp'] diff --git a/lib/evn/evn/testing/gen_tests.py b/lib/evn/evn/testing/gen_tests.py new file mode 100644 index 00000000..0e73095c --- /dev/null +++ b/lib/evn/evn/testing/gen_tests.py @@ -0,0 +1,41 @@ +import inspect +import copy +import typing as t +import evn + +T = t.TypeVar('T') +R = t.TypeVar('R') + + +def generate_tests( + args: list[T], + prefix: str = 'helper_test_', + convert: t.Callable[[T], R] = lambda x: x, + namespace: t.MutableMapping = {}, + **kw, +): + if not namespace: + namespace = inspect.currentframe().f_back.f_globals + + for arg in args: + testname = arg + if not isinstance(testname, str): + testname = arg[0].replace(' ', '_') + assert isinstance(testname, str) + + @evn.chrono + def run_convert(arg, kw=kw): + return evn.kwcall(kw, convert, arg) + + processed: R = run_convert(arg) + + for k, func in list(namespace.items()): + if k.startswith(prefix): + name = k[prefix.find('test_'):] + func = t.cast(t.Callable[[R], None], func) + + def testfunc(func=func, processed: R = processed, kw=kw): + return evn.kwcall(kw, func, *copy.copy(processed)) + + testfunc.__name__ = testfunc.__qualname__ = f'{name}_{testname}' + namespace[f'{name}_{testname.upper()}'] = testfunc diff --git a/lib/evn/evn/testing/pytest.py b/lib/evn/evn/testing/pytest.py new file mode 100644 index 00000000..aa09aad7 --- /dev/null +++ b/lib/evn/evn/testing/pytest.py @@ -0,0 +1,63 @@ +def has_pytest_mark(obj, mark): + """Checks if an object has a specific pytest mark. + + Args: + obj (Any): The object to check. + mark (str): The name of the pytest mark to check for. + + Returns: + bool: True if the object has the specified mark, False otherwise. + + Example: + >>> import pytest + >>> @pytest.mark.ci + ... def test_example(): + ... pass + + >>> print(has_pytest_mark(test_example, 'ci')) + True + >>> print(has_pytest_mark(test_example, 'skip')) + False + """ + return mark in [m.name for m in getattr(obj, 'pytestmark', ())] + + +def no_pytest_skip(obj): + """Checks if an object does not have the `skip` pytest mark. + + Args: + obj (Any): The object to check. + + Returns: + bool: True if the object does not have the `skip` mark, False otherwise. + + Example: + >>> import pytest + >>> @pytest.mark.skip + ... def test_example(): + ... pass + >>> print(no_pytest_skip(test_example)) + False + """ + return not has_pytest_mark(obj, 'skip') + + +def get_pytest_params(func): + """ + Detect if a function is decorated with @pytest.mark.parametrize and return the arguments. + + Args: + func (Callable): The function to inspect. + + Returns: + tuple[str, list] | None: A tuple of (argnames, argvalues) if decorated, else None. + """ + for mark in getattr(func, 'pytestmark', []): + if mark.name == 'parametrize': + names, vals = mark.args + names = list(map(str.strip, names.split(','))) + if len(names) > 1: + for v in vals: + assert len(names) == len(vals) + return names, vals + return None diff --git a/lib/evn/evn/testing/quicktest.py b/lib/evn/evn/testing/quicktest.py new file mode 100644 index 00000000..bcf1447f --- /dev/null +++ b/lib/evn/evn/testing/quicktest.py @@ -0,0 +1,225 @@ +import sys +import time +import inspect +# from doctest import testmod +import typing +import tempfile +import pytest +import io +import evn + +T = typing.TypeVar('T') + +class TestConfig(evn.Bunch): + + def __init__(self, *a, **kw): + super().__init__(self, *a, **kw) + self.nofail = self.get('nofail', False) + self.verbose = self.get('verbose', False) + self.checkxfail = self.get('checkxfail', False) + self.timed = self.get('timed', True) + self.nocapture = self.get('nocapture', []) + self.fixtures = self.get('fixtures', {}) + self.setup = self.get('setup', lambda: None) + self.funcsetup = self.get('funcsetup', lambda: None) + self.context = self.get('context', evn.nocontext) + self.use_test_classes = self.get('use_test_classes', True) + self.dryrun = self.get('dryrun', False) + + def detect_fixtures(self, namespace): + if not evn.ismap(namespace): + namespace = vars(namespace) + for name, obj in namespace.items(): + if callable(obj) and hasattr(obj, '_pytestfixturefunction'): + assert name not in self.fixtures + self.fixtures[name] = obj.__wrapped__() + +@evn.struct +class TestResult: + passed: list[str] = evn.field(list) + failed: list[str] = evn.field(list) + errored: list[str] = evn.field(list) + xfailed: list[str] = evn.field(list) + skipexcn: list[str] = evn.field(list) + _runtime: dict[str, float] = evn.field(dict) + + def runtime(self, name: str) -> float: + return self._runtime[name] + + def items(self) -> list[tuple[str, list[str]]]: + return [ + ('passed', self.passed), + ('failed', self.failed), + ('errored', self.errored), + ('xfailed', self.xfailed), + ('skipexcn', self.skipexcn), + ] + +def quicktest(namespace, config=evn.Bunch(), **kw): + t_start = time.perf_counter() + orig = namespace + if not evn.ismap(namespace): + namespace = vars(namespace) + if '__file__' in namespace: + print(f'quicktest "{namespace["__file__"]}":', flush=True) + else: + print(f'quicktest "{orig}":', flush=True) + # evn.onexit(evn.global_timer.report, timecut=0.01, spacer=1) + config = TestConfig(**config, **kw) + config.detect_fixtures(namespace) + evn.kwcall(config, evn.meta.filter_namespace_funcs, namespace) + # timed = evn.chrono if config.timed else lambda f: f + # timed = lambda f: + test_funcs, teardown = collect_tests(namespace, config) + # evn.global_timer.checkpoint('quicktest') + try: + result = run_tests(test_funcs, config, kw) + finally: + for func in teardown: + func() + print_result(config, result, time.perf_counter() - t_start) + return result + +def print_result(config, result, t_total): + if result.passed: + print(f'PASSED {len(result.passed)} tests in {t_total:.3f} seconds') + result.passed.sort(key=result.runtime, reverse=True) + npassprinted = 0 + for label, tests in result.items(): + for test in tests: + if label == 'passed' and not config.verbose and npassprinted > 9 and result._runtime[test] < 100: + npassprinted += 1 + continue + print(f'{label.upper():9} {result._runtime[test]*1000:7.3f} ms {test}', flush=True) + +def test_func_ok(name, obj): + return name.startswith('test_') and callable(obj) and evn.testing.no_pytest_skip(obj) + +def test_class_ok(name, obj): + return name.startswith('Test') and isinstance(obj, type) and not hasattr(obj, '__unittest_skip__') + +def collect_tests(namespace, config): + test_funcs, test_classes, teardown = [], [], [] + for name, obj in namespace.items(): + if test_class_ok(name, obj) and config.use_test_classes: + suite = obj() + test_classes.append(suite) + # print(f'{f" obj: {name} ":=^80}', flush=True) + test_methods = evn.meta.filter_namespace_funcs(vars(namespace[name])) + test_methods = { + f'{name}.{k}': getattr(suite, k) + for k, v in test_methods.items() if test_func_ok(k, v) + } + # TODO: maybe call these lazilyt? + getattr(suite, 'setUp', lambda: None)() + # test_suites.append((name, obj)) + test_funcs.extend(test_methods.items()) + teardown.append(getattr(suite, 'tearDown', lambda: None)) + elif test_func_ok(name, obj): + test_funcs.append((name, obj)) + testmodule = evn.Path(inspect.getfile(test_funcs[0][1])).stem + for _, func in test_funcs: + if evn.is_free_function(func): + func.__module__ = func.__module__.replace('__main__', testmodule) + for obj in test_classes: + obj.__module__ = obj.__module__.replace('__main__', testmodule) + return test_funcs, teardown + +def run_tests(test_funcs, config, kw): + result = TestResult() + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir = evn.Path(tmpdir) + evn.kwcall(config.fixtures, config.setup) + config.fixtures['tmpdir'] = str(tmpdir) + config.fixtures['tmp_path'] = tmpdir + for name, func in test_funcs: + quicktest_run_maybe_parametrized_func(name, func, result, config, kw) + return result + +def quicktest_run_maybe_parametrized_func(name, func, result, config, kw): + names, values = evn.testing.get_pytest_params(func) or ((), [()]) + for val in values: + if len(names) == 1 and not isinstance(val, (list, tuple)): + val = [val] + paramkw = kw | dict(zip(names, val)) + quicktest_run_test_function(name, func, result, config, paramkw) + +def quicktest_run_test_function(name, func, result, config, kw, check_xfail=True): + error, testout = None, None + nocapture = config.nocapture is True or name in config.nocapture + context = evn.nocontext if nocapture else evn.capture_stdio + with context() as testout: # noqa + try: + evn.kwcall(config.fixtures, config.funcsetup) + if not config.dryrun: + kwthis = evn.kwcheck(config.fixtures | kw, func) + t_start = time.perf_counter() + func(**kwthis) + result._runtime[name] = time.perf_counter() - t_start + result.passed.append(name) + except pytest.skip.Exception: + result.skipexcn.append(name) + except AssertionError as e: + if evn.testing.has_pytest_mark(func, 'xfail'): + result.xfailed.append(name) + else: + result.failed.append(name) + error = e + except Exception as e: # noqa + result.errored.append(name) + error = e + if any([ + name in result.failed, + name in result.errored, + config.checkxfail and name in result.xfailed, + ]): + print(f'{f" {func.__name__} ":-^80}', flush=True) + if testout: print(testout.read(), flush=True, end='') + if config.nofail and error: print(error) + elif error: raise error + +class CapSys: + + def __init__(self): + self._stdout = None + self._stderr = None + self._old_stdout = None + self._old_stderr = None + + def __enter__(self): + self._stdout = io.StringIO() + self._stderr = io.StringIO() + self._old_stdout = sys.stdout + self._old_stderr = sys.stderr + sys.stdout = self._stdout + sys.stderr = self._stderr + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self._finalize() + + def readouterr(self): + self._stdout.seek(0) + self._stderr.seek(0) + return CapResult(self._stdout.read(), self._stderr.read()) + + def _finalize(self): + sys.stdout = self._old_stdout + sys.stderr = self._old_stderr + +class CapResult: + + def __init__(self, out, err): + self.out = out + self.err = err + +def maincrudtest(crud, namespace, fixtures=None, funcsetup=lambda: None, **kw): + fixtures = fixtures or {} + with crud() as crud: + fixtures |= crud + + def newfuncsetup(backend): + backend._clear_all_data_for_testing_only() + evn.kwcall(fixtures, funcsetup) + + return quicktest(namespace, fixtures, funcsetup=newfuncsetup, **kw) diff --git a/lib/evn/evn/testing/testapp.py b/lib/evn/evn/testing/testapp.py new file mode 100644 index 00000000..ea282c71 --- /dev/null +++ b/lib/evn/evn/testing/testapp.py @@ -0,0 +1,268 @@ +from evn import CLI +from pathlib import Path + +# === Root CLI scaffold using inheritance-based hierarchy === +class TestApp(CLI): + """ + Main entry point for the EVN developer workflow CLI. + """ + + def version(self, verbose: bool = False): + """ + Show version info. + + :param verbose: If True, include environment and git info. + :type verbose: bool + """ + print(f'[evn.version] Version info (verbose={verbose})') + + def name_with_under_scores(self): + ... + + def _private_meth(self): + ... + + @classmethod + def _private_func(cls): + ... + + @staticmethod + def _static_func(): + ... + + @classmethod + def _callback(cls, foo='bar'): + """ + Configure global CLI behavior (used for testing _callback hooks). + """ + return dict(help_option_names=['-h', '--help']) + + +class dev(TestApp): + "Development: edit, format, test a single file or unit." + + pass + + +class format(dev): + + def stream(self, tab_width: int = 4, language: str = 'python'): + """ + Format input stream. + + :param tab_width: Tab width to use. + :type tab_width: int + :param language: Programming language (e.g. 'python'). + :type language: str + """ + print( + f'[dev.format.stream] Format stream (tab_width={tab_width}, language={language})' + ) + + def smart(self, mode: str = 'git'): + """ + Format changed project files. + + :param mode: Change detection mode ('md5', 'git'). + :type mode: str + """ + print(f'[dev.format.smart] Format changed files using mode={mode}') + + +class test(dev): + + def file(self, fail_fast: bool = False): + """ + Run pytest or doctest. + + :param fail_fast: Stop after first failure. + :type fail_fast: bool + """ + print(f'[dev.test.file] Run tests (fail_fast={fail_fast})') + + def swap(self, path: Path = Path('')): + """ + Swap between test/source. + + :param path: Path to swap. + :type path: Path + """ + print(f'[dev.test.swap] Swap source/test for {path}') + + +class validate(dev): + + def file(self, strict: bool = True): + """ + Validate file syntax/config. + + :param strict: Fail on warnings. + :type strict: bool + """ + print(f'[dev.validate.file] Validate file (strict={strict})') + + +class doc(dev): + + def build(self, open_browser: bool = False): + """ + Build docs for current file. + + :param open_browser: Open result in browser. + :type open_browser: bool + """ + print(f'[dev.doc.build] Build docs (open_browser={open_browser})') + + +class create(dev): + + def testfile(self, + module: Path, + testfile: Path = Path(''), + prompts=True, + browser: str = ''): + """ + Create a test file for current file. + + :param prompts: create prompts for ai gen. + :type bool: bool + """ + print(f'[dev.doc.build] Build docs (open_browser={browser})') + + +class doccheck(TestApp): + "Doccheck: audit project documentation and doctests." + + @classmethod + def _callback(cls, docsdir='docs'): + return dict(help_option_names=['--dochelp']) + + +class build(doccheck): + + def full(self, force: bool = False): + print(f'[doccheck.build.full] Full doc build (force={force})') + + +class open(doccheck): + + def file(self, browser: str = 'firefox'): + print(f'[doccheck.open.file] Open HTML with browser={browser}') + + +class doctest(doccheck): + + def fail_loop(self, verbose: bool = False): + print( + f'[doccheck.doctest.fail_loop] Iterate doctest failures (verbose={verbose})' + ) + + +class missing(doccheck): + + def list(self, json: bool = False): + print(f'[doccheck.missing.list] List missing docs (json={json})') + + +class qa(TestApp): + "QA: prepare commits, PRs, and run test matrices." + + pass + + +class matrix(qa): + + def run(self, parallel: int = 1): + print(f'[qa.matrix.run] Run matrix with {parallel} parallel jobs') + + +class testqa(qa): + + def loop(self, max_retries: int = 3): + print(f'[qa.test.loop] Retry failing tests up to {max_retries} times') + + +class out(qa): + + def filter(self, min_lines: int = 5): + print(f'[qa.out.filter] Filter output (min_lines={min_lines})') + + +class review(qa): + + def coverage(self, min_coverage: float = 75): + print(f'[qa.review.coverage] Minimum coverage = {min_coverage}%') + + def changes(self, summary: bool = True): + print(f'[qa.review.changes] Show changes (summary={summary})') + + +class run(TestApp): + "Run: dispatch actions, scripts, or simulate GH actions." + + pass + + +class dispatch(run): + + def file(self, path: str): + print(f'[run.dispatch.file] Dispatch on file {path}') + + +class act(run): + + def job(self, name: str): + print(f'[run.act.job] Run GitHub job {name}') + + +class doit(run): + + def task(self, name: str = ''): + print(f'[run.doit.task] Run doit task {name}') + + +class script(run): + + def shell(self, cmd: str): + print(f'[run.script.shell] Run shell: {cmd}') + + +class buildtools(TestApp): + "Build: C++ and native build tasks." + + pass + + +class cpp(buildtools): + + def compile(self, debug: bool = False): + print(f'[build.cpp.compile] Compile (debug={debug})') + + def pybind(self, header_only: bool = False): + print( + f'[build.cpp.pybind] Generate pybind (header_only={header_only})') + + +class clean(buildtools): + + def all(self, verbose: bool = False): + print(f'[build.clean.all] Clean all (verbose={verbose})') + + +class proj(TestApp): + "Project structure, tagging, and discovery." + + def root(self, verbose: bool = False): + print(f'[proj.TestApp] Project TestApp (verbose={verbose})') + + def info(self): + print('[proj.info] Project metadata') + + def tags(self, rebuild: bool = False): + print(f'[proj.tags] Generate tags (rebuild={rebuild})') + + +if __name__ == '__main__': + # for click_path in TestApp._walk_click(): + # print(click_path) + TestApp._run() diff --git a/lib/evn/evn/tests/_prelude/broken_py_file.py b/lib/evn/evn/tests/_prelude/broken_py_file.py new file mode 100644 index 00000000..cde85a54 --- /dev/null +++ b/lib/evn/evn/tests/_prelude/broken_py_file.py @@ -0,0 +1,7 @@ +import sys + +if 'doctest' not in sys.modules: + import evn + + missing = evn.lazyimport('does_not_exist') + missing.BOOM diff --git a/lib/evn/evn/tests/_prelude/test_basic_types.py b/lib/evn/evn/tests/_prelude/test_basic_types.py new file mode 100644 index 00000000..e69de29b diff --git a/lib/evn/evn/tests/_prelude/test_chrono.py b/lib/evn/evn/tests/_prelude/test_chrono.py new file mode 100644 index 00000000..a81a9651 --- /dev/null +++ b/lib/evn/evn/tests/_prelude/test_chrono.py @@ -0,0 +1,356 @@ +from pprint import pprint +import statistics +import pytest +import time +import random +from evn._prelude.chrono import Chrono, chrono, TimerScope +# from evn.dynamic_float_array import DynamicFloatArray + +import evn + +# orig_name = __name__ +# __name__ = 'test_chrono' + +config_test = evn.Bunch( + re_only=[ + # + ], + re_exclude=[ + # + ], +) + +def main(): + evn.testing.quicktest( + namespace=globals(), + config=config_test, + verbose=1, + check_xfail=False, + chrono=False, + ) + +def test_chronometer(): + assert evn.chronometer + +class FuncNest: + + def __init__(self): + self.runtime = {'method1': [], 'method2': [], 'recursive': [], 'generator': []} + + @chrono + def method1(self): + start = time.perf_counter() + time.sleep(0.01) # random.uniform(0.01, 0.03)) + # print(self) + self.runtime['method1'].append(time.perf_counter() - start) + self.method2() + start = time.perf_counter() + time.sleep(0.01) # random.uniform(0.01, 0.03)) + self.runtime['method1'].append(time.perf_counter() - start) + + @chrono + def method2(self): + start = time.perf_counter() + time.sleep(0.01) # random.uniform(0.01, 0.03)) + self.runtime['method2'].append(time.perf_counter() - start) + self.recursive(random.randint(1, 3)) + start = time.perf_counter() + time.sleep(0.01) # random.uniform(0.01, 0.03)) + self.runtime['method2'].append(time.perf_counter() - start) + + @chrono + def recursive(self, depth): + if not depth: + return + start = time.perf_counter() + time.sleep(0.01) # random.uniform(0.01, 0.03)) + self.runtime['recursive'].append(time.perf_counter() - start) + self.recursive(depth - 1) + start = time.perf_counter() + time.sleep(0.01) # random.uniform(0.01, 0.03)) + self.runtime['recursive'].append(time.perf_counter() - start) + + # @chrono + # def generator(self): + # start = time.perf_counter() + # time.sleep(0.01) # random.uniform(0.01, 0.03)) + # self.runtime['generator'].append(time.perf_counter() - start) + # for i in range(5): + # start = time.perf_counter() + # time.sleep(0.01) # random.uniform(0.01, 0.03)) + # self.runtime['generator'].append(time.perf_counter() - start) + # yield i + # start = time.perf_counter() + # time.sleep(0.01) # random.uniform(0.01, 0.03)) + # self.runtime['generator'].append(time.perf_counter() - start) + +FuncNest.__module__ = 'test_chrono' +FuncNest.method1.__module__ = 'test_chrono' +FuncNest.method2.__module__ = 'test_chrono' +FuncNest.recursive.__module__ = 'test_chrono' + +# FuncNese.generator.__module__ = 'test_chrono' + +# @pyt8est.mark.xfail +def test_chrono_nesting(): + instance = FuncNest() + instance.method1() + # assert list(instance.generator()) == [0, 1, 2, 3, 4] + report = evn.chronometer.report_dict() + pprint(report) + for method in 'method1 method2 recursive'.split(): + try: + recorded_time = sum(instance.runtime[method]) + print(report.keys()) + chrono_time = report[f'test_chrono.FuncNest.{method}'] + err = f'Mismatch in {method}, internal: {recorded_time} vs chrono: {chrono_time}' + assert abs(recorded_time - chrono_time) < 0.005, err + except KeyError: + assert 0, f'missing key {method}' + + assert evn.chronometer.scopestack[-1].name == 'main' + +def test_chrono_func(): + timer = Chrono() + + @chrono(chrono=timer) + def foo(): + time.sleep(0.001) + + foo.__module__ = 'test_chrono' + foo() + assert 'test_chrono.test_chrono_func.foo' in timer.times + assert len(timer.times['test_chrono.test_chrono_func.foo']) == 1 + print(timer.times['test_chrono.test_chrono_func.foo']) + assert sum(timer.times['test_chrono.test_chrono_func.foo']) >= 0.001 + +def test_scope(): + with Chrono() as t: + t.enter_scope('foo') + t.enter_scope('bar') + t.enter_scope('baz') + t.exit_scope('baz') + t.exit_scope('bar') + t.exit_scope('foo') + assert 'foo' in t.times + assert 'bar' in t.times + assert 'baz' in t.times + +def allclose(a, b, atol): + if isinstance(a, float): return abs(a - b) < atol + return all(abs(a - b) <= atol for x, y in zip(a, b)) + +def test_chrono_checkpoint(): + with Chrono() as chrono: + time.sleep(0.02) + chrono.checkpoint('foo') + time.sleep(0.06) + chrono.checkpoint('bar') + time.sleep(0.04) + chrono.checkpoint('baz') + + times = chrono.report_dict() + assert allclose(times['foo'], 0.02, atol=0.05) + assert allclose(times['bar'], 0.06, atol=0.05) + assert allclose(times['baz'], 0.04, atol=0.05) + + times = chrono.report_dict(order='longest') + assert list(times.keys()) == ['total', 'bar', 'baz', 'foo', 'Chrono'] + + times = chrono.report_dict(order='callorder') + print(times.keys()) + assert list(times.keys()) == ['foo', 'bar', 'baz', 'Chrono', 'total'] + + with pytest.raises(ValueError): + chrono.report_dict(order='oarenstoiaen') + +def chrono_deco_func(): + time.sleep(0.01) + +chrono_deco_func.__module__ = 'test_chrono' +chrono_deco_func = chrono(chrono_deco_func) + +def test_chrono_deco_func(): + evn.chronometer.clear() + for _ in range(3): + chrono_deco_func() + + times = evn.chronometer.find_times('test_chrono.chrono_deco_func') + for t in times: + assert 0.01 <= t < 0.012 + assert 'test_chrono.chrono_deco_func' in evn.chronometer.times + +def chrono_deco_func2(): + time.sleep(0.005) + chrono_deco_func() + time.sleep(0.005) + +chrono_deco_func2.__module__ = 'test_chrono' +chrono_deco_func2 = chrono(chrono_deco_func2) + +def chrono_deco_func3(): + time.sleep(0.005) + chrono_deco_func2() + time.sleep(0.005) + +chrono_deco_func3.__module__ = 'test_chrono' +chrono_deco_func3 = chrono(chrono_deco_func3) + +def test_chrono_deco_func_nest(): + evn.chronometer.clear() + N = 1 + for _ in range(N): + chrono_deco_func3() + times = evn.chronometer.find_times('test_chrono.chrono_deco_func') + times2 = evn.chronometer.find_times('test_chrono.chrono_deco_func2') + times3 = evn.chronometer.find_times('test_chrono.chrono_deco_func3') + print(evn.chronometer.times.keys()) + assert N == len(times) == len(times2) == len(times3) + for t, t2, t3 in zip(times, times2, times3): + assert 0.01 <= t < 0.012 + assert 0.01 <= t2 < 0.012 + assert 0.01 <= t3 < 0.012 + assert 'test_chrono.chrono_deco_func' in evn.chronometer.times + assert 'test_chrono.chrono_deco_func2' in evn.chronometer.times + +def test_summary(): + with Chrono() as chrono: + chrono.enter_scope('foo') + time.sleep(0.01) + chrono.exit_scope('foo') + chrono.enter_scope('foo') + time.sleep(0.03) + chrono.exit_scope('foo') + chrono.enter_scope('foo') + time.sleep(0.02) + chrono.exit_scope('foo') + times = chrono.report_dict(summary=sum) + assert allclose(times['foo'], 0.06, atol=0.02) + + times = chrono.report_dict(summary=statistics.mean) + assert allclose(times['foo'], 0.02, atol=0.01) + + times = chrono.report_dict(summary=min) + assert allclose(times['foo'], 0.01, atol=0.01) + + with pytest.raises(TypeError): + chrono.report(summary='foo') + + with pytest.raises(TypeError): + chrono.report(summary=1) + +def test_chrono_stop_behavior(): + chrono = Chrono() + chrono.enter_scope('foo') + chrono.exit_scope('foo') + chrono.stop() + assert chrono.stopped + with pytest.raises(AssertionError): + chrono.enter_scope('bar') + with pytest.raises(AssertionError): + chrono.store_finished_scope(TimerScope('baz')) + +def test_scope_mismatch(): + chrono = Chrono() + chrono.enter_scope('foo') + with pytest.raises(AssertionError, match='exiting scope: bar doesnt match: foo'): + chrono.exit_scope('bar') + +def test_scope_name_from_object(): + chrono = Chrono() + + class Dummy: + pass + + name = chrono.scope_name(Dummy) + assert isinstance(name, str) + assert 'Dummy' in name + +def test_report_string_return(): + with Chrono() as chrono: + chrono.enter_scope('foo') + time.sleep(0.01) + chrono.exit_scope('foo') + report = chrono.report(printme=False) + assert isinstance(report, str) + assert 'foo' in report + +@pytest.mark.skip +def test_generator_deco(): + calls = [] + + @chrono + def gen(): + yield 1 + yield 2 + + with pytest.raises(ValueError): + for x in gen(): + calls.append(x) + + assert calls == [1, 2] + print(evn.chronometer.times) + +@pytest.mark.skip +def test_generator_with_exception(): + calls = [] + + @chrono + def gen(): + yield 1 + yield 2 + raise ValueError('boom') + + with pytest.raises(ValueError): + for x in gen(): + calls.append(x) + + assert calls == [1, 2] + print(evn.chronometer.times) + +def test_nested_chrono_scopes(): + with Chrono() as outer: + outer.enter_scope('outer') + time.sleep(0.005) + with Chrono() as inner: + inner.enter_scope('inner') + time.sleep(0.005) + inner.exit_scope('inner') + outer.exit_scope('outer') + assert 'outer' in outer.times + assert 'inner' in inner.times + +def test_report_dict_bad_order(): + chrono = Chrono() + with pytest.raises(ValueError): + chrono.report_dict(order='invalid') + +def test_chrono_context_manager(): + with Chrono('foo') as c: + time.sleep(0.01) + assert 'foo' in c.times + assert 0.01 <= c.times['foo'][0] < 0.012 + +def test_scope_context_manager(): + c = Chrono() + with c.scope('foo'): + time.sleep(0.01) + assert 'foo' in c.times + assert 0.01 <= c.times['foo'][0] < 0.012 + +def test_nested_scope_context_manager(): + c = Chrono() + with c.scope('foo'): + time.sleep(0.005) + with c.scope('bar'): + time.sleep(0.005) + with c.scope('baz'): + time.sleep(0.01) + time.sleep(0.005) + time.sleep(0.005) + for n in 'foo bar baz'.split(): + assert n in c.times + assert 0.01 <= c.times[n][0] < 0.012 + +if __name__ == '__main__': + main() diff --git a/lib/evn/evn/tests/_prelude/test_import_util.py b/lib/evn/evn/tests/_prelude/test_import_util.py new file mode 100644 index 00000000..2b7e4316 --- /dev/null +++ b/lib/evn/evn/tests/_prelude/test_import_util.py @@ -0,0 +1,24 @@ +import contextlib +import os +import sys +import tempfile +import shutil +from typing import Tuple, Generator +from pathlib import Path + +import pytest + +import evn +from evn._prelude.import_util import is_installed + + +def main(): + evn.testing.quicktest(globals()) + +def test_is_installed(): + assert is_installed('icecream') + assert not is_installed('lwySENESIONAOIRENTeives') + + +if __name__ == '__main__': + main() diff --git a/lib/evn/evn/tests/_prelude/test_inspect.py b/lib/evn/evn/tests/_prelude/test_inspect.py new file mode 100644 index 00000000..d337a8e6 --- /dev/null +++ b/lib/evn/evn/tests/_prelude/test_inspect.py @@ -0,0 +1,76 @@ +# test_inspect.py + +import types +import builtins +import pytest +from evn._prelude import inspect as insp + +def main(): + import evn + evn.testing.quicktest(namespace=globals()) + +def test_summary_basic_types(): + insp42 = insp.summary(42, debug=True) + print(insp42) + assert insp42 == '42' + assert insp.summary("hello") == 'hello' + assert insp.summary([1, 2]) == "[1, 2]" + assert "Type: int" in insp.summary(int) + assert "Type: list" in insp.summary(list) + +def test_summary_function_and_method(): + + def dummy_fn(): + pass + + class Dummy: + + def method(self): + pass + + assert "dummy_fn" in insp.summary(dummy_fn) + assert "Dummy.method" in insp.summary(Dummy().method) + +def test_summary_numpy_array(): + np = pytest.importorskip("numpy") + a = np.arange(5) + b = np.arange(100) + assert insp.summary(a) == str(a) + assert insp.summary(b).startswith("ndarray[") + +def test_summary_torch_tensor(): + torch = pytest.importorskip("torch") + t = torch.arange(5) + t_large = torch.arange(100) + assert insp.summary(t) == str(t) + assert insp.summary(t_large).startswith("Tensor[") + +def test_diff_basic_sets(): + assert insp.diff({1, 2}, {2, 3}) == {1, 3} + +def test_show_prints(monkeypatch, capsys): + called = {} + + def fake_inspect(obj, **kw): + called["obj"] = obj + + monkeypatch.setattr(insp, "show_impl", fake_inspect) + result = insp.show("hello") + assert called["obj"] == "hello" + assert result is None + +def test_inspect_alias(monkeypatch): + called = {} + + def fake_show(obj, **kw): + called["obj"] = obj + + monkeypatch.setattr(insp, "show_impl", fake_show) + assert insp.inspect("world") is None + assert called["obj"] == "world" + +def test_trace_decorator(capsys): + insp.evn.show_trace = True + +if __name__ == '__main__': + main() diff --git a/lib/evn/evn/tests/_prelude/test_lazy_dispatch.py b/lib/evn/evn/tests/_prelude/test_lazy_dispatch.py new file mode 100644 index 00000000..2beccd6f --- /dev/null +++ b/lib/evn/evn/tests/_prelude/test_lazy_dispatch.py @@ -0,0 +1,229 @@ +# test_lazy_dispatch.py +import sys +import types +import pytest + +import evn +from evn._prelude.lazy_dispatch import GLOBAL_DISPATCHERS, lazydispatch, LazyDispatcher + +def main(): + import evn + evn.testing.quicktest(namespace=globals()) + +def test_dispatch_deco(): + + @lazydispatch + def foo(obj): + print(obj) + + assert isinstance(foo, LazyDispatcher) + +def test_dispatch_deco_nest(): + + @lazydispatch(object, scope='local') + def foo(obj): + print(obj) + + assert isinstance(foo, LazyDispatcher) + +def test_dispatch_global_registry(): + GLOBAL_DISPATCHERS.clear() + + @lazydispatch(object, scope='local') + def describe(obj): + return 'default' + + @lazydispatch(list, scope='local') + def describe(obj): + return 'list' + + assert len(GLOBAL_DISPATCHERS) == 1 + +def test_dispatchers_match(): + GLOBAL_DISPATCHERS.clear() + + @lazydispatch(object, scope='local') + def describe(obj): + return 'default' + + describe1 = describe + assert isinstance(describe1, LazyDispatcher) + + @lazydispatch(list, scope='local') + def describe(obj): + return 'list' + + describe2 = describe + assert isinstance(describe2, LazyDispatcher) + assert describe1 is evn.first(GLOBAL_DISPATCHERS.values()) + assert describe1 is describe2 + + assert describe([1, 2]) == 'list', str(describe([1, 2])) + assert describe(42) == 'default', str(describe(42)) + +def test_dispatch_default(): + GLOBAL_DISPATCHERS.clear() + + @lazydispatch(object, scope='local') + def describe(obj): + return 'default' + + describe1 = describe + + @lazydispatch(list, scope='local') + def describe(obj): + return 'list' + + describe2 = describe + assert describe1 is describe2 + + assert describe([1, 2]) == 'list' + assert describe(42) == 'default' + +def test_lazy_registration_numpy(): + numpy = pytest.importorskip('numpy') + if 'numpy' in sys.modules: del sys.modules['numpy'] + + @lazydispatch(object, scope='local') + def describe(obj): + return 'default' + + @lazydispatch('numpy.ndarray', scope='local') + def describe(obj): + return f'ndarray({obj.size})' + + assert describe(numpy.arange(3)) == 'ndarray(3)' + +def test_scope_local_disambiguation(): + + @lazydispatch(object, scope='local') + def action(obj): + return 'default' + + @lazydispatch('builtins.int', scope='local') + def action(obj): + return 'int' + + assert action(123) == 'int' + assert action('hi') == 'default' + +def test_unresolved_type_skips(): + + @lazydispatch(object, scope='local') + def handler(obj): + return 'base' + + @lazydispatch('ghost.Type', scope='local') + def handler(obj): + return 'ghost' + + class Other: + pass + + assert handler(Other()) == 'base' + +def test_missing_dispatcher_errors(): + with pytest.raises(ValueError): + + @lazydispatch('foo. Bar') + def nothing(obj): + return 'fail' + + print(nothing(5)) + +def test_predicate_registration(): + GLOBAL_DISPATCHERS.clear() + + @lazydispatch(object, scope='local') + def describe(obj): + return 'default' + + @lazydispatch(predicate=lambda x: isinstance(x, tuple), scope='local') + def describe(obj): + return 'tuple' + + assert callable(describe), str(describe) + assert describe(5) == 'default' + assert describe((15, 13)) == 'tuple' + +def test_lazydispatch_int(): + + @lazydispatch(int) + def int_func(obj): + return obj + 1 + + assert int_func(5) == 6 + +def test_lazydispatch_int_type(): + + @lazydispatch(int) + def int_func2(obj): + return obj + 1 + + assert int_func2(5) == 6 + + @lazydispatch(type) + def int_func2(obj): + return f'type: {str(obj)}' + + assert int_func2(5) == 6 + assert int_func2(int) == 'type: ' + +def test_lazydispatch_int_type_pred(): + + @lazydispatch(int) + def int_func3(obj): + return obj + 1 + + assert int_func3(5) == 6 + + @lazydispatch(type) + def int_func3(obj): + return f'type: {str(obj)}' + + assert int_func3(5) == 6 + assert int_func3(int) == 'type: ' + + @lazydispatch(predicate=lambda x: isinstance(x, list)) + def int_func3(obj): + return f'list: {str(obj)}' + + assert int_func3(5) == 6 + assert int_func3(int) == 'type: ' + assert int_func3([1, 2, 3]) == 'list: [1, 2, 3]' + +def test_lazydispatch_int_type_pred_func(): + + @lazydispatch(object) + def int_func4(obj): + return str(obj) + + assert int_func4(5) == '5' + + @lazydispatch(type) + def int_func4(obj): + return f'type: {str(obj)}' + + assert int_func4(5) == '5' + assert int_func4(int) == 'type: ' + + @lazydispatch(predicate=lambda x: isinstance(x, list)) + def int_func4(obj): + return f'list: {str(obj)}' + + assert int_func4(5) == '5' + assert int_func4(int) == 'type: ' + assert int_func4([1, 2, 3]) == 'list: [1, 2, 3]' + + # return 0 + @lazydispatch(types.FunctionType) + def int_func4(obj): + return f'func: {str(obj)}' + + assert int_func4(5) == '5' + assert int_func4(int) == 'type: ' + assert int_func4([1, 2, 3]) == 'list: [1, 2, 3]' + assert int_func4(lambda: 'lambda').startswith('func:') + +if __name__ == '__main__': + main() diff --git a/lib/evn/evn/tests/_prelude/test_lazy_import.py b/lib/evn/evn/tests/_prelude/test_lazy_import.py new file mode 100644 index 00000000..4ee820c8 --- /dev/null +++ b/lib/evn/evn/tests/_prelude/test_lazy_import.py @@ -0,0 +1,50 @@ +import sys + +import pytest + +import evn +from evn._prelude.lazy_import import _LazyModule, lazyimport + +testconfig = evn.Bunch(nocapture=['test_broken_package'], ) + +def main(): + evn.testing.quicktest(namespace=globals(), config=testconfig) + +def test_broken_package(): + if 'doctest' not in sys.modules: + borked = lazyimport('evn.tests._prelude.broken_py_file') + with pytest.raises(ImportError): + borked.foo + +def test_maybeimport(): + re = evn.maybeimport('re') + assert re is sys.modules['re'] + missing = evn.maybeimport('noufuomemioixecmeiorutnaufoinairesvoraisevmraoui') + assert not missing + missing = evn.maybeimports('noufuomem ioixecmeiorutnaufoina iresvoraisevmraoui') + assert not any(missing) + +def test_lazyimport_re(): + re = evn.lazyimport('re') + assert isinstance(re, _LazyModule) + assert 2 == len(re.findall('foo', 'foofoo')) + assert isinstance(re, _LazyModule) + +def test_lazyimport_this(): + this = evn.lazyimport('this') + assert not this._lazymodule_is_loaded() + with evn.capture_stdio() as poem: + assert this.c == 97 + assert 'The Zen of Python, by Tim Peters' == evn.first(poem.readlines()).strip() + assert this._lazymodule_is_loaded() + +def helper_test_re_ft_it(re, ft, it): + assert 2 == len(re.findall('foo', 'foofoo')) + assert ft.partial(lambda x, y: x + y, 1)(2) == 3 + assert list(it.chain([0], [1], [2])) == [0, 1, 2] + +def test_multi_lazyimport_args(): + helper_test_re_ft_it(*evn.lazyimports('re', 'functools', 'itertools')) + +if __name__ == '__main__': + main() diff --git a/lib/evn/evn/tests/_prelude/test_make_decorator.py b/lib/evn/evn/tests/_prelude/test_make_decorator.py new file mode 100644 index 00000000..f0b5afe4 --- /dev/null +++ b/lib/evn/evn/tests/_prelude/test_make_decorator.py @@ -0,0 +1,320 @@ +import types +import pytest +import evn + + +def main(): + evn.testing.quicktest( + namespace=globals(), + config=evn.Bunch(re_only=[], re_exclude=[]), + verbose=1, + check_xfail=False, + chrono=False, + ) + + +def test_deco(): + result = '' + + @evn.make_decorator + def foo(func, *args, **kwargs): + nonlocal result + result += 'prefoo' + return func(*args, **kwargs) + + def baz(): + pass + + @foo + def bar(): + nonlocal result + result += 'bar' + + bar() + assert result == 'prefoobar' # Check the result of the decorator + + +def test_deco_config_default(): + result = '' + + @evn.make_decorator(msg='baz') + def foo(func, *args, msg, **kwargs): + nonlocal result + result += msg + return func(*args, **kwargs) + + @foo + def bar(): + nonlocal result + result += 'bar' + + bar() + assert result == 'bazbar' # Check the result of the decorator + result = '' + + +def test_deco_config(): + result = '' + + @evn.make_decorator(msg='baz') + def foo(func, *args, msg=None, **kwargs): + nonlocal result + result += msg + return func(*args, **kwargs) + + @foo(msg='aaaaaa') + def aaa(): + nonlocal result + result += 'bar' + + aaa() + assert result == 'aaaaaabar' # Check the result of the decorator with different message + + +def test_deco_not_callable_error(): + with pytest.raises(TypeError): + + @evn.make_decorator('baz') + def foo(func, *args, **kwargs): + return func(*args, **kwargs) + + +def test_deco_config_kwargs_error(): + result = '' + + @evn.make_decorator(msg='baz') + def foo(func, *args, msg='', **kwargs): + nonlocal result + result += msg + return func(*args, **kwargs) + + with pytest.raises(TypeError): + + @foo(msg2='aaaaaa') + def aaa(): + nonlocal result + result += 'bar' + + +def test_deco_config_args_error(): + result = '' + + @evn.make_decorator(msg='baz') + def foo(func, *args, msg='', **kwargs): + nonlocal result + result += msg + return func(*args, **kwargs) + + with pytest.raises(TypeError): + + @foo(msg2='aaaaaa') + def aaa(): + nonlocal result + result += 'bar' + + +def test_deco_method(): + + @evn.make_decorator(extra=0) + def plus_this(func, *args, extra, **kwargs): + return func(*args, **kwargs) + extra + + class Foo: + + @plus_this(extra=3) + def add(self, a, b): + return a + b + + def mul(self, a, b): + return a * b + + foo = Foo() + assert foo.add(1, 2) == 6 + assert foo.mul(1, 2) == 2 + + +def test_deco_class(): + + class Foo: + + def add(self, a, b): + return a + b + + def mul(self, a, b): + return a * b + + foo = Foo() + assert foo.add(1, 2) == 3 + assert foo.mul(1, 2) == 2 + + @evn.make_decorator(extra=0) + def plus_this(func, *args, extra, **kwargs): + return func(*args, **kwargs) + extra + + @plus_this(extra=5) + class Bar: + + def add(self, a, b): + return a + b + + def mul(self, a, b): + return a * b + + bar = Bar() + assert bar.add(1, 2) == 8 + assert bar.mul(1, 2) == 7 + + +def test_basic_function_decorator(): + log = [] + + @evn.make_decorator + def logger(func, *args, **kwargs): + log.append(f'calling {func.__name__}') + return func(*args, **kwargs) + + @logger + def foo(): + log.append('foo ran') + return 42 + + result = foo() + assert result == 42 + assert log == ['calling foo', 'foo ran'] + + +def test_configurable_decorator_default_and_override(): + log = [] + + @evn.make_decorator(prefix='>> ') + def trace(func, *args, prefix, **kwargs): + log.append(prefix + func.__name__) + return func(*args, **kwargs) + + @trace + def one(): + log.append('one') + + @trace(prefix='** ') + def two(): + log.append('two') + + one() + two() + assert log == ['>> one', 'one', '** two', 'two'] + + +def test_strict_mode_disallows_unknown_config(): + + @evn.make_decorator(msg='ok', strict=True) + def f(func, *args, msg, **kwargs): + return func(*args, **kwargs) + + with pytest.raises(TypeError): + + @f(extra='bad') + def nope(): + pass + + +def test_non_callable_userwrap_raises(): + with pytest.raises(TypeError): + evn.make_decorator(123) + + +def test_decorator_metadata_preserved(): + + @evn.make_decorator + def dummy(func, *args, **kwargs): + return func(*args, **kwargs) + + @dummy + def my_func(): + """This is a docstring.""" + return 7 + + assert my_func.__name__ == 'my_func' + assert my_func.__doc__ == 'This is a docstring.' + assert isinstance(my_func, types.FunctionType) # Still a function + + +def test_decorator_on_instance_method(): + + @evn.make_decorator(extra=1) + def bump(func, *args, extra, **kwargs): + return func(*args, **kwargs) + extra + + class Thing: + + @bump(extra=3) + def do(self, x): + return x + + t = Thing() + assert t.do(4) == 7 + + +def test_decorator_on_class_entirely(): + + @evn.make_decorator(suffix=1) + def plus(func, *args, suffix, **kwargs): + return func(*args, **kwargs) + suffix + + @plus(suffix=5) + class Math: + + def add(self, x, y): + return x + y + + def mul(self, x, y): + return x * y + + m = Math() + assert m.add(1, 2) == 8 + assert m.mul(2, 3) == 11 + + +def test_classmethod_and_staticmethod_wrapping(): + calls = [] + + @evn.make_decorator(tag='') + def logcall(func, *args, tag, **kwargs): + calls.append(f'{tag}:{func.__name__}') + return func(*args, **kwargs) + + @logcall(tag='X') + class Example: + + @classmethod + def cls_method(cls): + return 'cls' + + @staticmethod + def stat_method(): + return 'stat' + + assert Example.cls_method() == 'cls' + assert Example.stat_method() == 'stat' + assert calls == ['X:cls_method', 'X:stat_method'] + + +def test_nested_configuration_application(): + + @evn.make_decorator(greeting='hi') + def greeter(func, *args, greeting, **kwargs): + return f'{greeting}, {func(*args, **kwargs)}' + + @greeter + def name(): + return 'Alice' + + @greeter(greeting='hello') + def name2(): + return 'Bob' + + assert name() == 'hi, Alice' + assert name2() == 'hello, Bob' + + +if __name__ == '__main__': + main() diff --git a/lib/evn/evn/tests/_prelude/test_typehints.py b/lib/evn/evn/tests/_prelude/test_typehints.py new file mode 100644 index 00000000..a454ea39 --- /dev/null +++ b/lib/evn/evn/tests/_prelude/test_typehints.py @@ -0,0 +1,81 @@ +import unittest +from evn import isstr, isint, islist, isdict, isseq, ismap, isseqmut, ismapmut, isiter + +import evn + +config_test = evn.Bunch( + re_only=[ + # + ], + re_exclude=[ + # + ], +) + + +def main(): + evn.testing.quicktest( + namespace=globals(), + config=config_test, + verbose=1, + check_xfail=False, + ) + + +class TestTypeCheckers(unittest.TestCase): + + def test_isstr(self): + self.assertTrue(isstr('hello')) + self.assertFalse(isstr(123)) + self.assertFalse(isstr([])) + + def test_isint(self): + self.assertTrue(isint(42)) + self.assertFalse(isint('42')) + self.assertFalse(isint(3.14)) + + def test_islist(self): + self.assertTrue(islist([1, 2, 3])) + self.assertFalse(islist((1, 2, 3))) + self.assertFalse(islist('list')) + + def test_isdict(self): + self.assertTrue(isdict({'key': 'value'})) + self.assertFalse(isdict([('key', 'value')])) + self.assertFalse(isdict('dict')) + + def test_isseq(self): + self.assertTrue(isseq([1, 2, 3])) + self.assertTrue(isseq((1, 2, 3))) + self.assertTrue(isseq('sequence')) + self.assertFalse(isseq(42)) + + def test_ismap(self): + self.assertTrue(ismap({'key': 'value'})) + self.assertFalse(ismap([('key', 'value')])) + self.assertFalse(ismap('map')) + + def test_isseqmut(self): + self.assertTrue(isseqmut([1, 2, 3])) + self.assertFalse(isseqmut((1, 2, 3))) # Tuples are not mutable + self.assertFalse(isseqmut('string')) # Strings are immutable + + def test_ismapmut(self): + self.assertTrue(ismapmut({'key': 'value'})) + self.assertFalse(ismapmut(frozenset([('key', 'value') + ]))) # frozenset is immutable + self.assertFalse(ismapmut('map')) + + def test_isiter(self): + self.assertTrue(isiter([1, 2, 3])) + self.assertTrue(isiter((1, 2, 3))) + self.assertTrue(isiter('iterable')) + self.assertTrue(isiter({1, 2, 3})) + self.assertFalse(isiter(42)) + + +if __name__ == '__main__': + unittest.main() + +if __name__ == '__main__': + main() diff --git a/lib/evn/evn/tests/cli/test_auto_click_decorator.py b/lib/evn/evn/tests/cli/test_auto_click_decorator.py new file mode 100644 index 00000000..6aa4f157 --- /dev/null +++ b/lib/evn/evn/tests/cli/test_auto_click_decorator.py @@ -0,0 +1,196 @@ +# evn/tests/cli/test_auto_click_decorator.py +import pytest +import click +import typing +from click.testing import CliRunner +from evn.cli.auto_click_decorator import auto_click_decorate_command +from evn.cli.click_type_handler import ClickTypeHandler, ClickTypeHandlers, HandlerNotFoundError, MetadataPolicy + +# Import default basic handlers from our basic conversion layer. +from evn.cli.basic_click_type_handlers import BasicStringHandler + + +class MetadataIgnoreHandler(ClickTypeHandler): + __supported_types__ = {int: MetadataPolicy.REQUIRED} + __priority_bonus__ = 0 + + def convert(self, value, param, ctx): + return value + + +# Prepare a list of type handlers. +TYPE_HANDLERS = ClickTypeHandlers([BasicStringHandler, MetadataIgnoreHandler]) + + +# Dummy command with no manual decorators; use click.echo to print output. +def dummy_command(a: int, b: str, c: bool = False): + """Dummy command for testing auto-decoration.""" + click.echo(f'{a}-{b}-{c}') + + +# Auto-decorate the function. +auto_dummy_command = auto_click_decorate_command(dummy_command, TYPE_HANDLERS) +# Now wrap it with click.command. +auto_dummy_command = click.command()(auto_dummy_command) + + +# Define a function with a manual option for parameter b. +def partial_manual(a: int, b: str): + """Partial manual command for testing merging.""" + click.echo(f'{a}-{b}') + + +# Manually decorate parameter b. +partial_manual = click.option('--b', type=str, + default='manual')(partial_manual) +auto_partial_manual = auto_click_decorate_command(partial_manual, + TYPE_HANDLERS) +auto_partial_manual = click.command()(auto_partial_manual) + + +# Test using Annotated for parameter. +def annotated_command(a: typing.Annotated[int, 'metadata info'], b: str): + """Command with Annotated parameter.""" + click.echo(f'{a}-{b}') + + +auto_annotated_command = auto_click_decorate_command(annotated_command, + TYPE_HANDLERS) +auto_annotated_command = click.command()(auto_annotated_command) + + +# Test with an internal parameter that should be skipped (and supplied as None). +def internal_param_command(a: int, _internal: str, b: str): + """Command that has an internal parameter (_internal) that should be ignored.""" + # _internal should be supplied as None. + click.echo(f'{a}-{_internal}-{b}') + + +auto_internal_command = auto_click_decorate_command(internal_param_command, + TYPE_HANDLERS) +auto_internal_command = click.command()(auto_internal_command) + + +# Test with multiple parameters and defaults. +def complex_command(a: int, + b: str = 'default', + c: bool = False, + d: float = 3.14): + """A command with mixed required and optional parameters.""" + click.echo(f'{a}-{b}-{c}-{d}') + + +auto_complex_command = auto_click_decorate_command(complex_command, + TYPE_HANDLERS) +auto_complex_command = click.command()(auto_complex_command) + + +def test_auto_generated_parameters(): + runner = CliRunner() + # 'a' and 'b' are required, 'c' is an optional boolean flag. + result = runner.invoke(auto_dummy_command, ['123', 'hello']) + assert result.exit_code == 0 + assert '123-hello-False' in result.output + result = runner.invoke(auto_dummy_command, ['123', 'hello', '--c']) + assert result.exit_code == 0 + assert '123-hello-True' in result.output + + +def test_manual_decorator_merging(): + runner = CliRunner() + result = runner.invoke(auto_partial_manual, + ['789', '--b', 'manual_override']) + assert result.exit_code == 0 + # Expect the output to reflect the manual override. + assert '789-manual_override' in result.output + + +def test_annotated_parameter(): + runner = CliRunner() + # Even though we use Annotated for parameter a, it should be processed as int. + result = runner.invoke(auto_annotated_command, ['321', 'world']) + assert result.exit_code == 0 + assert '321-world' in result.output + + +def test_internal_parameter_skipped(): + runner = CliRunner() + # _internal is not exposed; only provide values for a and b. + result = runner.invoke(auto_internal_command, ['111', 'visible']) + assert result.exit_code == 0 + # The output should show that _internal is None. + assert '111-None-visible' in result.output + + +def test_complex_command_defaults(): + runner = CliRunner() + # Only a is required; others should use defaults. + result = runner.invoke(auto_complex_command, ['555']) + assert result.exit_code == 0 + # Expected output: "555-default-False-3.14" + assert '555-default-False-3.14' in result.output + + +def test_error_on_manual_click_command(): + # Define a function already wrapped with click.command. + @click.command() + def already_command(x: int): + click.echo(str(x)) + + with pytest.raises(RuntimeError): + auto_click_decorate_command(already_command, TYPE_HANDLERS) + + +def test_handler_skipped_without_required_metadata(): + + class DummyHandler(ClickTypeHandler): + __test__ = False + __supported_types__ = {int: MetadataPolicy.REQUIRED} + + def convert(self, value, param, ctx): + return int(value) + + handlers = ClickTypeHandlers() + handlers.add(DummyHandler()) + + result = handlers.typehint_to_click_paramtype(int, 'foo') + assert isinstance(result, DummyHandler) + + result = handlers.typehint_to_click_paramtype(int, None) + assert issubclass(result, int) + + +def test_handler_priority_affects_resolution(): + + class LowPriority(ClickTypeHandler): + __test__ = False + __supported_types__ = {int: MetadataPolicy.REQUIRED} + __priority_bonus__ = 1 + + def convert(self, value, param, ctx): + return 1 + + class HighPriority(ClickTypeHandler): + __test__ = False + __supported_types__ = {int: MetadataPolicy.REQUIRED} + __priority_bonus__ = 10 + + def convert(self, value, param, ctx): + return 2 + + handlers = ClickTypeHandlers() + handlers.add(LowPriority()) + handlers.add(HighPriority()) + + param_type = handlers.typehint_to_click_paramtype(int, ('some-metadata', )) + assert param_type.convert('42', None, None) == 2 + + +def test_no_handler_returns_none(): + handlers = ClickTypeHandlers() + with pytest.raises(HandlerNotFoundError): + result = handlers.typehint_to_click_paramtype(dict, None) + + +if __name__ == '__main__': + pytest.main([__file__]) diff --git a/lib/evn/evn/tests/cli/test_basic_click_type_handlers.py b/lib/evn/evn/tests/cli/test_basic_click_type_handlers.py new file mode 100644 index 00000000..98743b0e --- /dev/null +++ b/lib/evn/evn/tests/cli/test_basic_click_type_handlers.py @@ -0,0 +1,78 @@ +# test_basic_click_type_handlers.py +import pytest +import click +import datetime +from evn.cli.basic_click_type_handlers import ( + BasicStringHandler, + BasicPathHandler, + BasicChoiceHandler, + BasicIntRangeHandler, + BasicFloatRangeHandler, + BasicDateTimeHandler, +) + + +# Dummy parameter for testing. +class DummyParam: + pass + + +# Use Click's context. +ctx = click.get_current_context(silent=True) + + +def test_basic_string_handler(): + handler = BasicStringHandler() + result = handler.convert('hello', DummyParam(), ctx) + assert result == 'hello' + + +def test_basic_path_handler(tmp_path): + handler = BasicPathHandler() + result = handler.convert(str(tmp_path), DummyParam(), ctx) + # click.Path returns a string by default. + assert isinstance(result, str) + # Optionally, ensure the result equals the input path. + assert result == str(tmp_path) + + +def test_basic_choice_handler(): + handler = BasicChoiceHandler() + handler.choices = ['apple', 'banana', 'cherry'] + result = handler.convert('banana', DummyParam(), ctx) + assert result == 'banana' + with pytest.raises(click.BadParameter): + handler.convert('durian', DummyParam(), ctx) + + +def test_basic_int_range_handler(): + handler = BasicIntRangeHandler() + handler.min = 10 + handler.max = 20 + result = handler.convert('15', DummyParam(), ctx) + assert result == 15 + with pytest.raises(click.BadParameter): + handler.convert('5', DummyParam(), ctx) + + +def test_basic_float_range_handler(): + handler = BasicFloatRangeHandler() + handler.min = 1.0 + handler.max = 2.0 + result = handler.convert('1.5', DummyParam(), ctx) + assert result == pytest.approx(1.5) + with pytest.raises(click.BadParameter): + handler.convert('0.5', DummyParam(), ctx) + + +def test_basic_datetime_handler(): + handler = BasicDateTimeHandler() + handler.formats = ('%Y-%m-%d', ) + date_str = '2023-01-01' + result = handler.convert(date_str, DummyParam(), ctx) + expected = datetime.datetime.strptime(date_str, '%Y-%m-%d') + assert result == expected + + +if __name__ == '__main__': + pytest.main([__file__]) diff --git a/lib/evn/evn/tests/cli/test_cli_command_resolver.py b/lib/evn/evn/tests/cli/test_cli_command_resolver.py new file mode 100644 index 00000000..b25034f7 --- /dev/null +++ b/lib/evn/evn/tests/cli/test_cli_command_resolver.py @@ -0,0 +1,52 @@ +# test_cli_command_resolver.py +import pytest +from click import Command +from evn.cli.cli_command_resolver import walk_commands, find_command, get_all_cli_paths +from evn.cli.cli_metaclass import CLI + +pytestmark = pytest.mark.usefixtures('capfd') + + +# CLI hierarchy using inheritance +class CLITestTop(CLI): + + def greet(self, name: str): + return f'Hi {name}' + + +class CLITestSub(CLITestTop): + + def hello(self, name: str): + return f'Hello {name}' + + +def test_walk_commands_finds_all(): + paths = [p for p, _ in walk_commands(CLITestTop)] + # print("\nwalked command paths:", paths) + assert ' testtop greet' in paths + assert ' testtop testsub hello' in paths + + +def test_find_command_by_path(): + cmd = find_command(CLITestTop, 'testsub') + assert isinstance(cmd, Command) + sub_cmd = find_command(CLITestTop, 'testsub.hello') + assert isinstance(sub_cmd, Command) + + +def test_get_all_cli_paths_contains_expected(): + paths = get_all_cli_paths() + # print("all CLI paths:", paths) + assert any('greet' in p for p in paths) + assert any('testsub hello' in p for p in paths) + + +def test_get_all_cli_paths_unique(): + paths = get_all_cli_paths() + for k in paths: + assert paths.count(k) == 1, f'Duplicate path found: {k}' + + +def test_find_command_invalid_path(): + with pytest.raises(KeyError): + find_command(CLITestTop, 'nonexistent.command') diff --git a/lib/evn/evn/tests/cli/test_cli_config.py b/lib/evn/evn/tests/cli/test_cli_config.py new file mode 100644 index 00000000..cc799119 --- /dev/null +++ b/lib/evn/evn/tests/cli/test_cli_config.py @@ -0,0 +1,101 @@ +from pathlib import Path +import evn +from evn.testing import TestApp as App + + +def main(): + # test_set_app_defaults_from_config() + # return + evn.quicktest( + namespace=globals(), + verbose=1, + check_xfail=False, + use_test_classes=True, + # re_only=['test_generic_get_items'], + re_exclude=[], + ) + + +def test_config_from_app(): + config = evn.config.get_config(App) + # evn.show(config, format='forest', max_width=111) + confstr = str(evn.unbunchify(config)) + assert '_static_func' not in confstr + assert '_private_func' not in confstr + assert '_private_method' not in confstr + assert 'name-with-under-scores' not in confstr + assert 'name_with_under_scores' in confstr + # config.testapp.doccheck.doctest.fail-loopverbose = + + +def test_convert_config_to_app_types(): + config = evn.config.get_config(App) + assert config._conf('split') == ' ' + assert config.testapp._conf('split') == ' ' + assert config.testapp._conf('split') == ' ' + assert config.testapp.doccheck._conf('split') == ' ' + # evn.config.print_config(config) + config = evn.cli.convert_config_to_app_types(App, config) + + +def test_set_app_defaults_from_config(): + config = evn.config.get_config(App) + config.testapp.version.verbose = True + evn.cli.set_app_defaults_from_config(App, config) + config2 = evn.cli.get_config_from_app_defaults(App) + assert config == config2 + + +def test_set_app_callback_defaults_from_config(): + config = evn.config.get_config(App) + evn.cli.set_app_defaults_from_config(App, config) + config2 = evn.cli.get_config_from_app_defaults(App) + if config != config2: + print('fail test_set_app_defaults_from_config:') + evn.diff(config, config2, out=print) + assert config == config2 + config.testapp._callback.foo = 'bar' + + +def test_big_change(): + + def mutate(cfg, prm, group, path, param): + new = param.default + if isinstance(new, str): + new = f'"{new}foo"' + elif isinstance(new, bool): + new = not new + elif isinstance(new, int): + new = new + 1 + elif isinstance(new, float): + new = new * 2 + elif isinstance(new, Path): + new = new.parent + # print(f'config.{'.'.join(path.split())}.{name}{param.name} = {new}') + cfg[param.name] = new + + config = evn.config.get_config(App) + mutated = evn.cli.mutate_config(App, config, action_func=mutate) + # evn.diff(config, mutated, out=print) + assert config != mutated + print('\nconfig', config.testapp.buildtools.clean.all.verbose) + print('mutate', mutated.testapp.buildtools.clean.all.verbose) + evn.cli.set_app_defaults_from_config(App, mutated) + print('config', config.testapp.buildtools.clean.all.verbose) + print('mutate', mutated.testapp.buildtools.clean.all.verbose) + assert config != mutated + config2 = evn.cli.get_config_from_app_defaults(App) + evn.cli.set_app_defaults_from_config(App, config) # restore + assert config != mutated + assert config2 == mutated + assert config != config2 + # evn.diff(mutated, config, out=print) + # evn.diff(mutated, config2, out=print) + if mutated != config2: + print('fail test_big_change:') + evn.diff(mutated, config2, out=print) + assert mutated == config2 + + +if __name__ == '__main__': + main() diff --git a/lib/evn/evn/tests/cli/test_cli_logger.py b/lib/evn/evn/tests/cli/test_cli_logger.py new file mode 100644 index 00000000..44666221 --- /dev/null +++ b/lib/evn/evn/tests/cli/test_cli_logger.py @@ -0,0 +1,63 @@ +from evn.cli.cli_logger import CliLogger + + +class DummyCLI: + __log__ = [] + + @classmethod + def get_command_path(cls): + return 'DummyCLI' + + +def test_basic_logging(): + CliLogger.clear(DummyCLI) + CliLogger.log(DummyCLI, + 'test message', + event='test_event', + data={'key': 123}) + log = CliLogger.get_log(DummyCLI) + assert len(log) == 1 + entry = log[0] + assert entry['message'] == 'test message' + assert entry['event'] == 'test_event' + assert entry['data'] == {'key': 123} + assert 'timestamp' in entry + assert entry['path'] == 'DummyCLI' + + +def test_log_clear(): + CliLogger.log(DummyCLI, 'will clear') + assert CliLogger.get_log(DummyCLI) + CliLogger.clear(DummyCLI) + assert CliLogger.get_log(DummyCLI) == [] + + +def test_log_event_context(capsys): + CliLogger.clear(DummyCLI) + with CliLogger.log_event_context(DummyCLI, + 'context_event', + data={'foo': 'bar'}): + pass + log = CliLogger.get_log(DummyCLI) + assert len(log) == 2 + assert log[0]['message'] == 'begin context_event' + assert log[1]['message'] == 'end context_event' + assert log[0]['data'] == log[1]['data'] == {'foo': 'bar'} + + +def test_class_logging_does_not_call_instance_method(): + + class DummyWithInstancePath: + __log__ = [] + + @classmethod + def get_command_path(cls): + return 'DummyWithInstancePath' + + def get_full_path(self): + return 'fail-if-called' + + # Call logger with class, not instance + CliLogger.log(DummyWithInstancePath, 'hello') + log = CliLogger.get_log(DummyWithInstancePath) + assert log[0]['path'] == 'DummyWithInstancePath' diff --git a/lib/evn/evn/tests/cli/test_cli_metaclass.py b/lib/evn/evn/tests/cli/test_cli_metaclass.py new file mode 100644 index 00000000..15240ccd --- /dev/null +++ b/lib/evn/evn/tests/cli/test_cli_metaclass.py @@ -0,0 +1,209 @@ +# evn/tests/cli/test_cli_metaclass.py +import pytest +import click +from click.testing import CliRunner +from evn.cli.cli_metaclass import CLI +from evn.cli.auto_click_decorator import auto_click_decorate_command +from evn.cli.cli_logger import CliLogger +from evn.cli.click_type_handler import ClickTypeHandlers + +runner = CliRunner() + + +# Define a dummy parent CLI tool. +class CLIParent(CLI): + __type_handlers__ = ClickTypeHandlers( + ) # For testing, no extra handlers needed. + + def _callback(self, debug: bool = False): + if debug: + click.echo('parent debug is on') + self._parent_debug = debug + + def greet(self, name: str): + "Command that greets a person." + click.echo(f'Hello, {name}!') + + +# Define a dummy child CLI tool. +class CLIChild(CLIParent): + __type_handlers__ = ClickTypeHandlers() # Inherit parent's handlers. + + def _callback(self, debug: bool = False): + if debug: + click.echo('child debug is on') + self._child_debug = debug + + def farewell(self, name: str): + "Command that says goodbye." + click.echo(f'Goodbye, {name}!') + + +def test_root_is_CLI(): + assert CLIChild.get_command_path() == ' parent child' + assert CLIChild._root().__name__ == 'CLI' + + +def test_singleton_behavior(): + parent1 = CLIParent() + parent2 = CLIParent() + assert parent1 is parent2 + child1 = CLIChild() + child2 = CLIChild() + assert child1 is child2 + + +def test_parent_command_registration(): + runner = CliRunner() + # CLIParent's group should include the 'greet' command. + result = runner.invoke(CLIParent.__group__, ['greet', 'Alice']) + assert result.exit_code == 0 + assert 'Hello, Alice!' in result.output + + +def test_child_command_registration(): + runner = CliRunner() + # CLIChild's group is attached as a subcommand of CLIParent's group. + # Invoke as: parent group 'child' then command 'farewell'. + result = runner.invoke(CLIParent.__group__, ['child', 'farewell', 'Bob']) + assert result.exit_code == 0 + assert 'Goodbye, Bob!' in result.output + + +def test_child_callback(): + runner = CliRunner() + result = runner.invoke(CLIParent.__group__, + ['child', '--debug', 'farewell', 'Bob']) + # ic(result.output) + assert result.exit_code == 0 + assert 'Goodbye, Bob!' in result.output + assert 'child debug is on' in result.output + assert 'parent debug is on' not in result.output + + +def test_parent_callback(): + runner = CliRunner() + result = runner.invoke(CLIParent.__group__, + ['--debug', 'child', 'farewell', 'Bob']) + assert result.exit_code == 0 + assert 'Goodbye, Bob!' in result.output + assert 'child debug is on' not in result.output + assert 'parent debug is on' in result.output + + +def test_parent_and_child_callback(): + runner = CliRunner() + result = runner.invoke(CLIParent.__group__, + ['--debug', 'child', '--debug', 'farewell', 'Bob']) + assert result.exit_code == 0 + assert 'Goodbye, Bob!' in result.output + assert 'child debug is on' in result.output + assert 'parent debug is on' in result.output + + +def test_instance_logging(): + parent_instance = CLIParent() + logs = CliLogger.get_log(CLIParent) + found = any('Instance created' in log.get('message', '') for log in logs) + assert found + + +def test_get_full_path(): + parent_instance = CLIParent() + child_instance = CLIChild() + assert parent_instance.get_full_path() == 'CLI CLIParent' + assert child_instance.get_full_path() == 'CLI CLIParent CLIChild' + + +def test_greet_command(): + runner = CliRunner() + result = runner.invoke(CLIParent.__group__, ['greet', 'Alice']) + + +def test_greet_command_exists(): + assert 'greet' in CLIParent.__group__.commands + + +class CLIDebugTest(CLI): + + def greet(self, name: str, default=7): + "Basic test for argument passing." + click.echo(f'Hello, {name}!') + + +def test_debug_sanity_command_runs(): + runner = CliRunner() + result = runner.invoke(CLIDebugTest.__group__, ['greet', 'TestUser']) + assert result.exit_code == 0 + assert 'Hello, TestUser!' in result.output + + +def test_click_metadata_capture(): + decorated = auto_click_decorate_command(CLIDebugTest.greet, + ClickTypeHandlers) + assert [['--default'], ['name'] + ] == [p.opts for p in getattr(decorated, '__click_params__', [])] + assert {} == getattr(decorated, '__click_attrs__', {}) + + +def test_empty_cli_group_creates_successfully(): + + class EmptyCLI(CLI): + pass + + assert isinstance(EmptyCLI.__group__, click.Group) + assert len(EmptyCLI.__group__.commands) == 0 + + +def test_command_override(): + + class BaseCLI(CLI): + + def greet(self): + click.echo('base') + + class SubCLI(BaseCLI): + + def greet(self, name: str = 'world'): + click.echo('sub') + + result = SubCLI._test(['greet']) + assert 'base' not in result.output + assert 'sub' in result.output + result = SubCLI._testroot(['base greet']) + assert 'base' in result.output + assert 'sub' not in result.output + result = SubCLI._testroot(['sub', 'greet']) + assert 'base' not in result.output + assert 'sub' in result.output + + +def test_command_test_noarg(): + + class TestCLI(CLI): + + def greet(self): + click.echo('Hello') + + result = TestCLI._test(['greet']) + assert result.exit_code == 0 + assert 'Hello' in result.output + + +def test_default_option(): + + class CliGreet(CLI): + + def greet(self, name: str = 'world'): + click.echo(f'Hello, {name}') + + result = CliGreet._test(['greet']) + assert 'Hello, world' in result.output + result = CliGreet._test(['greet', '--name', 'Alice']) + assert 'Hello, Alice' in result.output + assert result.exit_code == 0 + + +if __name__ == '__main__': + pytest.main([__file__]) +9 diff --git a/lib/evn/evn/tests/cli/test_cli_registry.py b/lib/evn/evn/tests/cli/test_cli_registry.py new file mode 100644 index 00000000..5c576bfa --- /dev/null +++ b/lib/evn/evn/tests/cli/test_cli_registry.py @@ -0,0 +1,44 @@ +import click +from evn.cli.cli_registry import CliRegistry +from evn.cli.cli_metaclass import CLI + + +def setup_module(): + CliRegistry.reset() + + +class CLIExample(CLI): + + def hi(self, name: str): + click.echo(f'Hi {name}!') + + +def test_register_and_reset(): + assert CLIExample in CliRegistry.all_cli_classes() + CLIExample() # instantiate singleton + assert hasattr(CLIExample, '_instance') + assert hasattr(CLIExample, '__log__') + CLIExample._log('Test log') + assert len(CLIExample.__log__) > 0 + + CliRegistry.reset() + assert not hasattr(CLIExample, '_instance') + assert CLIExample.__log__ == [] + + +def test_print_summary(capsys): + CliRegistry.print_summary() + captured = capsys.readouterr() + assert 'CLIExample' in captured.out + assert 'group' in captured.out + + +def test_get_root_commands(): + roots = CliRegistry.get_root_commands() + group_name = CLIExample.__group__.name + assert group_name in roots, f"Expected group '{group_name}' in {roots.keys()}" + + +def test_registry_unique(): + clis = CliRegistry.all_cli_classes() + assert len(clis) == len(set(clis)) diff --git a/lib/evn/evn/tests/cli/test_click_type_handler.py b/lib/evn/evn/tests/cli/test_click_type_handler.py new file mode 100644 index 00000000..8f19592d --- /dev/null +++ b/lib/evn/evn/tests/cli/test_click_type_handler.py @@ -0,0 +1,193 @@ +# evn/tests/cli/test_click_type_handler.py +import pytest +import click +from evn.cli.click_type_handler import ClickTypeHandler, MetadataPolicy, get_cached_paramtype + +def main(): + import evn + evn.testing.quicktest(globals()) + +class DummyParam: + pass + +# Dummy handler classes for testing. Mark them as not tests. +class DummyIntHandler(ClickTypeHandler): + __supported_types__ = {int: MetadataPolicy.OPTIONAL} + __priority_bonus__ = 5 + + def convert(self, value, param, ctx): + try: + preprocessed = self.preprocess_value(value) + converted = int(preprocessed) + return self.postprocess_value(converted) + except Exception as e: + self.fail(f'DummyIntHandler conversion failed: {e}', param, ctx) + +class DummyBoolHandler(ClickTypeHandler): + __supported_types__ = {bool: MetadataPolicy.OPTIONAL} + __priority_bonus__ = 2 + + def convert(self, value, param, ctx): + val = self.preprocess_value(value) + if isinstance(val, str) and val.lower() in ['true', '1', 'yes']: + return True + elif isinstance(val, str) and val.lower() in ['false', '0', 'no']: + return False + else: + self.fail(f'DummyBoolHandler conversion failed for {value}', param, ctx) + +class DummyListHandler(ClickTypeHandler): + __test__ = False + # For list types, we require metadata to specify the element type. + __supported_types__ = {list: MetadataPolicy.REQUIRED} + __priority_bonus__ = 3 + + def convert(self, value, param, ctx): + raw_list = self.preprocess_value(value) + if not isinstance(raw_list, str): + self.fail('Expected a string for list conversion', param, ctx) + items = [item.strip() for item in raw_list.split(',')] + try: + # Use metadata to determine the element type; assume metadata is stored as self.metadata. + element_type = self.metadata_element_type # Will be set in preprocess_value. + except AttributeError: + self.fail('Missing element type metadata', param, ctx) + try: + converted = [element_type(item) for item in items] + return self.postprocess_value(converted) + except Exception as e: + self.fail(f'List conversion failed: {e}', param, ctx) + + def preprocess_value(self, raw: str): + # For testing, we expect metadata to be passed via an attribute. + self.metadata_element_type = self.metadata if hasattr(self, 'metadata') else int + return raw + +# New Dummy handler for float. +class DummyFloatHandler(ClickTypeHandler): + __test__ = False + __supported_types__ = {float: MetadataPolicy.OPTIONAL} + __priority_bonus__ = 4 + + def convert(self, value, param, ctx): + try: + preprocessed = self.preprocess_value(value) + converted = float(preprocessed) + return self.postprocess_value(converted) + except Exception as e: + self.fail(f'DummyFloatHandler conversion failed: {e}', param, ctx) + +# New Dummy handler for string. +class DummyStringHandler(ClickTypeHandler): + __test__ = False + __supported_types__ = {str: MetadataPolicy.OPTIONAL} + __priority_bonus__ = 1 + + def convert(self, value, param, ctx): + try: + preprocessed = self.preprocess_value(value) + return self.postprocess_value(preprocessed) + except Exception as e: + self.fail(f'DummyStringHandler conversion failed: {e}', param, ctx) + +# Tests for handles_type method. +def test_handles_type_int(): + handler = DummyIntHandler() + assert handler.handles_type(int) is True + assert handler.handles_type(str) is False + +def test_handles_type_bool(): + handler = DummyBoolHandler() + assert handler.handles_type(bool) is True + assert handler.handles_type(int) is False + +def test_handles_type_list_with_metadata(): + handler = DummyListHandler() + assert handler.handles_type(list, metadata=int) is True + assert handler.handles_type(list, metadata=None) is False + +def test_handles_type_float(): + handler = DummyFloatHandler() + assert handler.handles_type(float) is True + assert handler.handles_type(int) is False + +def test_handles_type_string(): + handler = DummyStringHandler() + assert handler.handles_type(str) is True + assert handler.handles_type(int) is False + +# # Tests for compute_priority +# def test_compute_priority(): +# handler = DummyIntHandler() +# priority = handler.compute_priority(int, None, 1) +# # Expected: mro_rank (1) + __priority_bonus__ (5) + specificity (0) = 6 +# assert priority == 6 +# def test_compute_priority_with_metadata(): +# handler = DummyListHandler() +# priority = handler.compute_priority(list, int, 2) +# # Expected: 2 + __priority_bonus__ (3) + METADATA_BONUS (10) + specificity (0) = 15 +# assert priority == 15 +# Test caching of paramtype +def test_get_cached_paramtype(): + pt1 = get_cached_paramtype(DummyIntHandler, int, None) + pt2 = get_cached_paramtype(DummyIntHandler, int, None) + assert pt1 is pt2 + +# Test conversion: DummyIntHandler should convert a string to int. +def test_convert_int(): + handler = DummyIntHandler() + ctx = click.get_current_context(silent=True) + result = handler.convert('123', DummyParam(), ctx) + assert result == 123 + +# Test conversion: DummyBoolHandler should convert strings to booleans. +def test_convert_bool_true(): + handler = DummyBoolHandler() + ctx = click.get_current_context(silent=True) + result = handler.convert('yes', DummyParam(), ctx) + assert result is True + +def test_convert_bool_false(): + handler = DummyBoolHandler() + ctx = click.get_current_context(silent=True) + result = handler.convert('no', DummyParam(), ctx) + assert result is False + +# Test conversion for list: Using DummyListHandler. +def test_convert_list(): + handler = DummyListHandler() + handler.metadata = int # Simulate metadata. + ctx = click.get_current_context(silent=True) + result = handler.convert('1, 2, 3', DummyParam(), ctx) + assert result == [1, 2, 3] + +# Test conversion: DummyFloatHandler should convert a string to float. +def test_convert_float(): + handler = DummyFloatHandler() + ctx = click.get_current_context(silent=True) + result = handler.convert('123.45', DummyParam(), ctx) + assert result == 123.45 + +# Test conversion: DummyStringHandler should return the input string. +def test_convert_string(): + handler = DummyStringHandler() + ctx = click.get_current_context(silent=True) + result = handler.convert('hello world', DummyParam(), ctx) + assert result == 'hello world' + +def test_bool_handler_invalid_input(): + from evn.cli.basic_click_type_handlers import BasicBoolHandler + handler = BasicBoolHandler() + ctx = click.get_current_context(silent=True) + with pytest.raises(click.BadParameter): + handler.convert('maybe', DummyParam(), ctx) + +def test_uuid_handler_invalid_input(): + from evn.cli.basic_click_type_handlers import BasicUUIDHandler + handler = BasicUUIDHandler() + ctx = click.get_current_context(silent=True) + with pytest.raises(click.BadParameter): + handler.convert('not-a-uuid', DummyParam(), ctx) + +if __name__ == '__main__': + main() diff --git a/lib/evn/evn/tests/cli/test_click_util.py b/lib/evn/evn/tests/cli/test_click_util.py new file mode 100644 index 00000000..5eb4a767 --- /dev/null +++ b/lib/evn/evn/tests/cli/test_click_util.py @@ -0,0 +1,55 @@ +import click +from evn.cli.click_util import * + + +def test_extract_command_info(): + + def greet(count, name): + """Greet someone a specified number of times.""" + for _ in range(count): + click.echo(f'Hello, {name}!') + + arg = click.argument('name')(greet) + opt = click.option('--count', default=1, help='Number of greetings.')(arg) + cmd = click.command()(opt) + + info = extract_command_info(cmd) + assert info.function == greet + assert info['parameters'] == [ + { + 'count': False, + 'default': 1, + 'envvar': None, + 'flag_value': False, + 'help': 'Number of greetings.', + 'hidden': False, + 'is_flag': False, + 'multiple': False, + 'name': 'count', + 'nargs': 1, + 'opts': ['--count'], + 'param_type_name': 'option', + 'prompt': None, + 'required': False, + 'secondary_opts': [], + 'type': { + 'name': 'integer', + 'param_type': 'Int' + }, + }, + { + 'default': None, + 'envvar': None, + 'multiple': False, + 'name': 'name', + 'nargs': 1, + 'opts': ['name'], + 'param_type_name': 'argument', + 'required': True, + 'secondary_opts': [], + 'type': { + 'name': 'text', + 'param_type': 'String' + }, + }, + ] diff --git a/lib/evn/evn/tests/config/test_confload.py b/lib/evn/evn/tests/config/test_confload.py new file mode 100644 index 00000000..a84995bb --- /dev/null +++ b/lib/evn/evn/tests/config/test_confload.py @@ -0,0 +1,65 @@ +import tempfile +import evn +from evn.config import get_config, load_env_layer, load_toml_layer +from evn.testing import TestApp + + +def test_env_layer_parsing(monkeypatch): + monkeypatch.setenv('EVN_TESTAPP__FORMAT__TAB_WIDTH', '4') + monkeypatch.setenv('EVN_TESTAPP__DEV__TEST__FAIL_FAST', 'true') + env = load_env_layer('EVN_') + assert env.testapp.format.tab_width == '4' + assert env.testapp.dev.test.fail_fast == 'true' + + +def test_toml_layer_loading(): + with tempfile.NamedTemporaryFile(mode='w+', suffix='.toml', + delete=False) as f: + f.write(""" + [testapp.format] + tab_width = 2 + + [testapp.dev.test] + fail_fast = true + """) + f.flush() + fpath = evn.Path(f.name) + + loaded = load_toml_layer(fpath) + assert loaded.testapp.format.tab_width == 2 + assert loaded.testapp.dev.test.fail_fast is True + + fpath.unlink() # cleanup + + +def test_get_config_from_app_defaults(): + config = evn.cli.get_config_from_app_defaults(TestApp) + # print() + # print_config(config.testapp.dev.format) + # print(config.testapp.dev.format.format.keys()) + assert isinstance(config, dict) + assert config.testapp.dev.format.stream.tab_width == 4 + assert not config.testapp.dev.test.file.fail_fast + assert not config.testapp.dev.doc.build.open_browser + assert config.testapp.qa.review.coverage.min_coverage == 75 + assert config.testapp.qa.review.changes.summary + assert config.testapp.run.dispatch.file.path is None + + +def test_config_includes_all_layers(monkeypatch): + monkeypatch.setenv('EVN_TESTAPP__PROJ__TAGS__REBUILD', 'true') + config = get_config(TestApp) + # print_config(config) + assert config.testapp.proj.tags.rebuild == 'true' + + +def test_config_includes_callbacks(): + config = get_config(TestApp) + assert config.testapp._callback.foo == 'bar' + assert config.testapp.doccheck._callback.docsdir == 'docs' + + +def test_config_is_corrent_bunch_type(): + config = get_config(TestApp) + assert config.__dict__['_config'][ + 'split'], 'Config should have split attribute' diff --git a/lib/evn/evn/tests/decon/test_attr_access.py b/lib/evn/evn/tests/decon/test_attr_access.py new file mode 100644 index 00000000..fc0e9d5f --- /dev/null +++ b/lib/evn/evn/tests/decon/test_attr_access.py @@ -0,0 +1,434 @@ +from pathlib import Path +from collections import namedtuple +import unittest +import pytest + +import evn + + +def main(): + evn.testing.quicktest(namespace=globals()) + + +def test_iterize(): + + @evn.iterize_on_first_param + def Foo(a): + return a * a + + assert Foo(4) == 4 * 4 + assert Foo([1, 2]) == [1, 4] + + +def test_iterize_basetype(): + + @evn.iterize_on_first_param(basetype=str) + def bar(a): + return 2 * a + + assert bar('foo') == 'foofoo' + assert bar(['a', 'b']) == ['aa', 'bb'] + # ic(bar('a b')) + assert bar('a b') == ['aa', 'bb'] + assert bar(1.1) == 2.2 + + +def test_iterize_asdict(): + + @evn.iterize_on_first_param(basetype=str, asdict=True) + def baz(a): + return 2 * a + + assert baz('foo') == 'foofoo' + assert baz(['a', 'b']) == dict(a='aa', b='bb') + assert baz('a b') == dict(a='aa', b='bb') + assert baz(1.1) == 2.2 + + +def test_iterize_asbunch(): + + @evn.iterize_on_first_param(basetype=str, asbunch=True) + def baz(a): + return 2 * a + + assert baz('foo') == 'foofoo' + assert isinstance(baz(['a', 'b']), evn.Bunch) + assert baz(['a', 'b']) == dict(a='aa', b='bb') + assert baz('a b') == dict(a='aa', b='bb') + assert baz(1.1) == 2.2 + assert baz([1, 2]) == {1: 2, 2: 4} + + +def test_iterize_allowmap(): + + @evn.iterize_on_first_param(basetype=str, asbunch=True) + def foo(a): + return 2 * a + + with pytest.raises(TypeError): + foo(dict(a=1, b=2)) + + @evn.iterize_on_first_param(basetype=str, asbunch=True, allowmap=True) + def bar(a): + return 2 * a + + assert bar(dict(a=1, b=2)) == dict(a=2, b=4) + + +def test_iterize_basetype_string(): + + class mylist(list): + pass + + @evn.iterize_on_first_param(basetype='str') + def foo(a): + return 2 * a + + with pytest.raises(TypeError): + foo(dict(a=1, b=2)) + + @evn.iterize_on_first_param(basetype='mylist') + def bar(a): + return len(a) + + assert bar([]) == [] + assert bar([[], []]) == [0, 0] + assert bar(mylist([[], []])) == 2 + # assert bar(e/[dict(a=1, b=2)]) == ['a', 'b'] + + +# Define a custom iterable type for testing +class CustomIterable(namedtuple('CustomIterable', ['items'])): + + def __iter__(self): + return iter(self.items) + + +class TestIterizeOnFirstParam(unittest.TestCase): + """Test suite for the evn.iterize_on_first_param decorator.""" + + def setUp(self): + """Set up test functions with the decorator applied in different ways.""" + + # Basic decorator without arguments + @evn.iterize_on_first_param + def square(x): + return x * x + + self.square = square + + @evn.iterize_on_first_param + def multiply(x, y): + return x * y + + self.multiply = multiply + + # Decorator with basetype=str + @evn.iterize_on_first_param(basetype=str) + def get_length(x): + return len(x) + + self.get_length = get_length + + # Decorator with basetype=str + @evn.iterize_on_first_param(basetype=str, nonempty=True) + def remove_first(x): + return x[1:] if x else '' + + self.remove_first = remove_first + + # Using the pre-configured path decorator + @evn.iterize_on_first_param_path + def process_path(path): + return f'Processing {path}' + + self.process_path = process_path + + # For metadata preservation test + def original_func(x): + """Test docstring.""" + return x + + self.original_func = original_func + + # Test data + self.scalar = 5 + self.list_data = [1, 2, 3] + self.tuple_data = (4, 5, 6) + self.empty_list = [] + self.custom_iterable = CustomIterable([1, 2, 3]) + self.nested_lists = [[1, 2], [3, 4]] + self.string = 'hello' + self.string_list = ['hello', 'world'] + self.path_obj = Path('sample.txt') + self.path_list = [Path('file1.txt'), Path('file2.txt')] + + def tearDown(self): + """Clean up after each test.""" + # No specific cleanup needed for these tests + pass + + def test_scalar_input(self): + """Test with a scalar input value.""" + assert self.square(self.scalar) == 25 + assert self.multiply(3, 4) == 12 + + def test_list_input(self): + """Test with a list input for the first parameter.""" + assert self.square(self.list_data) == [1, 4, 9] + assert self.multiply(self.list_data, 2) == [2, 4, 6] + + def test_tuple_input(self): + """Test with a tuple input for the first parameter.""" + assert self.square(self.tuple_data) == (16, 25, 36) + assert self.multiply(self.tuple_data, 3) == (12, 15, 18) + + def test_empty_iterable(self): + """Test with an empty iterable.""" + assert self.square(self.empty_list) == [] + assert self.multiply(self.empty_list, 10) == [] + + def test_custom_iterable(self): + """Test with a custom iterable type.""" + assert self.square(self.custom_iterable) == CustomIterable([1, 4, 9]) + assert self.multiply(self.custom_iterable, + 5) == CustomIterable([5, 10, 15]) + + def test_basetype_exclusion(self): + """Test that basetyped objects are treated as scalars.""" + # String should be treated as scalar when basetype=str + assert self.get_length(self.string) == 5 + assert self.get_length(self.string_list) == [5, 5] + + def test_multiple_basetype_exclusion(self): + """Test with multiple basetype exclusions.""" + # String should be treated as scalar with path decorator + assert self.process_path(self.string) == f'Processing {self.string}' + # Path object should be treated as scalar with path decorator + assert self.process_path( + self.path_obj) == f'Processing {self.path_obj}' + # List of strings should be processed element-wise + assert self.process_path( + ['file1.txt', + 'file2.txt']) == ['Processing file1.txt', 'Processing file2.txt'] + # List of Path objects should be processed element-wise + expected = [ + f'Processing {self.path_list[0]}', + f'Processing {self.path_list[1]}' + ] + assert self.process_path(self.path_list) == expected + + def test_nested_iterables(self): + """Test handling of nested iterables.""" + + # Define a custom function that handles lists for this test + @evn.iterize_on_first_param + def sum_list(x): + return sum(x) if isinstance(x, list) else x + + assert sum_list(self.nested_lists) == [3, 7] + + def test_decorator_preserves_metadata(self): + """Test that the decorator preserves function metadata.""" + decorated = evn.iterize_on_first_param(self.original_func) + + assert decorated.__name__ == 'original_func' + assert decorated.__doc__ == 'Test docstring.' + + def test_generator_input(self): + """Test with a generator expression as input.""" + gen = (i for i in range(1, 4)) + assert self.square(gen) == [1, 4, 9] + + def test_set_input(self): + """Test with a set as input.""" + # Note: Sets are unordered, so we need to check membership rather than exact equality + result = self.square({1, 2, 3}) + assert set(result) == {1, 4, 9} + assert len(result) == 3 + + def test_remove_first_nonempty(self): + """Test with a non-empty iterable.""" + assert self.remove_first(self.string) == 'ello' + assert self.remove_first(self.string_list) == ['ello', 'orld'] + assert self.remove_first(self.string_list + + ['a', '']) == ['ello', 'orld'] + + +class TestIterizeableFunction(unittest.TestCase): + """Test suite for the evn.is_iterizeable helper function.""" + + def setUp(self): + """Set up test data.""" + self.list_data = [1, 2, 3] + self.string = 'hello' + self.integer = 42 + self.path_obj = Path('test.txt') + + def test_basic_iterizeable(self): + """Test basic evn.is_iterizeable function without basetype.""" + assert evn.is_iterizeable(self.list_data) is True + assert evn.is_iterizeable(self.string) is False + assert evn.is_iterizeable(self.string, + basetype=None) is True # String is iterable + assert evn.is_iterizeable(self.integer) is False + + def test_iterizeable_with_basetype(self): + """Test evn.is_iterizeable function with basetype parameter.""" + # String should not be considered iterable when basetype includes str + assert evn.is_iterizeable(self.string, basetype=str) is False + assert evn.is_iterizeable(self.list_data, basetype=str) is True + + # Path should not be considered iterable when basetype includes Path + assert evn.is_iterizeable(self.path_obj, basetype=Path) is False + + # Multiple basetypes + assert evn.is_iterizeable(self.string, basetype=(str, Path)) is False + assert evn.is_iterizeable(self.path_obj, basetype=(str, Path)) is False + assert evn.is_iterizeable(self.list_data, basetype=(str, Path)) is True + + +def test_subscriptable_for_attributes__getitem__(): + + @evn.subscriptable_for_attributes + class Foo: + a, b, c = 6, 7, 8 + + assert Foo()['a'] == 6 + assert Foo()['a b'] == (6, 7) + + +def test_subscriptable_for_attributes_enumerate(): + + @evn.subscriptable_for_attributes + class Foo: + + def __init__(self): + self.a, self.b, self.c = range(6), range(1, 7), range(10, 17) + + foo = Foo() + for (i, a, b, c), e, f, g in zip(foo.enumerate('a b c'), range(6), + range(1, 7), range(10, 17)): + assert a == e and b == f and c == g + + +def test_subscriptable_for_attributes_enumerate_noarg(): + + @evn.subscriptable_for_attributes + class Foo: + + def __init__(self): + self.a, self.b, self.c = range(6), range(1, 7), range(10, 17) + + foo = Foo() + for (i, a, b, c), e, f, g in zip(foo.enumerate(), range(6), range(1, 7), + range(10, 17)): + assert a == e and b == f and c == g + + +def test_subscriptable_for_attributes_groupby(): + + @evn.subscriptable_for_attributes + class Foo: + + def __init__(self): + self.a, self.b, self.c, self.group = range(6), range(1, 7), range( + 10, 17), 'aaabbb' + + foo = Foo() + # for g, a, b, c in foo.groupby('group', 'a b c'): + # ic(g, a, b, c) + v = list(foo.groupby('group', 'a c')) + assert v == [('a', (0, 1, 2), (10, 11, 12)), + ('b', (3, 4, 5), (13, 14, 15))] + v = list(foo.groupby('group')) + assert v == [ + ('a', evn.Bunch(a=(0, 1, 2), b=(1, 2, 3), c=(10, 11, 12))), + ('b', evn.Bunch(a=(3, 4, 5), b=(4, 5, 6), c=(13, 14, 15))), + ] + + +def test_subscriptable_for_attributes_fzf(): + + @evn.subscriptable_for_attributes + class Foo: + + def __init__(self): + self.london, self.france, self.underpants = 'london', 'france', 'underpants' + self._ignored = 'ignored' + self.redundand1, self.redundand2 = 'fo' + + foo = Foo() + assert foo.fzf('lon') == 'london' + assert foo.fzf('fr') == 'france' + assert foo.fzf('underpants') == 'underpants' + assert foo.fzf('undpant loon frnc') == ('underpants', 'london', 'france') + with pytest.raises(AttributeError): + foo.fzf('notthere') + with pytest.raises(AttributeError): + foo.fzf('lndon') # first two must match + with pytest.raises(AttributeError): + foo.fzf('') + with pytest.raises(AttributeError): + foo.fzf('_ignor') + with pytest.raises(AttributeError): + foo.fzf('redun') + assert foo.fzf('red1') == 'f' + + +def test_getitem_picklable(): + + @evn.subscriptable_for_attributes + class Foo: + + def __init__(self): + self.a, self.b, self.c = range(6), range(1, 7), range(10, 17) + + foo = Foo() + assert foo.pick('a b').keys() == {'a', 'b'} + + +def test_safe_lru_cache(): + ncompute = 0 + + @evn.safe_lru_cache(maxsize=32) + def example(x): + nonlocal ncompute + ncompute += 1 + return x * 2 + + example(2) # Computing 2 + example(2) # No print (cached) + example([1, 2, 3]) # Computing [1, 2, 3] + example([1, 2, 3]) # Computing [1, 2, 3] (because list is unhashable) + assert ncompute == 3 + + +def test_safe_lru_cache_noarg(): + ncompute = 0 + + @evn.safe_lru_cache + def example(x): + nonlocal ncompute + ncompute += 1 + return x * 2 + + example(2) # Computing 2 + example(2) # No print (cached) + example([1, 2, 3]) # Computing [1, 2, 3] + example([1, 2, 3]) # Computing [1, 2, 3] (because list is unhashable) + assert ncompute == 3 + + +def test_is_safe_lru_cache_necessary(): + + @evn.ft.lru_cache + def example(x): + return x * 2 + + with pytest.raises(TypeError): + example([1, 2, 3]) + + +if __name__ == '__main__': + main() diff --git a/lib/evn/evn/tests/decon/test_bunch.py b/lib/evn/evn/tests/decon/test_bunch.py new file mode 100644 index 00000000..c91ca26a --- /dev/null +++ b/lib/evn/evn/tests/decon/test_bunch.py @@ -0,0 +1,485 @@ +import _pickle +import os +import shutil +from argparse import Namespace + +import pytest +import yaml + +import evn +from evn.decon.bunch import bunchfind, Bunch, make_autosave_hierarchy + +config_test = Bunch( + re_only=[ + # 'test_ewise_equal' + ], + re_exclude=[], +) + + +def main(): + evn.testing.quicktest( + namespace=globals(), + config=config_test, + verbose=1, + check_xfail=False, + ) + + +def assert_saved_ok(b): + with open(b._config['autosave']) as inp: + b2 = make_autosave_hierarchy(yaml.load(inp, yaml.Loader)) + assert b == b2 + + +def test_bunch_split_nest(): + test = Bunch(a='a', _split=' ') + test['foo bar baz'] = 8 + test['foo bar baz2'] = 7 + test['foo bar2 baz'] = 6 + with pytest.raises(TypeError): + test['foo bar2 baz but'] = 5 + assert test.foo.bar.baz == 8 + assert test.foo.bar.baz2 == 7 + assert test.foo.bar2.baz == 6 + # assert test.foo.bar2.baz.but == 5 + + +def test_bunch_split_space(): + test = Bunch(a='a', _split=' ') + test['foo bar baz'] = 8 + test['foo bar2 baz'] = 6 + assert test.foo.bar.baz == 8 + assert test.foo.bar2.baz == 6 + + +def test_bunch_split_space_get(): + test = Bunch(a='a', _split=' ') + test['foo bar baz'] = 8 + test['foo bar2 baz'] = 6 + assert test.foo.bar.baz == 8 + assert test.foo.bar2.baz == 6 + assert test._get_split('foo bar baz') == 8 + assert test._get_split('foo bar2 baz') == 6 + + +def test_bunch_split_dot(): + test = Bunch(a='a', _split='.') + test['foo.bar.baz'] = 8 + test['foo.bar2.baz'] = 8 + assert test.foo.bar.baz == 8 + assert test.foo.bar2.baz == 8 + + +def test_autosave(tmpdir): # sourcery skip: merge-list-append, merge-set-add + fname = f'{tmpdir}/test.yaml' + b = Bunch(a=1, b=[1, 2], c=[[[[0]]]], d={1, 3, 8}, e=([1], [2])) + b = make_autosave_hierarchy(b, _autosave=fname) + b.a = 7 + assert_saved_ok(b) + b.b.append(3) + assert_saved_ok(b) + b.b[1] = 17 + assert_saved_ok(b) + b.c.append(100) + assert_saved_ok(b) + b.c[0].append(200) + assert_saved_ok(b) + b.c[0][0].append(300) + assert_saved_ok(b) + b.c[0][0][0].append(400) + assert_saved_ok(b) + b.c[0][0][0][0] = 7 + assert_saved_ok(b) + b.d.add(3) + assert_saved_ok(b) + b.d.add(17) + assert_saved_ok(b) + b.d.remove(1) + assert_saved_ok(b) + b.d |= {101, 102, 10} + b.e[0].append(1000) + assert_saved_ok(b) + b.e[1][0] = 2000 + assert_saved_ok(b) + b.f = 'f' + assert_saved_ok(b) + delattr(b, 'f') + assert_saved_ok(b) + b.f = 'f2' + del b['f'] + assert_saved_ok(b) + b.g = [] + b.g.append(283) + assert_saved_ok(b) + b.h = set() + b.h.add('bar') + assert_saved_ok(b) + b.i = [[[17]]] + b.i[0][0][0] = 18 + assert_saved_ok(b) + + +def helper_test_autoreload(b, b2, tmpdir): + fname = f'{tmpdir}/test.yaml' + fname2 = f'{tmpdir}/test2.yaml' + shutil.copyfile(fname, f'{fname2}.tmp') + shutil.move(f'{fname2}.tmp', fname2) + assert_saved_ok(b) + assert b == b2 + assert set(os.listdir(tmpdir)) == {'test2.yaml', 'test.yaml'} + + +def test_autoreload(tmpdir): + fname = f'{tmpdir}/test.yaml' + fname2 = f'{tmpdir}/test2.yaml' + b = Bunch(a=1, b=[1, 2], c=[[[[0]]]], d={1, 3, 8}, e=([1], [2])) + b = make_autosave_hierarchy(b, _autosave=fname) + b2 = Bunch(_autoreload=fname2) + b.a = 7 + helper_test_autoreload(b, b2, tmpdir) + b.b.append(3) + helper_test_autoreload(b, b2, tmpdir) + b.b[1] = 17 + helper_test_autoreload(b, b2, tmpdir) + b.c.append(100) + helper_test_autoreload(b, b2, tmpdir) + b.c[0].append(200) + helper_test_autoreload(b, b2, tmpdir) + b.c[0][0].append(300) + helper_test_autoreload(b, b2, tmpdir) + b.c[0][0][0].append(400) + helper_test_autoreload(b, b2, tmpdir) + b.c[0][0][0][0] = 7 + helper_test_autoreload(b, b2, tmpdir) + b.d.add(3) + helper_test_autoreload(b, b2, tmpdir) + b.d.add(17) + helper_test_autoreload(b, b2, tmpdir) + b.d.remove(1) + helper_test_autoreload(b, b2, tmpdir) + b.d |= {101, 102, 10} + b.e[0].append(1000) + helper_test_autoreload(b, b2, tmpdir) + b.e[1][0] = 2000 + helper_test_autoreload(b, b2, tmpdir) + b.f = 'f' + helper_test_autoreload(b, b2, tmpdir) + delattr(b, 'f') + helper_test_autoreload(b, b2, tmpdir) + b.f = 'f2' + del b['f'] + helper_test_autoreload(b, b2, tmpdir) + b.g = [] + b.g.append(283) + helper_test_autoreload(b, b2, tmpdir) + b.h = set() + b.h.add('bar') + helper_test_autoreload(b, b2, tmpdir) + b.i = [[[17]]] + b.i[0][0][0] = 18 + helper_test_autoreload(b, b2, tmpdir) + b.bnch = Bunch() + b.bnch.c = 17 + helper_test_autoreload(b, b2, tmpdir) + b.bnch._notify_changed('baz', 'biz') + helper_test_autoreload(b, b2, tmpdir) + helper_test_autoreload(b, b2, tmpdir) + + +def test_bunch_pickle(tmpdir): + x = Bunch(dict(a=2, b='bee')) + x.c = 'see' + with open(f'{tmpdir}/foo', 'wb') as out: + _pickle.dump(x, out) + + with open(f'{tmpdir}/foo', 'rb') as inp: + y = _pickle.load(inp) + + assert x == y + assert y.a == 2 + assert y.b == 'bee' + assert y.c == 'see' + + +def test_bunch_init(): + b = Bunch(dict(a=2, b='bee'), _strict=False) + b2 = Bunch(b, _strict=False) + b3 = Bunch(c=3, d='dee', _strict=False, **b) + assert b.a == 2 + assert b.b == 'bee' + assert b.missing is None + + assert b.a == 2 + assert b.b == 'bee' + assert b.missing is None + + assert b3.a == 2 + assert b3.b == 'bee' + assert b3.missing is None + assert b3.c == 3 + assert b3.d == 'dee' + + foo = Namespace(a=1, b='c') + b = Bunch(foo, _strict=False) + assert b.a == 1 + assert b.b == 'c' + assert b.missing is None + + b.missing = 7 + assert b.missing == 7 + b.missing = 8 + assert b.missing == 8 + + +def test_bunch_sub(): + b = Bunch(dict(a=2, b='bee'), _strict=False) + assert b.b == 'bee' + b2 = b.sub(b='bar') + assert b2.b == 'bar' + b3 = b.sub({'a': 4, 'd': 'dee'}) + assert b3.a == 4 + assert b3.b == 'bee' + assert b3.d == 'dee' + assert b3.foobar is None + assert 'a' in b + b4 = b.sub(a=None) + assert 'a' not in b4 + assert 'b' in b4 + + b = Bunch(dict(a=2, b='bee'), _strict=False) + assert b.b == 'bee' + b2 = b.sub(b='bar', _onlynone=True) + assert b2.b == 'bee' + b3 = b.sub({'a': 4, 'd': 'dee'}, _onlynone=True) + assert b3.a == 2 + assert b3.b == 'bee' + assert b3.d == 'dee' + assert b3.foobar is None + assert 'a' in b + b4 = b.sub(a=None) + assert 'a' not in b4 + assert 'b' in b4 + + +def test_bunch_items(): + b = Bunch(dict(item='item')) + b.attr = 'attr' + assert len(list(b.items())) == 2 + assert list(b) == ['item', 'attr'] + assert list(b.keys()) == ['item', 'attr'] + assert list(b.values()) == ['item', 'attr'] + + +def test_bunch_add(): + b1 = Bunch(dict(a=2, b='bee', mergedint=4, mergedstr='b1')) + b2 = Bunch(dict(a=2, c='see', mergedint=3, mergedstr='b2')) + b1_plus_b2 = Bunch(a=4, b='bee', mergedint=7, mergedstr='b1b2', c='see') + assert (b1 + b2) == b1_plus_b2 + + +def test_bunch_visit(): + count = 0 + + def func(k, v, depth): + # print(' ' * depth, k, type(v)) + nonlocal count + count += 1 + if v == 'b': + return True + return False + + b = Bunch(a='a', b='b', bnch=Bunch(foo='bar')) + b.visit_remove_if(func) + assert b == Bunch(a='a', bnch=Bunch(foo='bar')) + assert count == 4 + + +def test_bunch_strict(): + b = Bunch(one=1, two=2, _strict=True) + assert len(b) == 2 + with pytest.raises(AttributeError): + assert b.foo is None + b.foo = 7 + assert b.foo == 7 + + b2 = Bunch(one=1, two=2, _strict=False) + assert b2.foo is None + + with pytest.raises(ValueError) as e: + b.clear = 8 + assert str(e.value) == 'clear is a reseved name for Bunch' + b = Bunch(clear=True) + assert 'clear_' in b + assert 'clear' not in b + + +def test_bunch_default(): + b = Bunch(foo='foo', _default=list, _strict=False) + assert b.foo == 'foo' + assert b.bar == [] + assert b['foo'] == 'foo' + assert b['bar'] == [] + + b = Bunch(foo='foo', _default=list, _strict=False) + assert b.foo == 'foo' + assert b.bar == [] + + b = Bunch(foo='foo', _default=7, _strict=False) + assert b.foo == 'foo' + assert b.bar == 7 + + b = Bunch(foo='foo', _default=list, _strict=True) + assert b.foo == 'foo' + # with pytest.raises(AttributeError): + assert b.bar == [] + + b = Bunch(foo='foo', _strict=True) + assert b.foo == 'foo' + with pytest.raises(AttributeError): + b.bar + + +def test_bunch_bugs(): + # with pytest.raises(ValueError) as e: + showme_opts = Bunch(headless=0, + spheres=0.0, + showme=0, + clear=True, + weight=2) + assert 'clear_' in showme_opts + assert 'clear' not in showme_opts + # assert str(e.value) == "clear is a reseved name for Bunch" + + +def test_bunch_dict_reserved(): + b = Bunch(values='foo') + assert b.values_ == 'foo' + + +def test_bunch_zip(): + zipped = evn.zipmaps(Bunch(a=1, b=2), Bunch(a='a', b='b')) + assert isinstance(zipped, Bunch) + assert zipped == Bunch(a=(1, 'a'), b=(2, 'b')) + + +def test_bunch_zip_missing(): + zipped = evn.zipmaps(Bunch(a=1, b=2), Bunch(a='a', b='b', c='c')) + assert isinstance(zipped, Bunch) + assert zipped == Bunch(c=(evn.NA, 'c'), a=(1, 'a'), b=(2, 'b')) + + +def test_bunch_zip_order(): + zipped = evn.zipmaps(Bunch(a=2, b=1), + Bunch(a='a', b='b', c='c'), + order='val') + assert isinstance(zipped, Bunch) + assert tuple(zipped.keys()) == ('c', 'b', 'a') + + +def test_search_basic_match(): + data = Bunch({'name': 'Alice', 'age': 30, 'location': 'New York'}) + result = bunchfind(data, 'name') + assert result == {'name': 'Alice'} + + +def test_search_nested_match(): + data = { + 'person': { + 'name': 'Bob', + 'details': { + 'age': 25, + 'location_name': 'Los Angeles' + } + } + } + result = bunchfind(data, 'name') + assert result == { + 'person.name': 'Bob', + 'person.details.location_name': 'Los Angeles' + } + + +def test_search_nested_match_recursive(): + data = { + 'person': { + 'name': 'Bob', + 'details': { + 'age': 25, + 'location_name': 'Los Angeles' + } + } + } + data['self'] = data + result = bunchfind(data, 'name') + assert result == { + 'person.name': 'Bob', + 'person.details.location_name': 'Los Angeles' + } + + +def test_search_no_match(): + data = Bunch({'name': 'Charlie', 'age': 40}) + result = bunchfind(data, 'location') + assert result == {} + + +def test_search_partial_key_match(): + data = { + 'username': 'admin', + 'user_id': 1234, + 'details': { + 'profile_name': 'Admin' + } + } + result = bunchfind(data, 'name') + assert result == {'username': 'admin', 'details.profile_name': 'Admin'} + + +def test_search_empty_dict(): + data = Bunch() + result = bunchfind(data, 'key') + assert result == {} + + +def test_bunch_underscore(): + data = Bunch(_foo='bar', foo_='baz') + assert data._foo == 'bar' + assert data.foo_ == 'baz' + + +def test_bunch_merge(): + a = Bunch(b=Bunch(c=Bunch(d=1))) + b = Bunch(b=Bunch(c=Bunch(b=2))) + a._merge(b) + assert a.b.c == Bunch(d=1, b=2) + c = Bunch(b=Bunch(c=Bunch(b=8))) + a._merge(c) + assert a.b.c == Bunch(d=1, b=8) + + +def test_contains_flagmode(): + flags = Bunch(_default=lambda k: k.startswith('a'), + _frozen=True, + _flagmode=True) + assert flags.a + assert flags.abcdesf + assert not flags.bnes + assert 'africa' in flags + assert 'bermuda' not in flags + with pytest.raises(ValueError): + flags.a = 7 + + +def test_bunch_copy(): + b = Bunch(a=1, b=Bunch(c=Bunch(d='foo')), c=3) + strb = str(b) + b2 = b.copy() + assert b == b2 + b2.b.c.d = 7 + assert b != b2 + assert str(b2) != strb + + +if __name__ == '__main__': + main() diff --git a/lib/evn/evn/tests/decon/test_item_wise.py b/lib/evn/evn/tests/decon/test_item_wise.py new file mode 100644 index 00000000..c86f802b --- /dev/null +++ b/lib/evn/evn/tests/decon/test_item_wise.py @@ -0,0 +1,445 @@ +from collections import OrderedDict +import operator +import unittest +import pytest +import evn + +np = evn.lazyimport('numpy') + +config_test = evn.Bunch( + # re_only=['test_generic_get_items'], + re_exclude=[], ) + + +def main(): + evn.testing.quicktest( + namespace=globals(), + config=config_test, + verbose=1, + check_xfail=False, + use_test_classes=True, + ) + + +def test_generic_get_items(): + foo = dict(a=1, b_=3) + assert evn.decon.generic_get_items(foo) == [('a', 1)] + + class Foo: + pass + + foo = Foo() + foo.a, foo.b, foo._c = 1, 1, 1 + assert evn.decon.generic_get_items(foo) == [('a', 1), ('b', 1)] + assert evn.decon.generic_get_items([0, 1, 2]) == [(0, 0), (1, 1), (2, 2)] + + class Bar: + a: int = 1 + _b: int = 2 + c_: int = 3 + + bar = Bar() + assert evn.decon.generic_get_items(bar) == [('a', 1)] + + +@evn.item_wise_operations +class EwiseDict(dict): + pass + + +def test_item_wise_no_args(): + + @evn.item_wise_operations + class EwiseDictonly(dict): + pass + + if evn.installed.numpy: + assert 'npwise' in dir(EwiseDictonly) + assert 'dictwise' not in dir(EwiseDictonly) + assert 'mapwise' in dir(EwiseDictonly) + assert 'valwise' in dir(EwiseDictonly) + + +def test_item_wise_resulttypes(): + with pytest.raises((TypeError, KeyError)): + + @evn.item_wise_operations(result_types='foo') + class EwiseDictBad(dict): + pass + + @evn.item_wise_operations(result_types='np dict') + class EwiseDictonly(dict): + pass + + if evn.installed.numpy: + assert 'npwise' in dir(EwiseDictonly) + assert 'dictwise' in dir(EwiseDictonly) + assert 'mapwise' not in dir(EwiseDictonly) + assert 'valwise' not in dir(EwiseDictonly) + instance = EwiseDictonly() + assert hasattr(instance, 'npwise') + assert hasattr(instance, 'dictwise') + assert not hasattr(instance, 'mapwise') + assert not hasattr(instance, 'valwise') + + +def test_item_wise(): + b = EwiseDict(zip('abcdefg', ([] for _ in range(7)))) + assert all(b.valwise == []) + r = b.mapwise.append(1) + assert all(b.valwise == [1]) + assert b['a'] == [1] + + +def test_item_wise_accum(): + b = EwiseDict(zip('abcdefg', range(7))) + assert isinstance(b.mapwise + 10, evn.Bunch) + assert isinstance(b.valwise + 10, list) + if evn.installed.numpy: + assert isinstance(b.npwise + 10, np.ndarray) + + +def test_item_wise_multi(): + b = EwiseDict(zip('abcdefg', ([] for _ in range(7)))) + assert b.mapwise == [] + with pytest.raises(ValueError): + r = b.mapwise.append(1, 2) + b.mapwise.append(*range(7)) + assert list(b.values()) == [[i] for i in range(7)] + + +def test_item_wise_equal(): + b = EwiseDict(zip('abcdefg', ([] for _ in range(7)))) + assert b.mapwise == [] + b.mapwise.append(*range(7)) + eq4 = b.mapwise == [4] + assert list(eq4.values()) == [0, 0, 0, 0, 1, 0, 0] + assert not any((b.mapwise == 3).values()) + + +def test_item_wise_add(): + b = EwiseDict(zip('abcdefg', range(7))) + assert (b.valwise == 4) == [0, 0, 0, 0, 1, 0, 0] + assert not any(b.valwise == 'ss') + c = b.mapwise + 7 + b.mapwise += 7 + assert b == c + if evn.installed.numpy: + d = b.npwise - 4 + e = 4 - b.npwise + assert np.all(d == -e) + + +def test_item_wise_contains(): + b = EwiseDict(zip('abcdefg', [[i] for i in range(7)])) + with pytest.raises(ValueError): + contains = b.valwise.__contains__(4) + contains = b.valwise.contains(4) + assert contains == [0, 0, 0, 0, 1, 0, 0] + + +def test_item_wise_contained_by(): + b = EwiseDict(zip('abcdefg', range(7))) + contained = b.valwise.contained_by([1, 2, 3]) + assert contained == [0, 1, 1, 1, 0, 0, 0] + + +def test_item_wise_indexing(): + if evn.installed.numpy: + dat = np.arange(7 * 4).reshape(7, 4) + b = EwiseDict(zip('abcdefg', dat)) + indexed = b.npwise[1] + assert np.all(indexed == dat[:, 1]) + + +def test_item_wise_slicing(): + if evn.installed.numpy: + dat = np.arange(7 * 4).reshape(7, 4) + b = EwiseDict(zip('abcdefg', dat)) + indexed = b.npwise[1:3] + assert np.all(indexed == dat[:, 1:3]) + + +def test_item_wise_call_operator(): + if evn.installed.numpy: + dat = np.arange(7 * 4).reshape(7, 4) + b = EwiseDict(zip('abcdefg', dat)) + c = b.mapwise(lambda x: list(map(int, x))) + d = c.mapwise(np.array, dtype=float) + assert np.all(b.npwise == d) + + +@evn.item_wise_operations +@evn.mutablestruct +class Foo: + a: list + b: list + + def c(self): + pass + + +def test_item_wise_attrs(): + foo = Foo(a=[], b=[]) + foo.mapwise.append(5, 7) + assert foo.a == [5], foo.b == [7] + with pytest.raises(ValueError): + foo.mapwise.append(1, 2, 3, 4) + + +@evn.item_wise_operations +@evn.struct +class Bar: + a: list + b: list + + def c(self): + pass + + +@pytest.mark.skip +def test_item_wise_slots(): + foo = Bar(a=[], b=[]) + foo.mapwise.append(5, 7) + assert foo.a == [5], foo.b == [7] + with pytest.raises(ValueError): + foo.mapwise.append(1, 2, 3, 4) + + +def test_item_wise_kw_call(): + x = Foo([], []) + x.mapwise.append(dict(b=2, a=1)) + x.mapwise.append(**dict(b=2, a=1)) + # x.mapwise.append(a=1, b=2) + assert x.a == [1, 1] and x.b == [2, 2] + + +############################ ai gen tests ###################### + + +class TestElementWiseOperations(unittest.TestCase): + """Test cases for item_wise_operations decorator and related functionality.""" + + def setUp(self): + """Set up test fixtures.""" + self.test_dict = EwiseDict({ + 'a': 1, + 'b': 2, + 'c': 3, + '_hidden': 4, # should be skipped in element-wise operations + }) + + # For testing container operations + self.test_container_dict = EwiseDict({ + 'a': [1, 2, 3], + 'b': [2, 3, 4], + 'c': [3, 4, 5] + }) + # For testing with objects + + @evn.item_wise_operations + class Metrics(dict): + pass + + self.metrics = Metrics({ + 'accuracy': 0.95, + 'precision': 0.87, + 'recall': 0.92, + 'f1': 0.89 + }) + + def test_item_wise_basic(self): + """Test basic mapwise operations.""" + # Test addition with single value + result = self.test_dict.mapwise.__add__(10) + self.assertIsInstance(result, evn.Bunch) + self.assertEqual(result.a, 11) + self.assertEqual(result.b, 12) + self.assertEqual(result.c, 13) + self.assertNotIn('_hidden', result) + + # Test with custom method + result = self.test_dict.mapwise.__getattr__(lambda x, y: x * y)(5) + self.assertEqual(result.a, 5) + self.assertEqual(result.b, 10) + self.assertEqual(result.c, 15) + + def test_valwise_basic(self): + """Test basic valwise operations.""" + # Test multiplication with single value + result = self.test_dict.valwise.__mul__(2) + self.assertIsInstance(result, list) + self.assertEqual(result, [2, 4, 6]) + + # Test with no arguments (calls method with no args) + test_dict = EwiseDict({'a': [1, 2], 'b': [3, 4], 'c': [5, 6]}) + result = test_dict.valwise.__getattr__(len)() + self.assertEqual(result, [2, 2, 2]) + + def test_npwise_basic(self): + """Test basic npwise operations.""" + # Test subtraction + if evn.installed.numpy: + result = self.test_dict.npwise.__sub__(1) + self.assertIsInstance(result, np.ndarray) + assert np.allclose(result, np.array([0, 1, 2])) + + # Test negative operation (unary) + result = self.test_dict.npwise.__neg__() + assert np.allclose(result, np.array([-1, -2, -3])) + + def test_multiple_args(self): + """Test operations with multiple arguments.""" + # Test with exact number of arguments + result = self.test_dict.mapwise.__getattr__(operator.add)(10, 20, 30) + self.assertEqual(result.a, 11) + self.assertEqual(result.b, 22) + self.assertEqual(result.c, 33) + + # Test with wrong number of arguments + with self.assertRaises(TypeError): + self.test_dict.mapwise.__getattr__(operator.add)([10, 20]) + + def test_binary_operations(self): + """Test binary operations (add, sub, mul, etc.).""" + # Addition + result = self.test_dict.mapwise + 5 + self.assertEqual(result.a, 6) + self.assertEqual(result.b, 7) + self.assertEqual(result.c, 8) + + # Subtraction + result = self.test_dict.mapwise - 1 + self.assertEqual(result.a, 0) + self.assertEqual(result.b, 1) + self.assertEqual(result.c, 2) + + # Right subtraction (special case) + # result = 10 - self.test_dict.mapwise + # self.assertEqual(result.a, 9) + # self.assertEqual(result.b, 8) + # self.assertEqual(result.c, 7) + + # Multiplication + result = self.test_dict.mapwise * 3 + self.assertEqual(result.a, 3) + self.assertEqual(result.b, 6) + self.assertEqual(result.c, 9) + + # Division + result = self.test_dict.mapwise / 2 + self.assertEqual(result.a, 0.5) + self.assertEqual(result.b, 1.0) + self.assertEqual(result.c, 1.5) + + def test_container_operations(self): + """Test container operations (contains, contained_by).""" + # Contains operation + result = self.test_container_dict.mapwise.contains(2) + self.assertEqual(result.a, True) + self.assertEqual(result.b, True) + self.assertEqual(result.c, False) + + # Contained_by operation + container = [1, 2, 3, 4] + testmap = EwiseDict(a=1, b=3, c=7) + result = testmap.mapwise.contained_by(container) + self.assertEqual(result.a, + True) # all elements in [1,2,3] are in container + self.assertEqual(result.b, + True) # all elements in [2,3,4] are in container + self.assertEqual(result.c, False) # 5 is not in container + + # Test direct __contains__ (should raise error) + with self.assertRaises(ValueError): + 2 in self.test_container_dict.mapwise + + def test_method_calls(self): + """Test calling methods on elements.""" + dict_of_lists = EwiseDict({ + 'a': [1, 2, 3], + 'b': [4, 5], + 'c': [6, 7, 8, 9] + }) + + # Call len() on each element + result = dict_of_lists.mapwise.__getattr__('__len__')() + self.assertEqual(result.a, 3) + self.assertEqual(result.b, 2) + self.assertEqual(result.c, 4) + + def test_accumulators(self): + """Test different accumulator types.""" + # BunchAccumulator (mapwise) + mapwise_result = self.metrics.mapwise * 100 + self.assertIsInstance(mapwise_result, evn.Bunch) + self.assertEqual(mapwise_result.accuracy, 95) + self.assertEqual(mapwise_result.precision, 87) + + # ListAccumulator (valwise) + valwise_result = self.metrics.valwise * 100 + self.assertIsInstance(valwise_result, list) + self.assertEqual(valwise_result, [95, 87, 92, 89]) + + # NumpyAccumulator (npwise) + # npwise_result = self.metrics.npwise * 100 + # self.assertIsInstance(npwise_result, np.ndarray) + # np.testing.assert_array_almost_equal(npwise_result, np.array([95, 87, 92, 89])) + + def test_set_values(self): + """Test setting values through the descriptor.""" + # Using a mapping + self.test_dict.mapwise = {'a': 10, 'b': 20, 'c': 30} + self.assertEqual(self.test_dict['a'], 10) + self.assertEqual(self.test_dict['b'], 20) + self.assertEqual(self.test_dict['c'], 30) + + # Using a sequence + test_dict2 = EwiseDict({'x': 0, 'y': 0, 'z': 0}) + test_dict2.mapwise = [5, 6, 7] + self.assertEqual(test_dict2['x'], 5) + self.assertEqual(test_dict2['y'], 6) + self.assertEqual(test_dict2['z'], 7) + + def test_real_world_scenario(self): + """Test a realistic scenario with the decorator.""" + + @evn.item_wise_operations + class ExperimentResults(OrderedDict): + pass + + # Create test data simulating experiment results + results = ExperimentResults({ + 'exp1': { + 'accuracy': 0.85, + 'runtime': 120 + }, + 'exp2': { + 'accuracy': 0.92, + 'runtime': 150 + }, + 'exp3': { + 'accuracy': 0.78, + 'runtime': 90 + }, + }) + + # Extract a specific metric across all experiments + get_accuracy = lambda experiment_data: experiment_data['accuracy'] + accuracies = results.valwise.__getattr__(get_accuracy)() + self.assertEqual(accuracies, [0.85, 0.92, 0.78]) + + # Compute average runtime + if evn.installed.numpy: + get_runtime = lambda experiment_data: experiment_data['runtime'] + runtimes = results.npwise.__getattr__(get_runtime)() + self.assertEqual(np.mean(runtimes), 120.0) + + # Find best experiment by accuracy + accuracies_dict = results.mapwise.__getattr__(get_accuracy)() + best_exp = max(accuracies_dict.items(), key=lambda x: x[1])[0] + self.assertEqual(best_exp, 'exp2') + + +if __name__ == '__main__': + main() diff --git a/lib/evn/evn/tests/decon/test_iterables.py b/lib/evn/evn/tests/decon/test_iterables.py new file mode 100644 index 00000000..49a5cc09 --- /dev/null +++ b/lib/evn/evn/tests/decon/test_iterables.py @@ -0,0 +1,64 @@ +import evn + + +def main(): + evn.testing.quicktest(namespace=globals()) + + +def count(): + i = 0 + while True: + yield i + i += 1 + + +def test_iterables_nth(): + assert evn.nth(count(), 4) == 4 + + +def test_iterables_first(): + assert evn.first(count()) == 0 + assert evn.first([]) is None + + +def test_iterables_head(): + assert evn.head(count(), 4) == [0, 1, 2, 3] + assert evn.head([], 4) == [] + assert evn.head([1, 4], 4) == [1, 4] + + +def test_iterables_zipmap(): + zipped = evn.zipmaps(dict(a=1, b=2), dict(a='a', b='b')) + assert isinstance(zipped, dict) + assert zipped == dict(a=(1, 'a'), b=(2, 'b')) + + +def test_iterables_zipitems(): + zipped = evn.zipitems(dict(a=1, b=2), dict(a='a', b='b')) + assert list(zipped) == [('a', 1, 'a'), ('b', 2, 'b')] + + +def test_iterables_zipmap_missing(): + zipped = evn.zipmaps(dict(a=1, b=2), dict(a='a', b='b', c='c')) + assert isinstance(zipped, dict) + assert zipped == dict(c=(evn.NA, 'c'), a=(1, 'a'), b=(2, 'b')) + + +def test_iterables_zipmap_missing_intersection(): + zipped = evn.zipmaps(dict(a=1, b=2), + dict(a='a', b='b', c='c'), + intersection=True) + assert isinstance(zipped, dict) + assert zipped == dict(a=(1, 'a'), b=(2, 'b')) + + +def test_iterables_zipmap_order(): + zipped = evn.zipmaps(dict(a=2, b=1), + dict(a='a', b='b', c='c'), + order='val') + assert isinstance(zipped, dict) + assert tuple(zipped.keys()) == ('c', 'b', 'a') + + +if __name__ == '__main__': + main() diff --git a/lib/evn/evn/tests/decon/test_metadata.py b/lib/evn/evn/tests/decon/test_metadata.py new file mode 100644 index 00000000..c89b3c9e --- /dev/null +++ b/lib/evn/evn/tests/decon/test_metadata.py @@ -0,0 +1,118 @@ +import copy +import pytest +import evn + +config_test = evn.Bunch( + re_only=[ + # + ], + re_exclude=[ + # + ], +) + + +def main(): + evn.testing.quicktest( + namespace=globals(), + config=config_test, + verbose=1, + check_xfail=False, + ) + + +def test_sync_metadata(): + objs = [type(f'Foo{i}', (), {})() for i in range(7)] + for o, k, v in zip(objs, 'abcdefg', range(7)): + evn.decon.set_metadata(o, {k: v}) + ref = [ + dict(a=0), + dict(b=1), + dict(c=2), + dict(d=3), + dict(e=4), + dict(f=5), + dict(g=6) + ] + # print(list(map(dict, map(evn.decon.get_metadata, objs)))) + assert list(map(dict, map(evn.decon.get_metadata, objs))) == ref + evn.decon.sync_metadata(*objs) + ref2 = [ + evn.Bunch(a=0, b=1, c=2, d=3, e=4, f=5, g=6), + evn.Bunch(b=1, a=0, c=2, d=3, e=4, f=5, g=6), + evn.Bunch(c=2, a=0, b=1, d=3, e=4, f=5, g=6), + evn.Bunch(d=3, a=0, b=1, c=2, e=4, f=5, g=6), + evn.Bunch(e=4, a=0, b=1, c=2, d=3, f=5, g=6), + evn.Bunch(f=5, a=0, b=1, c=2, d=3, e=4, g=6), + evn.Bunch(g=6, a=0, b=1, c=2, d=3, e=4, f=5), + ] + assert list(map(evn.decon.get_metadata, objs)) == ref2 + + +def test_metadata_decorator__init__(): + + @evn.decon.holds_metadata + class Foo: + + def __init__(self, a, b): + self.a, self.b = a, b + + obj = Foo(1, b=2) + assert obj.get_metadata() == {} + assert obj.a == 1 and obj.b == 2 + + with pytest.raises(TypeError): + obj = Foo(1, b=2, c=3) + obj = Foo(1, b=2, _c=3) + assert obj.a == 1 and obj.b == 2 + assert obj.get_metadata() == {'c': 3} + + +def test_metadata_decorator(): + + @evn.decon.holds_metadata + class Foo: + pass + + obj = Foo() + obj.set_metadata({'a': 1, 'b': 2}) + assert obj.get_metadata() == {'a': 1, 'b': 2} + obj.set_metadata({'c': 3}) + assert obj.get_metadata() == {'a': 1, 'b': 2, 'c': 3} + + obj2 = Foo() + obj2.set_metadata({'x': 10}) + obj.sync_metadata(obj2) + assert obj.get_metadata() == {'a': 1, 'b': 2, 'c': 3, 'x': 10} + assert obj2.get_metadata() == {'a': 1, 'b': 2, 'c': 3, 'x': 10} + + +def test_metadata_copy(): + + @evn.decon.holds_metadata + class Foo: + pass + + a = Foo() + a.set_metadata({'a': 1, 'b': 2}) + b = copy.copy(a) + b.meta.c = 3 + assert a.get_metadata() == {'a': 1, 'b': 2} + assert b.get_metadata() == {'a': 1, 'b': 2, 'c': 3} + + +def test_doctest_issue(): + + class Example: + pass + + obj = Example() + evn.decon.set_metadata(obj, {'key': 'value'}) # doctest:+SKIP + assert isinstance(obj.__evn_metadata__, evn.Bunch) + assert 'value' == evn.decon.get_metadata(obj).key + obj2 = Example() + assert evn.Bunch() == evn.decon.get_metadata(obj2) + + +if __name__ == '__main__': + main() diff --git a/lib/evn/evn/tests/format/_code_examples.pytxt b/lib/evn/evn/tests/format/_code_examples.pytxt new file mode 100644 index 00000000..c51cf2c0 --- /dev/null +++ b/lib/evn/evn/tests/format/_code_examples.pytxt @@ -0,0 +1,27 @@ +#$@!########################## test spacing unchanged ############################### +@dataclass +class FormatHistory: + """Tracks original and formatted code for all files being processed.""" + buffers: dict[str, dict[str, str]] = field(default_factory=dict) + + def add(self, filename: str, original_code: str): + """Initialize a new file in history with its original code.""" + self.buffers[filename] = {"original": original_code} + + def update(self, filename: str, new_code: str): + """Update the formatted code for a given file.""" + self.buffers[filename]["formatted"] = new_code + + def get_original(self, filename: str) -> str: + """Retrieve the original code.""" + return self.buffers[filename]["original"] + + def get_formatted(self, filename: str) -> str: + """Retrieve the formatted code.""" + return self.buffers[filename]["formatted"] + +#$@!########################### test oneline spaces ############################### +with open('/tmp/test.txt'): pass +#$@!----------------------------- โ†‘ original โ†“ formatted ---------------------------- +with open('/tmp/test.txt'): pass +#$@!############################################################################ diff --git a/lib/evn/evn/tests/format/test_code_examples.py b/lib/evn/evn/tests/format/test_code_examples.py new file mode 100644 index 00000000..22d21f94 --- /dev/null +++ b/lib/evn/evn/tests/format/test_code_examples.py @@ -0,0 +1,57 @@ +import re +import evn + +config_test = evn.Bunch( + re_only=[ + # + ], + re_exclude=[ + # + ], +) + + +def helper_test_code_examples(testname, original, reference): + formatted = evn.format.format_buffer(original) + if formatted != reference: + print(testname) + evn.diff(formatted, reference) + + +def main(): + evn.testing.quicktest( + namespace=globals(), + config=config_test, + verbose=1, + check_xfail=False, + chrono=False, + ) + + +def read_examples(): + examples = [] + with open(f'{evn.pkgroot}/tests/format/_code_examples.pytxt') as inp: + re_testname = re.compile(r'#####\s*test\s+(.*?)\s*###########') + group = [] + context = None + for line in inp: + if not line.startswith('#$@!'): + group.append(line) + elif name := re_testname.findall(line): + assert len(name) == 1 + if examples and not examples[-1][2]: + examples[-1][2].extend(examples[-1][1]) + examples.append([name[0], [], []]) + group = examples[-1][1] + elif 'โ†‘ original โ†“ formatted' in line: + group = examples[-1][2] + for e in examples: + e[1] = ''.join(e[1]) + e[2] = ''.join(e[2]) + return examples + + +evn.testing.generate_tests(read_examples()) + +if __name__ == '__main__': + main() diff --git a/lib/evn/evn/tests/format/test_detect_formatted_blocks.py b/lib/evn/evn/tests/format/test_detect_formatted_blocks.py new file mode 100644 index 00000000..7504d370 --- /dev/null +++ b/lib/evn/evn/tests/format/test_detect_formatted_blocks.py @@ -0,0 +1,163 @@ +import pytest +import evn + + +def main(): + pass + + +@pytest.fixture +def ifb(): + return evn.format.IdentifyFormattedBlocks() + + +def test_strange_cases(ifb): + assert ifb.mark_formtted_blocks('', threshold=0.99) == '' + assert ifb.mark_formtted_blocks('\n', threshold=0.99) == '\n' + assert ifb.mark_formtted_blocks('\n\n', threshold=0.99) == '\n\n' + assert ifb.mark_formtted_blocks('========', threshold=0.99) + # assert ifb.mark_formtted_blocks('========\n========', threshold=0.99) + # assert ifb.mark_formtted_blocks('========\n========\n', threshold=0.99) + assert ifb.unmark('') == '' + assert ifb.unmark('\n') == '\n' + assert ifb.unmark('\n\n') == '\n' + assert ifb.unmark('========') + assert ifb.unmark('========\n========') + assert ifb.unmark('========\n========\n') + + +def test_unmark(ifb): + test = """q + # fmt: on +z + # fmt: off +a +# fmt: off +b +# fmt: on +c +""" + test2 = """q +z +a +b +c +""" + assert ifb.unmark(test) == test2 + + +def test_mark_formtted_blocks_no_change(ifb): + # If lines are dissimilar (or threshold is set high), + # the mark_formtted_blocks function should return code without formatting markers. + code = 'line one\nline two' + result = ifb.mark_formtted_blocks(code, threshold=5) + # We strip both strings to avoid any trailing newline differences. + assert result.strip() == code.strip() + + +def test_mark_formtted_blocks_formatting(ifb): + # When lines are similar, formatting markers should be inserted. + # Here we use a low threshold to force detection. + code = '\n int a = 0;\n int a = 0;' + print(ifb.compute_similarity_score('int a = 0', 'int a = 0')) + result = ifb.mark_formtted_blocks(code, threshold=2) + assert '# fmt: off' in result + assert '# fmt: on' in result + + +def test_whitespace(ifb): + # Test that the function handles leading/trailing whitespace correctly. + code = ' line one\n\n\n\n line two' + result = ifb.unmark(code) + assert len(result.split('\n')) == 4 + + +def test_inline_blocks_are_marked(ifb): + # Test that inline blocks are marked correctly. + code = """ + def example_function(): foo + if True: False + class Banana: ... + elif bar: baz + else: qux +""" + result = ifb.mark_formtted_blocks(code, threshold=2) + assert (result == """ + # fmt: off + def example_function(): foo + # fmt: on + # fmt: off + if True: False + # fmt: on + # fmt: off + class Banana: ... + # fmt: on + # fmt: off + elif bar: baz + # fmt: on + # fmt: off + else: qux + # fmt: on +""") + + +def test_inline_blocks_are_marked2(ifb): + # Test that inline blocks are marked correctly. + code = """ + print('foo') + + def example_function(): foo + dummy + if True: False + + dummy2 + class Banana: ... + elif bar: baz + else: qux + aaaa +""" + result = ifb.mark_formtted_blocks(code, threshold=2) + assert (result == """ + print('foo') + + # fmt: off + def example_function(): foo + # fmt: on + dummy + # fmt: off + if True: False + # fmt: on + + dummy2 + # fmt: off + class Banana: ... + # fmt: on + # fmt: off + elif bar: baz + # fmt: on + # fmt: off + else: qux + # fmt: on + aaaa +""") + + +def test_multiline_is_ignored(ifb): + # Test that inline blocks are marked correctly. + code = """ + if a: return b \\ + else: return c + for a in b: c +""" + result = ifb.mark_formtted_blocks(code, threshold=4) + assert (result == """ + if a: return b \\ + else: return c + # fmt: off + for a in b: c + # fmt: on +""") + + +if __name__ == '__main__': + main() diff --git a/lib/evn/evn/tests/format/test_formatter.py b/lib/evn/evn/tests/format/test_formatter.py new file mode 100644 index 00000000..22ee99a0 --- /dev/null +++ b/lib/evn/evn/tests/format/test_formatter.py @@ -0,0 +1,533 @@ +import difflib +import pytest +from evn.format import MarkHandFormattedBlocksCpp, RuffFormat, CodeFormatter, UnmarkCpp, AlignTokensCpp +import evn + +def main(): + evn.testing.quicktest(globals()) + +splitter = '======== โ†‘ original โ†“ formatted ========' + +@pytest.mark.parametrize( + 'testcase', + [ + """ +class Example: pass +# fmt: off +class Example: pass +# fmt: on +class Example: pass +======== โ†‘ original โ†“ formatted ======== +class Example: + pass + + +# fmt: off +class Example: pass +# fmt: on +class Example: + pass +""", + """ +# fmt: off +class Example: pass +# fmt: on +class Foo: pass +def foo(): + # fmt: off + if a: b + else: c + # fmt: on + if a: b + else: c +======== โ†‘ original โ†“ formatted ======== +# fmt: off +class Example: pass +# fmt: on +class Foo: + pass + + +def foo(): + # fmt: off + if a: b + else: c + # fmt: on + if a: + b + else: + c +""", + ], +) +def test_ruff_formatting(testcase): + """Test full formatting pipeline: AddFmtMarkers โ†’ RuffFormat โ†’ RemoveFmtMarkers โ†’ RemoveExtraBlankLines.""" + formatter = CodeFormatter(actions=[RuffFormat()]) + original, expected = testcase.split(splitter) + original = original.strip() + expected = expected.strip() + formatted = formatter.run({ + 'test_case.py': original + }).buffers['test_case.py']['formatted'] + err = f'Formatting failed on:\n------------------- orig ------------------------\n{original}\n------------------------ Got:------------------------\n{formatted}\n------------------------Expected: ------------------------\n{expected}' + # print('***************************************') + # print(expected) + # print('***************************************') + # print(formatted) + # print('***************************************') + # # print("\n".join(diff)) + # print('\n'.join(difflib.ndiff(expected.splitlines(), formatted.splitlines()))) + # # print(TEST.dev.diff(expected, formatted)) + # print('***************************************') + assert formatted.strip() == expected.strip(), err + + +@pytest.mark.parametrize( + 'testcase', + [ + """ +print('hello') +print('world') +======== โ†‘ original โ†“ formatted ======== +print('hello') +print('world') +""", + """ +print('start') +def foo(): pass + +def bar(): + pass +======== โ†‘ original โ†“ formatted ======== +print('start') +# fmt: off +def foo(): pass +# fmt: on + +def bar(): + pass +""", + (""" +class Example: + def method(self): + if self.flag: return True + def another(self): return False +======== โ†‘ original โ†“ formatted ======== +class Example: + def method(self): + # fmt: off + if self.flag: return True + # fmt: on + # fmt: off + def another(self): return False + # fmt: on +"""), + (""" +class Example: + def method(self): + if self.flag: return True + print('foo') + monkey = [banana, kiwi , apple, strawberry,] + alpha = [beta , gamma, delta, epsilon,] + monkey = [banana, kiwi , apple, strawberry,] + alpha = [beta , gamma, delta, epsilon,] + monkey = [banana, kiwi , apple, strawberry,] + print(bar) + def another(self): return False +======== โ†‘ original โ†“ formatted ======== +class Example: + def method(self): + # fmt: off + if self.flag: return True + # fmt: on + print('foo') + # fmt: off + monkey = [banana, kiwi , apple, strawberry,] + alpha = [beta , gamma, delta, epsilon,] + monkey = [banana, kiwi , apple, strawberry,] + alpha = [beta , gamma, delta, epsilon,] + monkey = [banana, kiwi , apple, strawberry,] + # fmt: on + print(bar) + # fmt: off + def another(self): return False + # fmt: on +"""), + ], +) +def test_mark_formatted_blocks(testcase): + """Test full formatting pipeline: AddFmtMarkers โ†’ RuffFormat โ†’ RemoveFmtMarkers โ†’ RemoveExtraBlankLines.""" + formatter = CodeFormatter(actions=[MarkHandFormattedBlocksCpp()]) + original, expected = testcase.split(splitter) + original = original.strip() + expected = expected.strip() + formatted = formatter.run({ + 'test_case.py': original + }).buffers['test_case.py']['formatted'] + assert ( + formatted.strip() == expected.strip() + ), f'Formatting failed on:\n------------------- orig ------------------------\n{original}\n------------------------ Got:------------------------\n{formatted}\n------------------------Expected: ------------------------\n{expected}' + + +@pytest.mark.parametrize( + 'testcase', + [ + """ +print('hello') +print('world') +======== โ†‘ original โ†“ formatted ======== +print('hello') +print('world') +""", + """ +import foo +def foo(): pass + +def bar(): + pass +======== โ†‘ original โ†“ formatted ======== +import foo + + +# fmt: off +def foo(): pass +# fmt: on + + +def bar(): + pass +""", + """ +class Example: + def method(self): + if self.flag: return True + def another(self): return False +======== โ†‘ original โ†“ formatted ======== +class Example: + def method(self): + # fmt: off + if self.flag: return True + # fmt: on + + # fmt: off + def another(self): return False + # fmt: on + +""", + """ +class Example: + def method(self): + if self.flag: return True + print('foo') + monkey = [banana, kiwi , apple, strawberry,] + alpha = [beta , gamma, delta, epsilon,] + monkey = [banana, kiwi , apple, strawberry,] + alpha = [beta , gamma, delta, epsilon,] + monkey = [banana, kiwi , apple, strawberry,] + print(bar) + def another(self): return False +======== โ†‘ original โ†“ formatted ======== +class Example: + def method(self): + # fmt: off + if self.flag: return True + # fmt: on + + print('foo') + # fmt: off + monkey = [banana, kiwi , apple, strawberry,] + alpha = [beta , gamma, delta, epsilon,] + monkey = [banana, kiwi , apple, strawberry,] + alpha = [beta , gamma, delta, epsilon,] + monkey = [banana, kiwi , apple, strawberry,] + # fmt: on + print(bar) + + # fmt: off + def another(self): return False + # fmt: on + +""", + ], +) +def test_mark_blocks_ruff(testcase): + """Test full formatting pipeline: AddFmtMarkers โ†’ RuffFormat โ†’ RemoveFmtMarkers โ†’ RemoveExtraBlankLines.""" + formatter = CodeFormatter( + actions=[MarkHandFormattedBlocksCpp(), + RuffFormat()]) + original, expected = testcase.split(splitter) + original = original.strip() + expected = expected.strip() + formatted = formatter.run({ + 'test_case.py': original + }, debug=False).buffers['test_case.py']['formatted'] + err = f'Formatting failed on:\n------------------- orig ------------------------\n{original}\n------------------------ Got:------------------------\n{formatted}\n------------------------Expected: ------------------------\n{expected}' + assert formatted.strip() == expected.strip(), err + + +@pytest.mark.parametrize( + 'testcase', + [ + """ +print('hello') +print('world') +======== โ†‘ original โ†“ formatted ======== +print('hello') +print('world') +""", + """ +import foo + + +# fmt: off +def foo(): pass +# fmt: on + + +def bar(): + pass +======== โ†‘ original โ†“ formatted ======== +import foo + +def foo(): pass + +def bar(): + pass +""", + """ +class Example: + def method(self): + # fmt: off + if self.flag: return True + # fmt: on + + # fmt: off + def another(self): return False + # fmt: on + +======== โ†‘ original โ†“ formatted ======== +class Example: + def method(self): + if self.flag: return True + + def another(self): return False +""", + """ +class Example: + def method(self): + # fmt: off + if self.flag: return True + # fmt: on + + print('foo') + # fmt: off + monkey = [banana, kiwi , apple, strawberry,] + alpha = [beta , gamma, delta, epsilon,] + monkey = [banana, kiwi , apple, strawberry,] + alpha = [beta , gamma, delta, epsilon,] + monkey = [banana, kiwi , apple, strawberry,] + # fmt: on + print(bar) + + # fmt: off + def another(self): return False + # fmt: on +======== โ†‘ original โ†“ formatted ======== +class Example: + def method(self): + if self.flag: return True + + print('foo') + monkey = [banana, kiwi , apple, strawberry,] + alpha = [beta , gamma, delta, epsilon,] + monkey = [banana, kiwi , apple, strawberry,] + alpha = [beta , gamma, delta, epsilon,] + monkey = [banana, kiwi , apple, strawberry,] + print(bar) + + def another(self): return False +""", + ], +) +def test_unmark(testcase): + """Test full formatting pipeline: AddFmtMarkers โ†’ RuffFormat โ†’ RemoveFmtMarkers โ†’ RemoveExtraBlankLines.""" + formatter = CodeFormatter([UnmarkCpp()]) + original, expected = testcase.split(splitter) + original = original.strip() + expected = expected.strip() + formatted = formatter.run({ + 'test_case.py': original + }, debug=False).buffers['test_case.py']['formatted'] + err = f'Formatting failed on:\n------------------- orig ------------------------\n{original}\n------------------------ Got:------------------------\n{formatted}\n------------------------Expected: ------------------------\n{expected}' + assert formatted.strip() == expected.strip(), err + + +@pytest.mark.parametrize( + 'testcase', + [ + """ +print('hello') +print('world') +======== โ†‘ original โ†“ formatted ======== +print('hello') +print('world') +""", + """ +import foo +def foo(): pass + +def bar(): + pass +======== โ†‘ original โ†“ formatted ======== +import foo + +def foo(): pass + +def bar(): + pass +""", + """ +class Example: + def method(self): + if self.flag: return True + def another(self): return False +======== โ†‘ original โ†“ formatted ======== +class Example: + def method(self): + if self.flag: return True + + def another(self): return False +""", + """ +class Example: + def method(self): + if self.flag: return True + print('foo') + monkey = [banana, kiwi , apple, strawberry,] + alpha = [beta , gamma, delta, epsilon,] + monkey = [banana, kiwi , apple, strawberry,] + alpha = [beta , gamma, delta, epsilon,] + monkey = [banana, kiwi , apple, strawberry,] + print(bar) + def another(self): return False +======== โ†‘ original โ†“ formatted ======== +class Example: + def method(self): + if self.flag: return True + + print('foo') + monkey = [banana, kiwi , apple, strawberry,] + alpha = [beta , gamma, delta, epsilon,] + monkey = [banana, kiwi , apple, strawberry,] + alpha = [beta , gamma, delta, epsilon,] + monkey = [banana, kiwi , apple, strawberry,] + print(bar) + + def another(self): return False +""", + ], +) +def test_mark_blocks_ruff_unmark(testcase): + """Test full formatting pipeline: AddFmtMarkers โ†’ RuffFormat โ†’ RemoveFmtMarkers โ†’ RemoveExtraBlankLines.""" + formatter = CodeFormatter( + [MarkHandFormattedBlocksCpp(), + RuffFormat(), UnmarkCpp()]) + original, expected = testcase.split(splitter) + original = original.strip() + expected = expected.strip() + formatted = formatter.run({ + 'test_case.py': original + }, debug=False).buffers['test_case.py']['formatted'] + err = f'Formatting failed on:\n------------------- orig ------------------------\n{original}\n------------------------ Got:------------------------\n{formatted}\n------------------------Expected: ------------------------\n{expected}' + assert formatted.strip() == expected.strip(), err + + +@pytest.mark.parametrize( + 'testcase', + [ + """ +print('hello') +print('world') +======== โ†‘ original โ†“ formatted ======== +# fmt: off +print('hello') +print('world') +# fmt: on +""", + """ +import foo +def foo(): pass +def bar(): + pass +======== โ†‘ original โ†“ formatted ======== +import foo +# fmt: off +def foo(): pass +# fmt: on +def bar(): + pass +""", + """ +class Example: + def method(self): + if self.flag: return True + def another(self): return False +======== โ†‘ original โ†“ formatted ======== +class Example: + def method(self): + # fmt: off + if self.flag: return True + # fmt: on + # fmt: off + def another(self): return False + # fmt: on +""", + """ +class Example: + def method(self): + if self.flag: return True + print('foo') + monkey = [banana, kiwi , apple, strawberry,] + alpha = [beta, gamma, delta, epsilon,] + monkey = [banana, kiwi , apple, strawberry,] + alpha = [beta, gamma, delta, epsilon,] + monkey = [banana, kiwi , apple, strawberry,] + print(bar) + def another(self): return False +======== โ†‘ original โ†“ formatted ======== +class Example: + def method(self): + # fmt: off + if self.flag: return True + # fmt: on + print('foo') + # fmt: off + monkey = [banana, kiwi , apple, strawberry,] + alpha = [beta , gamma, delta, epsilon ,] + monkey = [banana, kiwi , apple, strawberry,] + alpha = [beta , gamma, delta, epsilon ,] + monkey = [banana, kiwi , apple, strawberry,] + # fmt: on + print(bar) + # fmt: off + def another(self): return False + # fmt: on +""", + ], +) +def test_cpp_align_tokens(testcase): + """Test full formatting pipeline: AddFmtMarkers โ†’ RuffFormat โ†’ RemoveFmtMarkers โ†’ RemoveExtraBlankLines.""" + formatter = CodeFormatter([AlignTokensCpp()]) + original, expected = testcase.split(splitter) + original = original.strip() + expected = expected.strip() + formatted = formatter.run({ + 'test_case.py': original + }, debug=False).buffers['test_case.py']['formatted'] + err = '\n'.join( + difflib.ndiff(expected.splitlines(), formatted.splitlines())) + assert formatted.strip() == expected.strip(), err + + +if __name__ == '__main__': + main() diff --git a/lib/evn/evn/tests/format/test_token_column_format.py b/lib/evn/evn/tests/format/test_token_column_format.py new file mode 100644 index 00000000..fdb3e691 --- /dev/null +++ b/lib/evn/evn/tests/format/test_token_column_format.py @@ -0,0 +1,334 @@ +import difflib +import pytest +import evn + +# --- Tokenization Tests --- + + +@pytest.fixture +def tokenizer(): + return evn.format.PythonLineTokenizer() + + +def helper_test_reformat_lines(tokenizer, lines, expected): + output = tokenizer.reformat_lines(lines, add_fmt_tag=True) + if output == expected: + return + output = tokenizer.reformat_lines(lines, add_fmt_tag=True, debug=True) + print('+++++++++++++++++++ ndiff +++++++++++++++++++') + print('\n'.join(difflib.ndiff(expected, output))) + print('+++++++++++++++++++++++++++++++++++++++++++++') + assert output == expected + + +def test_tokenize_basic(tokenizer): + code_line = 'a = b + c' + tokens = evn.format.tokenize(code_line) + expected = ['a', '=', 'b', '+', 'c'] + assert tokens == expected + + +def test_tokenize_with_comment(tokenizer): + code_line = 'x = 42 # this is a comment' + tokens = evn.format.tokenize(code_line) + expected = ['x', '=', '42', '# this is a comment'] + assert tokens == expected + + +def test_tokenize_string_literal(tokenizer): + code_line = "print('Hello, \\'World\\'!')" + tokens = evn.format.tokenize(code_line) + expected = ['print', '(', "'Hello, \\'World\\'!'", ')'] + assert tokens == expected + + +def test_tokenize_fstring(tokenizer): + code_line = 'f"Hello, {name}!"' + tokens = evn.format.tokenize(code_line) + expected = ['f"Hello, {name}!"'] + assert tokens == expected + + +def test_tokenize_triple_quote_string(tokenizer): + code_line = "s = '''string literal'''" + tokens = evn.format.tokenize(code_line) + expected = ['s', '=', "'''string literal'''"] + assert tokens == expected + + +def test_tokenize_lambda_expression(tokenizer): + code_line = 'lambda x, y=42: (x + y) if x > y else (x - y)' + tokens = evn.format.tokenize(code_line) + expected = [ + 'lambda', + 'x', + ',', + 'y', + '=', + '42', + ':', + '(', + 'x', + '+', + 'y', + ')', + 'if', + 'x', + '>', + 'y', + 'else', + '(', + 'x', + '-', + 'y', + ')', + ] + assert tokens == expected + assert tokenizer.join_tokens(tokens) == code_line + + +def test_tokenize_multi_char_operators(tokenizer): + code_line = 'a ** b // c != d -> e' + tokens = evn.format.tokenize(code_line) + expected = ['a', '**', 'b', '//', 'c', '!=', 'd', '->', 'e'] + assert tokens == expected + + +def test_tokenize_while(tokenizer): + code_line = 'while if this is True: break out # comment' + tokens = evn.format.tokenize(code_line) + expected = [ + 'while', 'if', 'this', 'is', 'True', ':', 'break', 'out', '# comment' + ] + assert tokens == expected + assert not evn.format.is_oneline_statement(tokens[:-3]) + assert evn.format.is_oneline_statement(tokens[:-2]) + assert evn.format.is_oneline_statement(tokens) + + +# --- Join Tokens (Black-like formatting) Tests --- + + +def test_join_tokens_default_black_formatting(tokenizer): + tokens = ['def', 'greet', '(', 'name', ')', ':'] + joined = tokenizer.join_tokens(tokens) + # Expected: no space between function name and its call parentheses. + expected = 'def greet(name):' + assert joined == expected + + +def test_join_tokens_with_operators(tokenizer): + tokens = ['a', '=', 'b', '+', 'c', '*', '(', 'd', '-', 'e', ')', '/', 'f'] + joined = tokenizer.join_tokens(tokens) + expected = 'a = b + c * (d - e) / f' + assert joined == expected + + +def test_join_tokens_with_commas_and_colons(tokenizer): + tokens = ['print', '(', "'a'", ',', "'b'", ',', "'c'", ')'] + joined = tokenizer.join_tokens(tokens) + expected = "print('a', 'b', 'c')" + assert joined == expected + + +def test_join_tokens_complex_expression(tokenizer): + code_line = 'def func(a, b=2): return a**b + (a - b)*3.14' + tokens = evn.format.tokenize(code_line) + joined = tokenizer.join_tokens(tokens) + expected = 'def func(a, b=2): return a ** b + (a - b) * 3.14' + assert joined == expected + + +def test_join_tokens_operator_spacing(tokenizer): + tokens = ['x', '=', 'a', '==', 'b', 'and', 'c', '!=', 'd'] + joined = tokenizer.join_tokens(tokens) + expected = 'x = a == b and c != d' + assert joined == expected + + +def test_join_tokens_nested_parens(tokenizer): + code_line = 'print((a+b)*(c-d))' + tokens = evn.format.tokenize(code_line) + joined = tokenizer.join_tokens(tokens) + expected = 'print((a+b) * (c-d))' + assert joined == expected + + +def test_join_tokens_mixed_syntax(tokenizer): + code_line = 'def add(a,b):return a+b' + tokens = evn.format.tokenize(code_line) + joined = tokenizer.join_tokens(tokens) + expected = 'def add(a, b): return a + b' + assert joined == expected + + +# --- Token Matching Tests --- + + +def test_tokens_match_wildcards(tokenizer): + code1 = 'def compute(x): return 100 + x' + code2 = 'def compute(y): return 200 + y' + tokens1 = evn.format.tokenize(code1) + tokens2 = evn.format.tokenize(code2) + assert evn.format.tokens_match(tokens1, tokens2) + + +def test_tokens_match_fail_due_to_operator(tokenizer): + code1 = 'def compute(x): return 100 * x' + code2 = 'def compute(x): return 100 / x' + tokens1 = evn.format.tokenize(code1) + tokens2 = evn.format.tokenize(code2) + assert not evn.format.tokens_match(tokens1, tokens2) + + +def test_tokens_match_keyword_mismatch(tokenizer): + code1 = 'if x > 0: print(x)' + code2 = 'while x > 0: print(x)' + tokens1 = evn.format.tokenize(code1) + tokens2 = evn.format.tokenize(code2) + assert not evn.format.tokens_match(tokens1, tokens2) + + +def test_tokens_match_identifier_vs_string(tokenizer): + code1 = "def f(x): return 'value'" + code2 = "def f('x'): return x" + tokens1 = evn.format.tokenize(code1) + tokens2 = evn.format.tokenize(code2) + assert not evn.format.tokens_match(tokens1, tokens2) + + +def test_no_blocks(tokenizer): + # All lines have different token patterns; output should match input exactly. + lines = [ + "print('foo')", + 'b = 2', + 'if a > b: print(a)', + 'while True: break', + ] + expected = [ + "print('foo')", + 'b = 2', + '# fmt: off', + 'if a > b: print(a)', + '# fmt: on', + '# fmt: off', + 'while True: break', + '# fmt: on', + ] + helper_test_reformat_lines(tokenizer, lines, expected) + + +def test_single_block_alignment(tokenizer): + # All lines share the same token pattern and indentation. + # For example, three assignment lines. + lines = [' a=1', ' bb=22', ' ccc=333'] + output = tokenizer.reformat_lines(lines) + # Instead of splitting on whitespace, we check that the '=' appears at the same index. + eq_indices = [line.find('=') for line in output if '=' in line] + assert len(set(eq_indices) + ) == 1, f'Equal sign appears at different columns: {eq_indices}' + + +def test_multiple_blocks(tokenizer): + # Mix several blocks. + lines = [ + 'x=10', 'y=20', 'z=30', '', ' a=1', ' bb=22', ' ccc=333', + "print('done')" + ] + output = tokenizer.reformat_lines(lines) + # For the first block (no indent), check that the '=' sign is in the same column. + block1 = [ + line for line in output + if not line.startswith(' ') and line.strip() and 'print' not in line + ] + if len(block1) >= 3: + indices1 = [line.find('=') for line in block1 if '=' in line] + assert len(set(indices1)) == 1, f"Block1 '=' indices: {indices1}" + # For the second block (indent 4 spaces) + block2 = [line for line in output if line.startswith(' ')] + if len(block2) >= 3: + indices2 = [line.find('=') for line in block2 if '=' in line] + assert len(set(indices2)) == 1, f"Block2 '=' indices: {indices2}" + + +def test_block_boundary_by_indent(tokenizer): + # Two blocks with different indentations. + lines = ['def f():', ' a=1', ' b=2', ' c=3', "print('done')"] + output = tokenizer.reformat_lines(lines) + # The block inside the function should be aligned, while the other lines are unchanged. + assert output[0] == 'def f():' + assert output[-1] == "print('done')" + inner = [line for line in output if line.startswith(' ')] + eq_indices = [line.find('=') for line in inner if '=' in line] + assert len(set(eq_indices)) == 1, f"Inner block '=' indices: {eq_indices}" + + +def test_block_boundary_by_token_pattern(tokenizer): + # Lines with same indentation but different token patterns should not group. + lines = [' a=1', ' print(a)', ' b=2'] + output = tokenizer.reformat_lines(lines) + # Expect that no block is formed; output should equal input. + assert output == lines + + +def test_heuristic_length_mismatch(tokenizer): + # If one line is very short and the next is much longer, they should not be grouped. + lines = [' a=1', ' verylongidentifier=22'] + output = tokenizer.reformat_lines(lines) + # Expect that each line is output separately. + assert output == lines + + +def test_empty_and_whitespace_lines(tokenizer): + # Blank lines or lines with only spaces should be preserved. + lines = [' a=1', ' b=2', ' ', '', ' c=3'] + output = tokenizer.reformat_lines(lines) + assert output[2] == '' + assert output[3] == '' + + +def test_multiple_blocks_combined(tokenizer): + # Mix several blocks with different indents and token patterns. + lines = [ + 'x=10', 'y=20', 'z=30', '', ' a=1', ' bb=22', ' ccc=333', + "print('done')" + ] + output = tokenizer.reformat_lines(lines) + # Check that each block is independently formatted. + block1 = [ + line for line in output + if not line.startswith(' ') and line.strip() and 'print' not in line + ] + block2 = [line for line in output if line.startswith(' ')] + if len(block1) >= 3: + indices1 = [line.find('=') for line in block1 if '=' in line] + assert len(set(indices1)) == 1, f"Block1 '=' indices: {indices1}" + if len(block2) >= 3: + indices2 = [line.find('=') for line in block2 if '=' in line] + assert len(set(indices2)) == 1, f"Block2 '=' indices: {indices2}" + + +def test_reformat_lines_with_comments(tokenizer): + # Ensure comments are preserved and not altered. + lines = ['x=10 # This is a comment', 'y=20', 'z=30 # Another comment'] + output = tokenizer.reformat_lines(lines) + assert output[0] == 'x=10 # This is a comment' + assert output[1] == 'y=20' + assert output[2] == 'z=30 # Another comment' + + +def test_reformat_lines_with_empty_lines(tokenizer): + # Ensure empty lines are preserved. + lines = ['x=10', '', 'y=20', ' ', 'z=30'] + output = tokenizer.reformat_lines(lines) + assert output[1] == '' + assert output[3] == '' + + +def test_reformat_lines_with_mixed_indentation(tokenizer): + # Ensure mixed indentation is preserved. + lines = ['x=10', ' y=20', 'z=30', ' ', ' a=1'] + output = tokenizer.reformat_lines(lines) + assert output[1] == ' y=20' + assert output[3] == '' + assert output[4] == ' a=1' diff --git a/lib/evn/evn/tests/meta/test_kwcall.py b/lib/evn/evn/tests/meta/test_kwcall.py new file mode 100644 index 00000000..00361036 --- /dev/null +++ b/lib/evn/evn/tests/meta/test_kwcall.py @@ -0,0 +1,298 @@ +import unittest +import pytest + +import evn + +config_test = evn.Bunch( + re_only=[], + re_exclude=[], +) + + +def main(): + evn.testing.quicktest( + namespace=globals(), + config=config_test, + verbose=1, + check_xfail=False, + ) + + +def test_locals(): + foo, bar, baz = 1, 2, 3 + assert evn.meta.picklocals('foo bar', asdict=True) == dict(foo=1, bar=2) + + +def test_addreduce(): + assert evn.addreduce([[1], [2, 3], [4]]) == [1, 2, 3, 4] + + +@pytest.mark.xfail +def test_get_function_for_which_call_to_caller_is_argument(): + + def FIND_THIS_FUNCTION(*a, **kw): + ... + + def CALLED_TO_PRODUCE_ARGUMENT(): + uncle_func = evn.meta.get_function_for_which_call_to_caller_is_argument() + assert uncle_func == FIND_THIS_FUNCTION + + FIND_THIS_FUNCTION(1, 2, CALLED_TO_PRODUCE_ARGUMENT(), 3) + + +def test_kwcheck(): + kw = dict(apple='apple', banana='banana', cherry='cherry') + + def foo(apple): + return apple + + def bar(banana, cherry): + return banana, cherry + + with pytest.raises(TypeError): + foo(**kw) + with pytest.raises(TypeError): + bar(**kw) + + assert foo(**evn.kwcheck(kw, foo)) == evn.kwcall(kw, foo) + assert bar(**evn.kwcheck(kw, bar)) == evn.kwcall(kw, bar) + + assert evn.kwcall(kw, bar, banana='bananums') == ('bananums', 'cherry') + + +def target_func(a, b, c=3): + pass + + +class SomeClass: + + def method(self, param1, param2, optional=None): + pass + + +class_method = SomeClass().method + + +def flexible_func(a, b, *args, c=3, **kwargs): + pass + + +def test_kwcheck_explicit_function_filtering(): + """Test basic filtering with explicitly provided function.""" + kwargs = {'a': 1, 'b': 2, 'd': 4, 'e': 5} + result = evn.kwcheck(kwargs, target_func, checktypos=False) + + # Should only keep keys that match target_func parameters + assert result == {'a': 1, 'b': 2} + + +def test_kwcheck_checktypos_flag_disabled(): + """Test that no typo checking occurs when checktypos=False.""" + + def func(alpha, beta, gamma=3): + pass + + # 'alpho' is a potential typo for 'alpha' + kwargs = {'alpho': 1, 'beta': 2} + + # Should not raise TypeError because checktypos=False + result = evn.kwcheck(kwargs, func, checktypos=False) + assert result == {'beta': 2} + + +def test_kwcheck_typo_detection(): + """Test that typos are detected and raise TypeError.""" + + func = lambda alpha, beta, gamma=3: ... + + # 'alpho' is a potential typo for 'alpha' + kwargs = {'alpho': 1, 'beta': 2} + + # Should raise TypeError due to 'alpho' being close to 'alpha' + with pytest.raises(TypeError) as excinfo: + evn.kwcheck(kwargs, func, checktypos=True) + + # Check that the error message contains both the typo and suggestion + assert 'alpho' in str(excinfo.value) + assert 'alpha' in str(excinfo.value) + + +def test_kwcheck_no_typo_for_dissimilar(): + """Test that dissimilar argument names don't trigger typo detection.""" + + def func(first, second, third=3): + pass + + # 'fourth' is not similar enough to any parameter + kwargs = {'fourth': 4, 'first': 1} + + # Should not raise TypeError, just filter out 'fourth' + result = evn.kwcheck(kwargs, func) + assert result == {'first': 1} + + +def test_kwcheck_automatic_function_detection(): + """Test automatic detection of the calling function.""" + + def func(x, y, z=3): + pass + + kwargs = {'x': 1, 'y': 2, 'extra': 3} + result = evn.kwcheck(kwargs, func) + assert result == {'x': 1, 'y': 2} + + +def test_kwcheck_no_function_detection_error(): + """Test that an error is raised when function detection fails.""" + with pytest.raises(TypeError) as excinfo: + evn.kwcheck({'a': 1}) + assert "Couldn't get function" in str(excinfo.value) + + +def test_kwcheck_method_as_function(): + """Test that evn.kwcheck works with methods as well as functions.""" + kwargs = {'param1': 'value1', 'param2': 'value2', 'other': 'value3'} + + result = evn.kwcheck(kwargs, class_method) + assert result == {'param1': 'value1', 'param2': 'value2'} + + +def test_kwcheck_integration_with_function_call(): + """Test using evn.kwcheck directly in a function call (integration test).""" + + def function_with_specific_args(required, optional=None): + return (required, optional) + + # Create a wrapper that simulates the actual usage pattern + def call_with_kwcheck(): + kwargs = {'required': 'value', 'extra': 'ignored'} + return function_with_specific_args( + **evn.kwcheck(kwargs, function_with_specific_args)) + + result = call_with_kwcheck() + assert result == ('value', None) + + +def test_kwcheck_with_varargs_and_varkw(): + """Test with functions that use *args and **kwargs.""" + kwargs = {'a': 1, 'b': 2, 'c': 4, 'd': 5, 'e': 6} + result = evn.kwcheck(kwargs, flexible_func) + assert result == kwargs + + +def test_kwcheck_empty_kwargs(): + """Test with empty kwargs dictionary.""" + result = evn.kwcheck({}, target_func) + assert result == {} + + +def test_kwcheck_all_kwargs_match(): + """Test when all kwargs match function parameters.""" + kwargs = {'a': 1, 'b': 2, 'c': 3} + result = evn.kwcheck(kwargs, target_func) + assert result == kwargs + assert result is not kwargs # Should be a copy, not the same object + + +def test_kwcheck_kwargs_with_none_values(): + """Test with None values in kwargs.""" + kwargs = {'a': None, 'b': None, 'd': None} + result = evn.kwcheck(kwargs, target_func) + assert result == {'a': None, 'b': None} + + +def test_kwcheck_with_kwargs_func(): + """Test with None values in kwargs.""" + func = lambda a, b, c=3, **kw: ... + kwargs = {'a': None, 'b': None, 'd': None} + result = evn.kwcheck(kwargs, func) + assert result == kwargs + + +class TestFilterMapping(unittest.TestCase): + + def setUp(self): + self.map = { + 'test_func1': lambda: 'func1', + 'test_func2': lambda: 'func2', + 'test_funcA': lambda: 'funcA', + 'test_funcB': lambda: 'funcB', + 'test_other': lambda: 'other', + 'normal_func': lambda: 'normal', + } + + def test_default_behavior(self): + map = self.map.copy() + evn.meta.filter_namespace_funcs(map) + assert 'test_func1' in map + assert 'test_func2' in map + assert 'test_funcA' in map + assert 'test_funcB' in map + assert 'test_other' in map + assert 'normal_func' in map + + def test_only(self): + map = self.map.copy() + evn.meta.filter_namespace_funcs(map, only=('test_func1', )) + assert 'test_func1' in map + assert 'test_func2' not in map + assert 'test_funcA' not in map + assert 'test_funcB' not in map + assert 'test_other' not in map + + def test_exclude(self): + map = self.map.copy() + evn.meta.filter_namespace_funcs(map, exclude=('test_func1', )) + assert 'test_func1' not in map + assert 'test_func2' in map + + def test_re_only(self): + map = self.map.copy() + evn.meta.filter_namespace_funcs(map, re_only=('test_func[0-9]', )) + assert 'test_func1' in map + assert 'test_func2' in map + assert 'test_funcA' not in map + assert 'test_funcB' not in map + assert 'test_other' not in map + + def test_re_exclude(self): + map = self.map.copy() + evn.meta.filter_namespace_funcs(map, re_exclude=('test_func[0-9]', )) + assert 'test_func1' not in map + assert 'test_func2' not in map + assert 'test_funcA' in map + assert 'test_funcB' in map + assert 'test_other' in map + + def test_re_only_letters(self): + map = self.map.copy() + evn.meta.filter_namespace_funcs(map, re_only=('test_func[A-Z]', )) + assert 'test_func1' not in map + assert 'test_func2' not in map + assert 'test_funcA' in map + assert 'test_funcB' in map + assert 'test_other' not in map + + def test_combination_only_and_exclude(self): + map = self.map.copy() + evn.meta.filter_namespace_funcs(map, + only=('test_func1', ), + exclude=('test_func1', )) + assert 'test_func1' not in map + assert 'test_func2' not in map + assert 'test_funcA' not in map + assert 'test_funcB' not in map + + def test_combination_re_only_and_re_exclude(self): + map = self.map.copy() + evn.meta.filter_namespace_funcs(map, + re_only=('test_func[0-9]', ), + re_exclude=('test_func1', )) + assert 'test_func1' not in map + assert 'test_func2' in map + assert 'test_funcA' not in map + assert 'test_funcB' not in map + + +if __name__ == '__main__': + main() diff --git a/lib/evn/evn/tests/print/test_table.py b/lib/evn/evn/tests/print/test_table.py new file mode 100644 index 00000000..c7510b81 --- /dev/null +++ b/lib/evn/evn/tests/print/test_table.py @@ -0,0 +1,46 @@ +import pytest +import evn + + +def main(): + evn.testing.quicktest(namespace=globals()) + + +bunch = evn.Bunch( + dot_norm=evn.Bunch(frac=0.174, tol=0.04, total=282, passes=49), + isect=evn.Bunch(frac=0.149, tol=1.0, total=302, passes=45), + angle=evn.Bunch(frac=0.571, tol=0.09, total=42, passes=24), + helical_shift=evn.Bunch(frac=1.0, tol=1.0, total=47, passes=47), + axistol=evn.Bunch(frac=0.412, tol=0.1, total=17, passes=7), + nfold=evn.Bunch(frac=1.0, tol=0.2, total=5, passes=5), + cageang=evn.Bunch(frac=0.5, tol=0.1, total=2, passes=1), +) + + +def test_make_table_dict_of_dict(): + with evn.capture_stdio() as out: + evn.print.print_table(bunch) + printed = out.read() + assert (printed.strip() == """ +โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”ณโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”ณโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”ณโ”โ”โ”โ”โ”โ”โ”โ”ณโ”โ”โ”โ”โ”โ”โ”โ”โ”“ +โ”ƒ key โ”ƒ frac โ”ƒ tol โ”ƒ total โ”ƒ passes โ”ƒ +โ”กโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ•‡โ”โ”โ”โ”โ”โ”โ”โ”โ”โ•‡โ”โ”โ”โ”โ”โ”โ”โ”โ”โ•‡โ”โ”โ”โ”โ”โ”โ”โ•‡โ”โ”โ”โ”โ”โ”โ”โ”โ”ฉ +โ”‚ dot_norm โ”‚ 0.174 โ”‚ 0.040 โ”‚ 282 โ”‚ 49 โ”‚ +โ”‚ isect โ”‚ 0.149 โ”‚ 1.000 โ”‚ 302 โ”‚ 45 โ”‚ +โ”‚ angle โ”‚ 0.571 โ”‚ 0.090 โ”‚ 42 โ”‚ 24 โ”‚ +โ”‚ helical_shift โ”‚ 1.000 โ”‚ 1.000 โ”‚ 47 โ”‚ 47 โ”‚ +โ”‚ axistol โ”‚ 0.412 โ”‚ 0.100 โ”‚ 17 โ”‚ 7 โ”‚ +โ”‚ nfold โ”‚ 1.000 โ”‚ 0.200 โ”‚ 5 โ”‚ 5 โ”‚ +โ”‚ cageang โ”‚ 0.500 โ”‚ 0.100 โ”‚ 2 โ”‚ 1 โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +""".strip()) + + +def test_summary_numpy(): + np = pytest.importorskip('numpy') + assert evn.summary(np.arange(3)) == '[0 1 2]' + assert evn.summary(np.arange(300)) == 'ndarray[300]' + + +if __name__ == '__main__': + main() diff --git a/lib/evn/evn/tests/testing/test_pytest.py b/lib/evn/evn/tests/testing/test_pytest.py new file mode 100644 index 00000000..73565249 --- /dev/null +++ b/lib/evn/evn/tests/testing/test_pytest.py @@ -0,0 +1,118 @@ +import pytest + +import evn +from evn.testing.pytest import * + +config_test = evn.Bunch( + re_only=[ + # + ], + re_exclude=[ + # + ], +) + + +def main(): + evn.testing.quicktest( + namespace=globals(), + config=config_test, + verbose=1, + check_xfail=False, + ) + + +# ==== TESTS FOR has_pytest_mark ==== + + +@pytest.mark.ci +def test_with_ci_mark(): + pass + + +def test_has_pytest_mark_positive(): + assert has_pytest_mark(test_with_ci_mark, 'ci') is True + + +def test_has_pytest_mark_negative(): + assert has_pytest_mark(test_with_ci_mark, 'skip') is False + + +def test_has_pytest_mark_no_marks(): + + def test_func(): + pass + + assert has_pytest_mark(test_func, 'custom') is False + + +@pytest.mark.skip +def test_with_skip(): + pass + + +def test_no_pytest_skip_false(): + assert no_pytest_skip(test_with_skip) is False + + +def test_no_pytest_skip_true(): + + def test_func(): + pass + + assert no_pytest_skip(test_func) is True + + +# ==== TESTS FOR get_pytest_params ==== + + +@pytest.mark.parametrize('x, y', [(1, 2), (3, 4)]) +def test_with_parametrize(x, y): + assert y - x == 1 + + +def test_get_pytest_params(): + args = get_pytest_params(test_with_parametrize) + assert args == (['x', 'y'], [(1, 2), (3, 4)]) + + +def test_get_pytest_params_none(): + + def test_func(): + pass + + assert get_pytest_params(test_func) is None + + +# ==== USEFUL TEST UTILITIES ==== + + +def is_skipped(func): + """Utility function to check if a test is marked as skip.""" + return has_pytest_mark(func, 'skip') + + +def is_parametrized(func): + """Utility function to check if a test is marked as parametrize.""" + return get_pytest_params(func) is not None + + +# === TEST UTILITIES === + + +def test_is_skipped(): + assert is_skipped(test_with_skip) is True + assert is_skipped(test_with_ci_mark) is False + + +def test_is_parametrized(): + assert is_parametrized(test_with_parametrize) is True + + def test_func(): + pass + + assert is_parametrized(test_func) is False + + +if __name__ == '__main__': + main() diff --git a/lib/evn/evn/tests/testing/test_quicktest.py b/lib/evn/evn/tests/testing/test_quicktest.py new file mode 100644 index 00000000..7beee47c --- /dev/null +++ b/lib/evn/evn/tests/testing/test_quicktest.py @@ -0,0 +1,189 @@ +import types +import builtins +import pytest +import evn +import evn.testing as et + +def main(): + # test_TestConfig_defaults() + # test_TestConfig_detect_fixtures() + # test_TestResult_runtime_and_items() + # test__test_func_ok() + # test__test_class_ok() + # test_collect_and_run_tests() + # test_main_and_print_result() + # test_dryrun_mode() + # test_nocapture_mode() + # test_xfail_detection() + # test_skip_detection() + # test_setUp_tearDown_called() + # test_parametrized_test_handling() + + evn.testing.quicktest(globals()) + +def test_TestConfig_defaults(): + cfg = et.TestConfig() + assert cfg.nofail is False + assert cfg.verbose is False + assert cfg.checkxfail is False + assert cfg.timed is True + assert isinstance(cfg.fixtures, dict) + assert callable(cfg.setup) + assert callable(cfg.funcsetup) + assert callable(cfg.context) + assert cfg.use_test_classes is True + assert cfg.dryrun is False + +def test_TestConfig_detect_fixtures(): + + def fake_fixture(): + pass + + fake_fixture._pytestfixturefunction = True + fake_fixture.__wrapped__ = lambda: 'wrapped' + ns = {'fake_fixture': fake_fixture} + cfg = et.TestConfig() + cfg.detect_fixtures(ns) + assert 'fake_fixture' in cfg.fixtures + assert cfg.fixtures['fake_fixture'] == 'wrapped' + +def test_TestResult_runtime_and_items(): + r = et.TestResult(passed=['t1'], + failed=['t2'], + errored=[], + xfailed=[], + skipexcn=[], + _runtime={ + 't1': 1.23, + 't2': 0.45 + }) + assert r.runtime('t1') == 1.23 + items = dict(r.items()) + assert 'passed' in items and 'failed' in items + +def test__test_func_ok(): + + def test_abc(): + pass + + assert et.test_func_ok('test_abc', test_abc) + +def test__test_class_ok(): + + class TestThing: + pass + + assert et.test_class_ok('TestThing', TestThing) + +def test_collect_and_run_tests(): + state = {} + + def test_foo(tmpdir=None): + assert tmpdir is not None + state['ran'] = True + + ns = {'test_foo': test_foo} + cfg = et.TestConfig() + funcs, teardown = et.collect_tests(ns, cfg) + assert len(funcs) == 1 + result = et.run_tests(funcs, cfg, {}) + assert 'test_foo' in result.passed + assert state.get('ran') is True + +def test_main_and_print_result(): + with et.CapSys() as capsys: + ran = {} + + def test_foo(): + ran['yes'] = True + + ns = {'test_foo': test_foo, '__file__': 'dummy.py'} + res = et.quicktest(ns, verbose=True, check_xfail=True) + captured = capsys.readouterr() + print(captured.out) + assert 'PASSED' in captured.out + assert 'test_foo' in captured.out + assert ran['yes'] + +def test_dryrun_mode(): + flag = {'called': False} + + def test_func(): + flag['called'] = True + + cfg = et.TestConfig(dryrun=True) + funcs, _ = et.collect_tests({'test_func': test_func}, cfg) + result = et.run_tests(funcs, cfg, {}) + assert 'test_func' not in result.passed + assert not flag['called'] + +def test_nocapture_mode(): + with et.CapSys() as capsys: + + def test_func(): + print("visible output") + + cfg = et.TestConfig(nocapture=['test_func']) + funcs, _ = et.collect_tests({'test_func': test_func}, cfg) + result = et.run_tests(funcs, cfg, {}) + out = capsys.readouterr().out + assert "visible output" in out + +def test_xfail_detection(): + + @pytest.mark.xfail + def test_func(): + assert False + + cfg = et.TestConfig(checkxfail=False) + funcs, _ = et.collect_tests({'test_func': test_func}, cfg) + result = et.run_tests(funcs, cfg, {}) + print(result) + assert 'test_func' in result.xfailed + +def test_skip_detection(): + + def test_func(): + pytest.skip("skip") + + cfg = et.TestConfig() + funcs, _ = et.collect_tests({'test_func': test_func}, cfg) + result = et.run_tests(funcs, cfg, {}) + assert 'test_func' in result.skipexcn + +def test_setUp_tearDown_called(): + trace = [] + + class TestThing: + + def setUp(self): + trace.append('setup') + + def tearDown(self): + trace.append('teardown') + + def test_run(self): + trace.append('run') + + ns = {'TestThing': TestThing} + cfg = et.TestConfig() + funcs, teardown = et.collect_tests(ns, cfg) + result = et.run_tests(funcs, cfg, {}) + for fn in teardown: + fn() + assert trace == ['setup', 'run', 'teardown'] + assert 'TestThing.test_run' in result.passed + +def test_parametrized_test_handling(): + + @pytest.mark.parametrize('x', [(1, ), (5, ), (9, )]) + def test_func(x): + assert x < 10 + + cfg = et.TestConfig() + funcs, _ = et.collect_tests({'test_func': test_func}, cfg) + result = et.run_tests(funcs, cfg, {}) + assert result.passed.count('test_func') == 3 + +if __name__ == '__main__': + main() diff --git a/lib/evn/evn/tests/tool/test_filter_python_output.py b/lib/evn/evn/tests/tool/test_filter_python_output.py new file mode 100644 index 00000000..d2207999 --- /dev/null +++ b/lib/evn/evn/tests/tool/test_filter_python_output.py @@ -0,0 +1,303 @@ +import re +import difflib +import pytest +import evn + +def main(): + evn.testing.quicktest(globals(), verbose=True) + +def helper_test_filter_python_output(text, ref, preset): + result = evn.tool.filter_python_output(text, preset=preset, minlines=0) + if result != ref: + diff = difflib.ndiff(result.splitlines(), ref.splitlines()) + # print('\nDIFF:', flush=True) + # print('\n'.join(f'NDIFF {d}' for d in diff), flush=True) + print('--------------------------------------------------------------------') + print(result) + print('--------------------------------------------------------------------') + assert len(result.splitlines()) == len(ref.splitlines()) + assert 0, 'filter mismatch' + + +def test_transform_fileref_to_python_format(): + from evn.tool.filter_python_output import transform_fileref_to_python_format as tf + new = tf('evn/_prelude/chrono.py:63: TypeError') + assert new == ' File "evn/_prelude/chrono.py", line 63, ...' + new2 = tf('/home/sheffler/evn/evn/cli/__init__.py:32: DocTestFailure') + assert new2 == ' File "/home/sheffler/evn/evn/cli/__init__.py", line 32, ...' + +# @pytest.mark.xfail +def test_filter_python_output_whitespace(): + result = evn.tool.filter_python_output(' \n' * 22, preset='unittest', minlines=0) + print(result.count('\n')) + assert result.count('\n') == 1 + +def test_filter_python_output_mid(): + helper_test_filter_python_output(midtext, midfiltered, preset='unittest') + +def test_filter_python_output_small(): + helper_test_filter_python_output(smalltext, smallfiltered, preset='unittest') + +def test_filter_python_output_error(): + helper_test_filter_python_output(errortext, errorfiltered, preset='unittest') + +def test_analyze_python_errors_log(): + log = """Traceback (most recent call last): + File "example.py", line 10, in + 1/0 +ZeroDivisionError: division by zero""" + result = evn.tool.analyze_python_errors_log(log) + # print(result) + assert 'Unique Stack Traces Report (1 unique traces):' in result + assert 'ZeroDivisionError: division by zero' in result + +def test_create_errors_log_report(): + trace_map = { + ('1/0', 'division by zero'): + """Traceback (most recent call last): + File "example.py", line 10, in + 1/0 +ZeroDivisionError: division by zero""" + } + + report = evn.tool.create_errors_log_report(trace_map) + assert 'Unique Stack Traces Report (1 unique traces):' in report + assert 'ZeroDivisionError: division by zero' in report + +def test_multiple_unique_traces(): + log = """Traceback (most recent call last): + File "example.py", line 10, in + 1/0 +ZeroDivisionError: division by zero + +Traceback (most recent call last): + File "example.py", line 20, in + x = int("abc") +ValueError: invalid literal for int()""" + + result = evn.tool.analyze_python_errors_log(log) + assert 'Unique Stack Traces Report (2 unique traces):' in result + assert 'ZeroDivisionError: division by zero' in result + assert 'ValueError: invalid literal for int()' in result + +def test_similar_traces_are_grouped(): + log = """Traceback (most recent call last): + File "example.py", line 13, in + 1/0 +ZeroDivisionError: division by zero + +Traceback (most recent call last): + File "example.py", line 13, in + 1/0 +ZeroDivisionError: division by zero""" + + result = evn.tool.analyze_python_errors_log(log) + assert 'Unique Stack Traces Report (1 unique traces):' in result + assert 'ZeroDivisionError: division by zero' in result + assert result.count('ZeroDivisionError') == 1 + +def test_different_lines_are_not_grouped(): + log = """Traceback (most recent call last): + File "example.py", line 10, in + 1/0 +ZeroDivisionError: division by zero + +Traceback (most recent call last): + File "example.py", line 15, in + 1/0 +ZeroDivisionError: division by zero""" + + result = evn.tool.analyze_python_errors_log(log) + assert 'Unique Stack Traces Report (2 unique traces):' in result + assert 'ZeroDivisionError: division by zero' in result + assert result.count('ZeroDivisionError') == 2 + +# ######################### test data ####################### +errortext = """quicktest /home/sheffler/rfd/lib/TEST/TEST/tests/dev/code/test_filter_python_output.py: +Traceback (most recent call last): + File "/home/sheffler/rfd/TEST/tests/quicktest.py", line 40, in quicktest + _quicktest_run_test_function(name, func, result, nofail, fixtures, funcsetup, kw) + File "/home/sheffler/rfd/TEST/tests/quicktest.py", line 80, in _quicktest_run_test_function + TEST.dev.call_with_args_from(fixtures, func, **kw) + File "/home/sheffler/rfd/TEST.dev.decorators.py", line 33, in call_with_args_from + return func(**args) + ^^^^^^^^^^^^ + File "/home/sheffler/rfd/lib/TEST/TEST/tests/dev/code/test_filter_python_output.py", line 19, in test_filter_python_output_small + helper_test_filter_python_output(smalltext, smallfiltered, preset='boilerplate') + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +NameError: name 'helper_test_filter_python_output' is not defined +Times(name=Timer, order=longest, summary=sum): + quicktest * 0.39438 +============== run_tests_for_file.py done, time 0.611 ============== +""" +errorfiltered = """quicktest /home/sheffler/rfd/lib/TEST/TEST/tests/dev/code/test_filter_python_output.py: +Traceback (most recent call last): + quicktest -> _quicktest_run_test_function -> call_with_args_from -> + File "/home/sheffler/rfd/lib/TEST/TEST/tests/dev/code/test_filter_python_output.py", line 19, in test_filter_python_output_small + helper_test_filter_python_output(smalltext, smallfiltered, preset='boilerplate') + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +NameError: name 'helper_test_filter_python_output' is not defined +Times(name=Timer, order=longest, summary=sum): + quicktest * 0.39438 +============== run_tests_for_file.py done, time 0.611 ============== +""" + +midtext = """quicktest /home/sheffler/rfd/lib/TEST/TEST/tests/sym/test_sym_detect.py: +==============test_sym_detect_frames_noised_T=============== +Traceback (most recent call last): + File "/home/sheffler/rfd/lib/TEST/TEST/tests/sym/test_sym_detect.py", line 185, in + main() + File "/home/sheffler/rfd/lib/TEST/TEST/tests/sym/test_sym_detect.py", line 25, in main + TEST.tests.quicktest(namespace=globals(), config=config_test, verbose=1) + File "/home/sheffler/rfd/lib/TEST/TEST/tests/quicktest.py", line 40, in quicktest + _quicktest_run_test_function(name, func, result, nofail, fixtures, funcsetup, kw) + File "/home/sheffler/rfd/lib/TEST/TEST/tests/quicktest.py", line 93, in _quicktest_run_test_function + elif error: raise error + ^^^^^^^^^^^ + File "/home/sheffler/rfd/lib/TEST/TEST/tests/quicktest.py", line 80, in _quicktest_run_test_function + TEST.dev.call_with_args_from(fixtures, func, **kw) + File "/home/sheffler/rfd/lib/TEST/TEST.dev.decorators.py", line 33, in call_with_args_from + return func(**args) + ^^^^^^^^^^^^ + File "/home/sheffler/rfd/lib/TEST/TEST/tests/sym/test_sym_detect.py", line 76, in func_noised + sinfo = helper_test_frames(nframes, symid, ideal=False) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/home/sheffler/rfd/lib/TEST/TEST/tests/sym/test_sym_detect.py", line 30, in helper_test_frames + TEST.icv(tol) + File "/home/sheffler/sw/MambaForge/envs/rfdsym312/lib/python3.12/site-packages/icecream/icecream.py", line 208, in __call__ + out = self._format(callFrame, *args) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/home/sheffler/sw/MambaForge/envs/rfdsym312/lib/python3.12/site-packages/icecream/icecream.py", line 242, in _format + out = self._formatArgs( + ^^^^^^^^^^^^^^^^^ + File "/home/sheffler/sw/MambaForge/envs/rfdsym312/lib/python3.12/site-packages/icecream/icecream.py", line 255, in _formatArgs + out = self._constructArgumentOutput(prefix, context, pairs) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/home/sheffler/sw/MambaForge/envs/rfdsym312/lib/python3.12/site-packages/icecream/icecream.py", line 262, in _constructArgumentOutput + pairs = [(arg, self.argToStringFunction(val)) for arg, val in pairs] + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/home/sheffler/sw/MambaForge/envs/rfdsym312/lib/python3.12/functools.py", line 909, in wrapper + return dispatch(args[0].__class__)(*args, **kw) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/home/sheffler/sw/MambaForge/envs/rfdsym312/lib/python3.12/site-packages/icecream/icecream.py", line 183, in argumentToString + s = DEFAULT_ARG_TO_STRING_FUNCTION(obj) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/home/sheffler/sw/MambaForge/envs/rfdsym312/lib/python3.12/pprint.py", line 62, in pformat + underscore_numbers=underscore_numbers).pformat(object) + ^^^^^^^^^^^^^^^ + File "/home/sheffler/sw/MambaForge/envs/rfdsym312/lib/python3.12/pprint.py", line 161, in pformat + self._format(object, sio, 0, 0, {}, 0) + File "/home/sheffler/sw/MambaForge/envs/rfdsym312/lib/python3.12/pprint.py", line 178, in _format + rep = self._repr(object, context, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/home/sheffler/sw/MambaForge/envs/rfdsym312/lib/python3.12/pprint.py", line 458, in _repr + repr, readable, recursive = self.format(object, context.copy(), + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/home/sheffler/sw/MambaForge/envs/rfdsym312/lib/python3.12/pprint.py", line 471, in format + return self._safe_repr(object, context, maxlevels, level) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/home/sheffler/sw/MambaForge/envs/rfdsym312/lib/python3.12/pprint.py", line 632, in _safe_repr + rep = repr(object) + ^^^^^^^^^^^^ + File "/home/sheffler/rfd/lib/TEST/TEST/dev/tolerances.py", line 52, in __repr__ + TEST.dev.print_table(self.kw) + File "/home/sheffler/rfd/lib/TEST/TEST/dev/format.py", line 21, in print_table + table = make_table(thing, **kw) + ^^^^^^^^^^^^^^^^^^^^^^^ + File "/home/sheffler/rfd/lib/TEST/TEST/dev/format.py", line 14, in make_table + if isinstance(thing, dict): return make_table_dict(thing, **kw) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/home/sheffler/rfd/lib/TEST/TEST/dev/format.py", line 37, in make_table_dict + assert isinstance(mapping, Mapping) and mapping + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +AssertionError +Times(name=Timer, order=longest, summary=sum): + test_sym_detect.py:func_noised 0.43416 + quicktest * 0.39847 + sym.py:frames 0.03893 +Traceback (most recent call last): + File "/home/sheffler/rfd/lib/TEST/TEST/tests/dev/code/test_filter_python_output.py", line 92, in + main() + File "/home/sheffler/rfd/lib/TEST/TEST/tests/dev/code/test_filter_python_output.py", line 6, in main + TEST.tests.quicktest(namespace=globals()) + File "/home/sheffler/rfd/TEST/tests/quicktest.py", line 40, in quicktest + _quicktest_run_test_function(name, func, result, nofail, fixtures, funcsetup, kw) + File "/home/sheffler/rfd/TEST/tests/quicktest.py", line 93, in _quicktest_run_test_function + elif error: raise error + ^^^^^^^^^^^ + File "/home/sheffler/rfd/TEST/tests/quicktest.py", line 80, in _quicktest_run_test_function + TEST.dev.call_with_args_from(fixtures, func, **kw) + File "/home/sheffler/rfd/TEST.dev.decorators.py", line 33, in call_with_args_from + return func(**args) + ^^^^^^^^^^^^ + File "/home/sheffler/rfd/lib/TEST/TEST/tests/dev/code/test_filter_python_output.py", line 13, in test_filter_python_output + assert 0 + ^ +AssertionError + +""" + +midfiltered = """quicktest /home/sheffler/rfd/lib/TEST/TEST/tests/sym/test_sym_detect.py: +==============test_sym_detect_frames_noised_T=============== +Traceback (most recent call last): + test_sym_detect.py -> main -> quicktest -> _quicktest_run_test_function -> _quicktest_run_test_function -> call_with_args_from -> + File "/home/sheffler/rfd/lib/TEST/TEST/tests/sym/test_sym_detect.py", line 76, in func_noised + sinfo = helper_test_frames(nframes, symid, ideal=False) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/home/sheffler/rfd/lib/TEST/TEST/tests/sym/test_sym_detect.py", line 30, in helper_test_frames + TEST.icv(tol) + __call__ -> _format -> _formatArgs -> _constructArgumentOutput -> wrapper -> argumentToString -> pformat -> pformat -> _format -> _repr -> format -> _safe_repr -> + File "/home/sheffler/rfd/lib/TEST/TEST/dev/tolerances.py", line 52, in __repr__ + TEST.dev.print_table(self.kw) + print_table -> make_table -> + File "/home/sheffler/rfd/lib/TEST/TEST/dev/format.py", line 37, in make_table_dict + assert isinstance(mapping, Mapping) and mapping + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +AssertionError +Times(name=Timer, order=longest, summary=sum): + test_sym_detect.py:func_noised 0.43416 + quicktest * 0.39847 + sym.py:frames 0.03893 +Traceback (most recent call last): + test_filter_python_output.py -> main -> quicktest -> _quicktest_run_test_function -> _quicktest_run_test_function -> call_with_args_from -> + File "/home/sheffler/rfd/lib/TEST/TEST/tests/dev/code/test_filter_python_output.py", line 13, in test_filter_python_output + assert 0 + ^ +AssertionError +""" + +smalltext = """extra text at the start +Traceback (most recent call last): + File "/home/sheffler/rfd/lib/TEST/TEST/tests/dev/code/test_filter_python_output.py", line 92, in + main() + File "/home/sheffler/rfd/lib/TEST/TEST/tests/dev/code/test_filter_python_output.py", line 6, in main + TEST.tests.quicktest(namespace=globals()) + File "/home/sheffler/rfd/TEST/tests/quicktest.py", line 40, in quicktest + _quicktest_run_test_function(name, func, result, nofail, fixtures, funcsetup, kw) + File "/home/sheffler/rfd/TEST/tests/quicktest.py", line 93, in _quicktest_run_test_function + elif error: raise error + ^^^^^^^^^^^ + File "/home/sheffler/rfd/TEST/tests/quicktest.py", line 80, in _quicktest_run_test_function + TEST.dev.call_with_args_from(fixtures, func, **kw) + File "/home/sheffler/rfd/TEST.dev.decorators.py", line 33, in call_with_args_from + return func(**args) + ^^^^^^^^^^^^ + File "/home/sheffler/rfd/lib/TEST/TEST/tests/dev/code/test_filter_python_output.py", line 13, in test_filter_python_output + assert 0 + ^ +AssertionError +extra text at the end +""" + +smallfiltered = """extra text at the start +Traceback (most recent call last): + test_filter_python_output.py -> main -> quicktest -> _quicktest_run_test_function -> _quicktest_run_test_function -> call_with_args_from -> + File "/home/sheffler/rfd/lib/TEST/TEST/tests/dev/code/test_filter_python_output.py", line 13, in test_filter_python_output + assert 0 + ^ +AssertionError +extra text at the end +""" + +if __name__ == '__main__': + main() diff --git a/lib/evn/evn/tests/tree/test_tree_diff.py b/lib/evn/evn/tests/tree/test_tree_diff.py new file mode 100644 index 00000000..3cc3c4ae --- /dev/null +++ b/lib/evn/evn/tests/tree/test_tree_diff.py @@ -0,0 +1,176 @@ +from evn.tree.tree_diff import ( + TreeDiffer, + SummaryStrategy, + IgnoreKeyStrategy, + IgnoreUnderscoreKeysStrategy, + FloatToleranceStrategy, + ShallowOnPathStrategy, + SummaryOnPathStrategy, +) + + +def test_scalar_difference(): + cfg1 = {'a': 1} + cfg2 = {'a': 2} + result = TreeDiffer().diff(cfg1, cfg2) + assert result == {'a': (1, 2)} + + +def test_nested_difference(): + cfg1 = {'a': {'b': {'c': 1}}} + cfg2 = {'a': {'b': {'c': 2}}} + result = TreeDiffer().diff(cfg1, cfg2) + assert result == {'a': {'b': {'c': (1, 2)}}} + + +def test_missing_keys(): + cfg1 = {'a': 1, 'b': 2} + cfg2 = {'a': 1, 'c': 3} + result = TreeDiffer(summarize_subtrees=True).diff(cfg1, cfg2) + assert isinstance(result, tuple) + assert "'b': 2" in result[0] + assert "'c': 3" in result[1] + + +def test_list_flat_and_nested(): + cfg1 = {'x': [1, {'a': 'old'}, 3]} + cfg2 = {'x': [1, {'a': 'new'}, 4]} + result = TreeDiffer().diff(cfg1, cfg2) + assert result == { + 'x': { + '_flat': ([3], [4]), + '_nested': { + 1: { + 'a': ('old', 'new') + } + } + } + } + + +def test_flattened_output(): + cfg1 = {'a': {'b': {'c': 1}}, 'x': [1, 2]} + cfg2 = {'a': {'b': {'c': 2}}, 'x': [1, 3]} + differ = TreeDiffer(flatpaths=True) + result = differ.diff(cfg1, cfg2) + assert 'a.b.c' in result + assert 'x:_flat' in result + + +def test_summary_strategy(): + cfg1 = {'a': {'x': 1, 'y': 2}} + cfg2 = {'a': {'x': 3, 'z': 4}} + differ = TreeDiffer(strategy=SummaryStrategy(), summarize_subtrees=True) + result = differ.diff(cfg1, cfg2) + assert isinstance(result, tuple) + assert isinstance(result[0], str) + assert isinstance(result[1], str) + + +def test_ignore_keys(): + cfg1 = {'a': 1, '_meta': 123} + cfg2 = {'a': 2, '_meta': 456} + differ = TreeDiffer(strategy=IgnoreKeyStrategy()) + result = differ.diff(cfg1, cfg2) + assert result == {'a': (1, 2)} + + +def test_list_dicts_zip_match(): + cfg1 = {'lst': [{'x': 1}, {'y': 2}]} + cfg2 = {'lst': [{'x': 1}, {'y': 3}]} + result = TreeDiffer().diff(cfg1, cfg2) + assert result == {'lst': {'_nested': {1: {'y': (2, 3)}}}} + + +def test_cycle_detection(): + a, b = {}, {} + a['self'] = a + b['self'] = b + differ = TreeDiffer() + result = differ.diff(a, b) + assert result is None + + +def test_max_depth(): + cfg1 = {'a': {'b': {'c': 1}}} + cfg2 = {'a': {'b': {'c': 2}}} + result = TreeDiffer(max_depth=2).diff(cfg1, cfg2) + assert result is None + cfg1 = {'a': {'b': {'d': 1}}} + cfg2 = {'a': {'b': {'c': 2}}} + result = TreeDiffer(max_depth=2, flatpaths=True).diff(cfg1, cfg2) + assert result == {'a.b': ({'d': 1}, {'c': 2})} + + +def test_custom_path_fmt(): + cfg1 = {'a': {'b': 1}} + cfg2 = {'a': {'b': 2}} + differ = TreeDiffer(flatpaths=True, path_fmt=lambda p: '/'.join(p)) + result = differ.diff(cfg1, cfg2) + assert result == {'a/b': (1, 2)} + + +# --- Strategy Subclasses for Testing --- +# --- Feature Combination Tests --- + + +def test_ignore_keys_and_nested_diff(): + cfg1 = {'a': 1, '_meta': 999, 'b': {'x': 1}} + cfg2 = {'a': 2, '_meta': 1000, 'b': {'x': 1}} + differ = TreeDiffer(strategy=IgnoreUnderscoreKeysStrategy()) + result = differ.diff(cfg1, cfg2) + assert result == {'a': (1, 2)} + + +def test_float_tolerance_in_list_flat_diff(): + cfg1 = {'nums': [1.0, 2.0000001]} + cfg2 = {'nums': [1.0, 2.0]} + differ = TreeDiffer(strategy=FloatToleranceStrategy()) + result = differ.diff(cfg1, cfg2) + assert result is None + + +def test_shallow_on_path_strategy(): + cfg1 = {'meta': {'version': 1}, 'real': {'value': 10}} + cfg2 = {'meta': {'version': 2}, 'real': {'value': 20}} + differ = TreeDiffer(strategy=ShallowOnPathStrategy()) + result = differ.diff(cfg1, cfg2) + assert result == { + 'meta': ({ + 'version': 1 + }, { + 'version': 2 + }), + 'real': { + 'value': (10, 20) + } + } + + +def test_summary_on_path_strategy(): + cfg1 = {'data': {'big': list(range(1000))}, 'other': 1} + cfg2 = {'data': {'big': list(range(999)) + [9999]}, 'other': 2} + differ = TreeDiffer(strategy=SummaryOnPathStrategy(), + summarize_subtrees=True) + result = TreeDiffer(strategy=SummaryOnPathStrategy(), + summarize_subtrees=True).diff(cfg1, cfg2) + assert isinstance(result['data'][0], str) + assert '2, 13, 14, 15, 16, 17' in result['data'][1] + assert result['other'] == (1, 2) + + +def test_flat_output_with_summary_and_ignore(): + + class ComboStrategy(IgnoreUnderscoreKeysStrategy, SummaryOnPathStrategy): + pass + + cfg1 = {'info': {'data': [1, 2, 3]}, '_meta': 'skip'} + cfg2 = {'info': {'data': [1, 2, 999]}, '_meta': 'also skip'} + + differ = TreeDiffer(strategy=ComboStrategy(), + flatpaths=True, + summarize_subtrees=True) + result = differ.diff(cfg1, cfg2) + + assert 'info.data:_diff' in result or 'info.data' in result + assert '_meta' not in result diff --git a/lib/evn/evn/tests/tree/test_tree_format.py b/lib/evn/evn/tests/tree/test_tree_format.py new file mode 100644 index 00000000..fda67d64 --- /dev/null +++ b/lib/evn/evn/tests/tree/test_tree_format.py @@ -0,0 +1,64 @@ +cfg1 = { + 'a': 1, + 'b': [1, 2], + 'c': { + 'x': 10, + 'y': 20 + }, + 'd': 'hello', + 'e': { + 'shared': 5, + 'removed_subtree': { + 'k': 9 + } + }, +} + +cfg2 = { + 'a': 1, + 'b': [2, 3], + 'c': { + 'x': 10, + 'z': 30 + }, + 'd': 123, + 'e': { + 'shared': 6, + 'added_subtree': { + 'new': True + } + }, +} + +diff = dict( + b=([1], [3]), + c=({ + 'y': 20 + }, { + 'z': 30 + }), + d=('hello', 123), + e=({ + 'removed_subtree': { + 'k': 9 + } + }, { + 'added_subtree': { + 'new': True + } + }), +) + +e = { + '_diff': ({ + 'removed_subtree': { + 'k': 9 + } + }, { + 'added_subtree': { + 'new': True + } + }), + 'common_subtree1': '', + 'common_subtree2': 'even more diffs', +} diff --git a/lib/evn/evn/tool/__init__.py b/lib/evn/evn/tool/__init__.py new file mode 100644 index 00000000..1d92bb12 --- /dev/null +++ b/lib/evn/evn/tool/__init__.py @@ -0,0 +1,3 @@ +from evn.tool.filter_python_output import * +from evn.tool.run_tests_for_file import * +from evn.tool.evn_cli_app import EvnCLI diff --git a/lib/evn/evn/tool/bigstuff.py b/lib/evn/evn/tool/bigstuff.py new file mode 100644 index 00000000..392e4a62 --- /dev/null +++ b/lib/evn/evn/tool/bigstuff.py @@ -0,0 +1,189 @@ +import os +import fnmatch +import json +from pathlib import Path + +from typing import Union +from functools import cached_property + +import click +from rich.console import Console +from rich.tree import Tree as RichTree +from rich.table import Table as RichTable + +import evn + +class FilePath(Path): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.path = self + + @cached_property + def size(self) -> int: + # try: + return self.stat().st_size + + # except OSError: + # return 0 + +class FileTree(dict): + + def __init__(self, path: Path): + super().__init__() + self.path = path + + @cached_property + def size(self) -> int: + return sum(v.size for v in self.values()) + + def to_dict(self): + return { + "path": str(self.path), + "size": self.size, + "children": { + k: v.to_dict() if isinstance(v, FileTree) else { + "path": str(v), + "size": v.size + } + for k, v in self.items() + } + } + +def scan_dir(path: Path, opaque: list[str], follow_symlinks=False) -> Union[FileTree, FilePath]: + name = path.name + if any(fnmatch.fnmatch(name, pat) for pat in opaque): + return FilePath(path) + + if not path.is_dir(): + return FilePath(path) + + tree = FileTree(path) + try: + with os.scandir(path) as it: + for entry in it: + try: + p = Path(entry.path) + if entry.is_symlink() and not follow_symlinks: + continue + node = scan_dir(p, opaque, follow_symlinks) + tree[entry.name] = node + except Exception: + continue + except Exception: + return FilePath(path) + return tree + +def find_big(tree: Union[FileTree, FilePath], threshold: int, + max_children: int) -> list[Union[FileTree, FilePath]]: + if tree.size < threshold: return [] + if isinstance(tree, FilePath): return [tree] + + children = sorted(tree.items(), key=lambda x: x[1].size, reverse=True) + if sum(c[1].size for c in children[max_children:]) < threshold: + return [v for _, v in children[:max_children]] + + big: list[Union[FileTree, FilePath]] = [tree] + for _, child in children: + big.extend(find_big(child, threshold, max_children)) + return big + +def print_output(results, format): + if format == 'json': + print( + json.dumps([ + r.to_dict() if isinstance(r, FileTree) else { + "path": str(r), + "size": r.size + } for r in results + ], + indent=2)) + elif format == 'flat': + for r in results: + print(f"{r.path} ({r.size} bytes)") + elif format == 'tree': + console = Console() + for r in results: + t = RichTree(f"{r.path} ({r.size} bytes)") + if isinstance(r, FileTree): + + def add(tree, node): + for k, v in node.items(): + label = f"{k} ({v.size} bytes)" + if isinstance(v, FileTree): + subtree = tree.add(label) + add(subtree, v) + else: + tree.add(label) + + add(t, r) + console.print(t) + elif format == 'table': + table = RichTable(title="Big Stuff") + table.add_column("Path") + table.add_column("Size", justify="right") + for r in results: + table.add_row(str(r.path), f"{r.size} bytes") + Console().print(table) + +class SizeTypeHandler(evn.cli.ClickTypeHandler): + __supported_types__ = {str: evn.cli.MetadataPolicy.REQUIRED} + + @classmethod + def typehint_to_click_paramtype(cls, typ, meta=None): + if typ is str and meta == ('size', ): return SizeParamType() + raise evn.cli.HandlerNotFoundError(f"Unsupported type: {typ} with meta: {meta}") + +def parse_size(size_str: str) -> int: + size_str = size_str.strip().upper() + units = {"K": 1024, "M": 1024**2, "G": 1024**3, "T": 1024**4} + if size_str[-1] in units: + return int(float(size_str[:-1]) * units[size_str[-1]]) + return int(size_str) + +class SizeParamType: + __name__ = "size" + + # def convert(self, value, param, ctx): + def __call__(self, value): + try: + return parse_size(value) + except Exception as e: + raise click.BadParameter(f"Invalid size value: {value}") from e + +class BigStuff(evn.cli.CLI): + __test__ = False + __type_handlers__ = SizeTypeHandler + + def _callback(self, debug: bool = False, follow_symlinks: bool = False): + """ + Shared options for all commands. + + :param debug: Enable debug output. + :param follow_symlinks: Follow symlinks instead of skipping them. + """ + self._debug = debug + self._follow_symlinks = follow_symlinks + + def scan(self, + root: str, + threshold: evn.annotype(str, 'size') = '1m', + max_children: int = 3, + opaque: list[str] = ['.git', '__pycache__'], + output: str = 'flat'): + """ + Scan a directory and report large files or folders. + + :param root: Path to scan. + :param threshold: Minimum size to consider "big", e.g. '10M', '2G'. + :param max_children: Max children to report instead of entire dir. + :param opaque: Patterns (fnmatch) to treat as opaque directories. + :param output: Output format: json, tree, table, flat. + """ + root_path = Path(root) + full_tree = scan_dir(root_path, opaque, self._follow_symlinks) + big = find_big(full_tree, threshold, max_children) + print_output(big, output) + +if __name__ == '__main__': + BigStuff._run() diff --git a/lib/evn/evn/tool/evn_cli_app.py b/lib/evn/evn/tool/evn_cli_app.py new file mode 100644 index 00000000..ee6afbc7 --- /dev/null +++ b/lib/evn/evn/tool/evn_cli_app.py @@ -0,0 +1,243 @@ +import sys +from evn import CLI +from pathlib import Path + +# === Root CLI scaffold using inheritance-based hierarchy === +class EvnCLI(CLI): + """ + Main entry point for the EVN developer workflow CLI. + """ + + def version(self, verbose: bool = False): + """ + Show version info. + + :param verbose: If True, include environment and git info. + :type verbose: bool + """ + print(f'[evn.version] Version info (verbose={verbose})') + + def name_with_under_scores(self): + ... + + def _private_meth(self): + ... + + @classmethod + def _private_func(cls): + ... + + @staticmethod + def _static_func(): + ... + + @classmethod + def _callback(cls, foo='bar'): + """ + Configure global CLI behavior (used for testing _callback hooks). + """ + return dict(help_option_names=['-h', '--help']) + +class dev(EvnCLI): + "Development: edit, format, test a single file or unit." + + pass + +class format(dev): + + def stream(self, tab_width: int = 4, language: str = 'python'): + """ + Format input stream. + + :param tab_width: Tab width to use. + :type tab_width: int + :param language: Programming language (e.g. 'python'). + :type language: str + """ + print(f'[dev.format.stream] Format stream (tab_width={tab_width}, language={language})') + + def smart(self, mode: str = 'git'): + """ + Format changed project files. + + :param mode: Change detection mode ('md5', 'git'). + :type mode: str + """ + print(f'[dev.format.smart] Format changed files using mode={mode}') + +class test(dev): + + def file(self, fail_fast: bool = False): + """ + Run pytest or doctest. + + :param fail_fast: Stop after first failure. + :type fail_fast: bool + """ + print(f'[dev.test.file] Run tests (fail_fast={fail_fast})') + + def swap(self, path: Path = Path('')): + """ + Swap between test/source. + + :param path: Path to swap. + :type path: Path + """ + print(f'[dev.test.swap] Swap source/test for {path}') + +class validate(dev): + + def file(self, strict: bool = True): + """ + Validate file syntax/config. + + :param strict: Fail on warnings. + :type strict: bool + """ + print(f'[dev.validate.file] Validate file (strict={strict})') + +class doc(dev): + + def build(self, open_browser: bool = False): + """ + Build docs for current file. + + :param open_browser: Open result in browser. + :type open_browser: bool + """ + print(f'[dev.doc.build] Build docs (open_browser={open_browser})') + +class create(dev): + + def testfile(self, module: Path, testfile: Path = Path(''), prompts=True, browser: str = ''): + """ + Create a test file for current file. + + :param prompts: create prompts for ai gen. + :type bool: bool + """ + print(f'[dev.doc.build] Build docs (open_browser={browser})') + +class doccheck(EvnCLI): + "Doccheck: audit project documentation and doctests." + + @classmethod + def _callback(cls, docsdir='docs'): + return dict(help_option_names=['--dochelp']) + +class build(doccheck): + + def full(self, force: bool = False): + print(f'[doccheck.build.full] Full doc build (force={force})') + +class open(doccheck): + + def file(self, browser: str = 'firefox'): + print(f'[doccheck.open.file] Open HTML with browser={browser}') + +class doctest(doccheck): + + def fail_loop(self, verbose: bool = False): + print(f'[doccheck.doctest.fail_loop] Iterate doctest failures (verbose={verbose})') + +class missing(doccheck): + + def list(self, json: bool = False): + print(f'[doccheck.missing.list] List missing docs (json={json})') + +class qa(EvnCLI): + "QA: prepare commits, PRs, and run test matrices." + + pass + +class matrix(qa): + + def run(self, parallel: int = 1): + print(f'[qa.matrix.run] Run matrix with {parallel} parallel jobs') + +class testqa(qa): + + def loop(self, max_retries: int = 3): + print(f'[qa.test.loop] Retry failing tests up to {max_retries} times') + +class out(qa): + + def filter(self, min_lines: int = 5): + print(f'[qa.out.filter] Filter output (min_lines={min_lines})') + +class review(qa): + + def coverage(self, min_coverage: float = 75): + print(f'[qa.review.coverage] Minimum coverage = {min_coverage}%') + + def changes(self, summary: bool = True): + print(f'[qa.review.changes] Show changes (summary={summary})') + +class run(EvnCLI): + "Run: dispatch actions, scripts, or simulate GH actions." + + pass + +class dispatch(run): + + def file( + self, + inputfile: Path, + projects: list[str] = [], + python: str = sys.executable, + config:Path|None = None, + uv: bool = False, + pytest: bool = False, + quiet: bool = False, + verbose: bool = False, + filter_output: bool = False, + ): + # from evn.tool.run_tests_for_file import main + # TODO: read file_mappings, overrides from config file + print(locals()) + # main(**locals()) + +class act(run): + + def job(self, name: str): + print(f'[run.act.job] Run GitHub job {name}') + +class doit(run): + + def task(self, name: str = ''): + print(f'[run.doit.task] Run doit task {name}') + +class script(run): + + def shell(self, cmd: str): + print(f'[run.script.shell] Run shell: {cmd}') + +class buildtools(EvnCLI): + "Build: C++ and native build tasks." + + pass + +class cpp(buildtools): + + def compile(self, debug: bool = False): + print(f'[build.cpp.compile] Compile (debug={debug})') + + def pybind(self, header_only: bool = False): + print(f'[build.cpp.pybind] Generate pybind (header_only={header_only})') + +class clean(buildtools): + + def all(self, verbose: bool = False): + print(f'[build.clean.all] Clean all (verbose={verbose})') + +class proj(EvnCLI): + "Project structure, tagging, and discovery." + + def root(self, verbose: bool = False): + print(f'[proj.EvnCLI] Project EvnCLI (verbose={verbose})') + + def info(self): + print('[proj.info] Project metadata') + + def tags(self, rebuild: bool = False): + print(f'[proj.tags] Generate tags (rebuild={rebuild})') diff --git a/lib/evn/evn/tool/filter_python_output.py b/lib/evn/evn/tool/filter_python_output.py new file mode 100644 index 00000000..9a4c2d2d --- /dev/null +++ b/lib/evn/evn/tool/filter_python_output.py @@ -0,0 +1,254 @@ +import os +import re + +def main(): + print('filter_python_output main') + import sys + import argparse + parser = argparse.ArgumentParser() + parser.add_argument('inputfiles', type=str, nargs='+') + parser.add_argument('-m', '--minlines', type=int, default=0) + parser.add_argument('-p', '--preset', type=str, default='boilerplate') + parser.add_argument('-q', '--quiet', action='store_true') + parser.add_argument('-v', '--verbose', action='store_true') + args = parser.parse_args(sys.argv[1:]).__dict__ + for fname in args['inputfiles']: + os.rename(fname, f'{fname}.orig') + with open(f'{fname}.orig', 'r') as inp: + text = inp.read() + newtext = filter_python_output(text, **args) + with open(fname, 'w') as out: + out.write(newtext) + print(f'file: {fname}, lines {len(text.splitlines())} -> {len(newtext.splitlines())}') + +presets = dict( + unittest=dict( + refile= + (r'quicktest\.py|icecream/icecream.py|/pprint.py|lazy_import.py|<.*>|numexpr/__init__.py|hydra/_internal/defaults_list.py|click/core.py|/typer/main.py|/assertion/rewrite.py' + ), + refunc= + (r'|main|call_with_args_from|wrapper|print_table|make_table|import_module|import_optional_dependency|kwcall' + ), + minlines=30, + ), + boilerplate=dict( + refile= + (r'quicktest\.py|icecream/icecream.py|/pprint.py|lazy_import.py|<.*>|numexpr/__init__.py|hydra/_internal/defaults_list.py|click/core.py|/typer/main.py|/assertion/rewrite.py|/_[A-Za-z0-9i]*.py|site-packages/_pytest/.*py|evn/dev/inspect.py' + ), + refunc= + (r'|main|call_with_args_from|wrapper|print_table|make_table|import_module|import_optional_dependency|kwcall' + ), + minlines=30, + ), + aggressive=dict( + refile= + (r'quicktest\.py|icecream/icecream.py|/pprint.py|lazy_import.py|<.*>|numexpr/__init__.py|hydra/_internal/defaults_list.py|click/core.py|/typer/main.py|/assertion/rewrite.py|/_[A-Za-z0-9i]*.py|site-packages/_pytest/.*py||evn/contexts.py|multipledispatch/dispatcher.py|evn/dev/inspect.py|meta/kwcall.py' + ), + refunc= + (r'|main|call_with_args_from|wrapper|print_table|make_table|import_module|import_optional_dependency|kwcall|main|kwcall' + ), + minlines=1, + ), +) + +re_blank = re.compile(r'(?:^[ \t]*\n){2,}', re.MULTILINE) +re_block = re.compile(r' File "([^"]+)", line (\d+), in (.*)') +re_end = re.compile(r'(^[A-Za-z0-9.]+Error)(: .*)?') +re_null = r'a^' # never matches + +def filter_python_output( + text, + entrypoint=None, + re_file=re_null, + re_func=re_null, + preset='boilerplate', + minlines=-1, + filter_numpy_version_nonsense=True, + keep_blank_lines=False, + arrows=True, + **kw, +): + preset = presets[preset] + # if entrypoint == 'codetool': return text + if minlines < 0: minlines = preset['minlines'] + if preset and re_file == re_null: re_file = preset['refile'] + if preset and re_func == re_null: re_func = preset['refunc'] + if isinstance(re_file, str): re_file = re.compile(re_file) + if isinstance(re_func, str): re_func = re.compile(re_func) + if text.count(os.linesep) < minlines: return text + if filter_numpy_version_nonsense: + text = _filter_numpy_version_nonsense(text) + if not keep_blank_lines: + text = re_blank.sub(os.linesep * 2, text) + + skipped = [] + result = [] + file, _lineno, func, block = None, None, None, None + for line in text.splitlines(): + line = strip_line_extra_whitespace(line) + if m := re_block.match(line): + _finish_block(preset, arrows, block, file, func, re_file, re_func, result, skipped) + file, _linene, func, block = *m.groups(), [line] + elif m := re_end.match(line): + _finish_block(preset, arrows, block, file, func, re_file, re_func, result, skipped, keep=True) + file, _lineno, func, block = None, None, None, None + result.append(line) + elif block: + block.append(line) + else: + if m := re_file_alt.match(line): + line = transform_fileref_to_python_format(line, m) + result.append(line) + if result[-1]: result.append('') + new = os.linesep.join(result) + return new + +re_file_alt = re.compile(r'^E?\s*(.+?\.py):([0-9]+): .*') +def transform_fileref_to_python_format(line, match=None): + """ + examples + ' File "/home/sheffler/evn/evn/tests/_prelude/test_chrono.py", line 96, ...' + /home/sheffler/evn/evn/cli/__init__.py:32: DocTestFailure + """ + match = match or re_file_alt.match(line) + return f' File "{match.group(1)}", line {match.group(2)}, ...' + +def _finish_block(preset, arrows, block, file, func, re_file, re_func, result, skipped, keep=False): + if block: + filematch = re_file.search(file) + funcmatch = re_func.search(func) + if filematch or funcmatch and not keep: + file = os.path.basename(file.replace('/__init__.py', '[init]')) + skipped.append(file if func == '' else func) + else: + if skipped and arrows: + # result.append(' [' + str.join('] => [', skipped) + '] =>') + result.append(' ' + str.join(' -> ', skipped) + ' ->') + skipped.clear() + result.extend(block) + +def strip_line_extra_whitespace(line): + if not line[:60].strip(): + return line.strip() + return line.rstrip() + +# def _strip_text_extra_whitespace(text): +# return re.sub(r'\n\n', os.linesep, text, re.MULTILINE) + +def _filter_numpy_version_nonsense(text): + text = text.replace( + """ +A module that was compiled using NumPy 1.x cannot be run in +NumPy 2.2.3 as it may crash. To support both 1.x and 2.x +versions of NumPy, modules must be compiled with NumPy 2.0. +Some module may need to rebuild instead e.g. with 'pybind11>=2.12'. + +If you are a user of the module, the easiest solution will be to +downgrade to 'numpy<2' or try to upgrade the affected module. +We expect that some modules will need time to support NumPy 2. + +""", + '', + ) + text = text.replace( + """A module that was compiled using NumPy 1.x cannot be run in +NumPy 2.2.3 as it may crash. To support both 1.x and 2.x +versions of NumPy, modules must be compiled with NumPy 2.0. +Some module may need to rebuild instead e.g. with 'pybind11>=2.12'. +If you are a user of the module, the easiest solution will be to +downgrade to 'numpy<2' or try to upgrade the affected module. +We expect that some modules will need time to support NumPy 2. +""", + '', + ) + text = text.replace( + """ from numexpr.interpreter import MAX_THREADS, use_vml, __BLOCK_SIZE1__ +AttributeError: _ARRAY_API not found +""", + '', + ) + text = text.replace( + """AttributeError: _ARRAY_API not found + + + +Traceback""", + '', + ) + return text + +"""Traceback (most recent call last): + File "example.py", line 10, in + 1/0 +ZeroDivisionError: division by zero +foof +ISNR""" + +def analyze_python_errors_log(text): + """Analyze Python error logs and create a report of unique stack traces. + + Args: + text (str): The log file content as a string. + + Returns: + str: A report of unique stack traces. + + Example: + >>> log = '''Traceback (most recent call last): + ... File "example.py", line 10, in + ... 1/0 + ... ZeroDivisionError: division by zero''' + >>> result = analyze_python_errors_log(log) + >>> 'Unique Stack Traces Report (1 unique traces):' in result + True + """ + # traceback_pattern = re.compile(r'Traceback \(most recent call last\):.*?\n[A-Za-z]+?Error:.*?$', re.DOTALL) + from collections import defaultdict + traceback_pattern = re.compile(r'Traceback \(most recent call last\):.*?(?=\nTraceback |\Z)', re.DOTALL) + file_line_pattern = re.compile(r'\n\s*File "(.*?\.py)", line (\d+), in ') + error_pattern = re.compile(r'\n\s*[A-Za-z_0-9]+Error: .*') + trace_map = defaultdict(list) + tracebacks = traceback_pattern.findall(text) + for trace in tracebacks: + filematch = file_line_pattern.search(trace) + errmatch = error_pattern.search(trace) + assert filematch and errmatch, f'Error pattern not found in {trace}' + location = ':'.join(filematch.groups()) + error = errmatch.group(0).strip() + key = (location, error) + if key not in trace_map: + trace_map[key] = trace + return create_errors_log_report(trace_map) + +def create_errors_log_report(trace_map): + """Generate a report from a map of unique stack traces. + + Args: + trace_map (dict): A dictionary where keys are unique error signatures + and values are corresponding stack traces. + + Returns: + str: A formatted report of the unique stack traces. + + Example: + >>> trace_map = { + ... ('1/0', 'division by zero'): '''Traceback (most recent call last): + ... File "example.py", line 10, in + ... 1/0 + ... ZeroDivisionError: division by zero''' + ... } + >>> report = create_errors_log_report(trace_map) + >>> 'Unique Stack Traces Report (1 unique traces):' in report + True + """ + import evn + with evn.capture_stdio() as printed: + print(f'Unique Stack Traces Report ({len(trace_map)} unique traces):') + print('='*80 + '\n') + for _, trace in trace_map.items(): + print(trace) + print('-'*80 + '\n') + return printed.read() + +if __name__ == '__main__': # ignore + main() diff --git a/lib/evn/evn/tool/run_tests_for_file.py b/lib/evn/evn/tool/run_tests_for_file.py new file mode 100644 index 00000000..1ae98461 --- /dev/null +++ b/lib/evn/evn/tool/run_tests_for_file.py @@ -0,0 +1,232 @@ +""" +usage: python run_tests_for_file.py [project name(s)] [file.py] +This script exists for easy editor integration with python test files. Dispatch: +1. If the file has a main block, run it with python +2. If the file is a test_* file without a main block, run it with pytest +3. If the file is not a test_* file and does not have a main block, look for a test_* file in tests with the same path. for example rf_diffusion/foo/bar.py will look for rf_diffusion/tests/foo/test_bar.py +4. If none of the above, or no file specified, run pytest +_overrides can be set to manually specipy a command for a file +_file_mappings can be set to mannually map a file to another file +""" +import argparse +import os +from time import perf_counter + +t_start = perf_counter() + +import sys +import subprocess +from ninja_import import ninja_import +from fnmatch import fnmatch +import functools +from collections import defaultdict +from assertpy import assert_that +from io import StringIO + +spo = ninja_import('evn.tool.filter_python_output') +# set to manually specipy a command for a file +_overrides = { + 'noxfile.py': 'nox -- 3.13 all', + 'pyproject.toml': 'uv run validate-pyproject pyproject.toml', +} +# set to mannually map a file to another file +_file_mappings = { + # 'pymol_selection_algebra.lark': ['evn/tests/sel/test_sel_pymol.py'], + '*.sublime-project': 'ide/validate_sublime_project.py', +} +# postprocess command +_post = defaultdict(lambda: '') + +def get_args(sysargv): + """get command line arguments""" + parser = argparse.ArgumentParser() + parser.add_argument('projects', type=str, nargs='+', default='') + parser.add_argument('inputfile', type=str, default='') + parser.add_argument('--pytest', action='store_true') + parser.add_argument('-q', '--quiet', action='store_true') + parser.add_argument('-v', '--verbose', action='store_true') + parser.add_argument('--uv', action='store_true') + parser.add_argument('--python', type=str, default=sys.executable) + parser.add_argument('--filter-output', action='store_true') + args = parser.parse_args(sysargv[1:]) + return args.__dict__ + +@functools.cache +def file_has_main(fname): + "check if file has a main block" + if not os.path.exists(fname): + return False + with open(fname) as inp: + for line in inp: + if all([ + line.startswith('if __name__ == '), + not line.strip().endswith('{# in template #}'), + 'ignore' not in line, + ]): + return True + return False + +def test_testfile_of(): + tfile = testfile_of(['foo'], '/a/b/c/d/foo/e/f/g', 'h.py', debug=True) + assert_that(tfile).is_equal_to('/a/b/c/d/foo/tests/e/f/g/test_h.py') + tfile = testfile_of(['foo'], 'a/b/c/d/foo/e/f/g', 'h.py', debug=True) + assert_that(tfile).is_equal_to('a/b/c/d/foo/tests/e/f/g/test_h.py') + tfile = testfile_of(['foo', 'bar', 'baz'], '/a/foo/b/bar/c/baz/d', 'file.py', debug=True) + assert_that(tfile).is_equal_to('/a/foo/b/bar/c/baz/tests/d/test_file.py') + tfile = testfile_of(['foo', 'bar', 'baz'], 'a/foo/b/bar/c', 'file.py', debug=True) + assert_that(tfile).is_equal_to('a/foo/b/bar/tests/c/test_file.py') + tfile = testfile_of(['foo', 'bar', 'baz'], 'a/foo/b', 'file.py', debug=True) + assert_that(tfile).is_equal_to('a/foo/tests/b/test_file.py') + tfile = testfile_of(['foo', 'bar', 'baz'], 'foo/foo', 'file.py', debug=True) + assert_that(tfile).is_equal_to('foo/foo/tests/test_file.py') + tfile = testfile_of(['foo', 'bar', 'baz'], 'a/b/c', 'file.py', debug=True) + assert_that(tfile).is_equal_to('tests/a/b/c/test_file.py') + tfile = testfile_of(['foo', 'bar', 'baz'], '', 'file.py', debug=True) + assert_that(tfile).is_equal_to('tests//test_file.py') + print(__file__, 'tests pass') + +def rindex(lst, val): + try: + return len(lst) - lst[-1::-1].index(val) - 1 + except ValueError: + return -1 + +def testfile_of(projects, path, basename, debug=False, **kw) -> str: + "find testfile for a given file" + if basename.startswith('_'): + return None # type: ignore + root = '/' if path and path[0] == '/' else '' + spath = path.split('/') + i = max(rindex(spath, proj) for proj in projects) + # assert i >= 0, f'no {' or '.join(projects)} dir in {path}' + if i < 0: + pre, post = '', f'{path}/' + else: + # proj = spath[i] + # print(spath[:i + 1], spath[i + 1:]) + pre, post = spath[:i + 1], spath[i + 1:] + pre = f'{os.path.join(*pre)}/' if pre else '' + post = f'{os.path.join(*post)}/' if post else '' + # print(pre, post) + t = f'{root}{pre}tests/{post}test_{basename}' + return t + +# def locate_fname(fname): +# 'locate file in sys.path' +# if os.path.exists(fname): return fname +# candidates = [fn for fn in evn.project_files() if fn.endswith(fname)] +# if len(candidates) == 1: return candidates[0] +# if len(candidates) == 0: raise FileNotFoundError(f'file {fname} not found in git project') +# raise FileNotFoundError(f'file {fname} found ambiguous {candidates} in git project') +def dispatch( + projects, + fname, + file_mappings=dict(), + overrides=dict(), + strict=True, + **kw, +): + "dispatch command for a given file. see above" + # fname = locate_fname(fname) + fname = os.path.relpath(fname) + module_fname = '' if fname[:5] == 'test_' else fname + path, basename = os.path.split(fname) + for pattern in file_mappings: + if fnmatch(fname, pattern): + fname = file_mappings[pattern] + path, basename = os.path.split(fname) + if basename in overrides: + return overrides[basename], _post[basename] + if not strict and basename in file_mappings: + assert len(file_mappings[basename]) == 1 + basename = file_mappings[basename][0] + path, basename = os.path.split(basename) + if not file_has_main(fname) and not basename.startswith('test_'): + if testfile := testfile_of(projects, path, basename, **kw): + if not os.path.exists(testfile) and fname.endswith('.py'): + print('autogen test file', testfile) + os.system(f'{sys.executable} -mevn create testfile {fname} {testfile}') + os.system(f'subl {testfile}') + sys.exit() + fname = testfile + path, basename = os.path.split(fname) + cmd, post = make_build_cmds(fname, module_fname, **kw) + return cmd, post + +def make_build_cmds( + fname, + module_fname, + uv, + pytest_args='-x --disable-warnings -m "not nondeterministic" --doctest-modules --durations=7', + pytest=False, + python=None, + verbose=False, + **kw, +): + basename = os.path.basename(fname) + if uv: python = f'uv run --extra dev --python {python}' + pypath = f'PYTHONPATH={":".join(p for p in sys.path if "python3" not in p)}' + has_main = file_has_main(fname) + is_test = basename.startswith('test_') + if not is_test and 'doctest-mod' not in pytest_args: + pytest_args += ' --doctest-modules' + if fname.endswith('.rst'): + cmd = f'{pypath} {python} -m doctest {module_fname}' + elif is_test and (pytest or not has_main): + if module_fname == fname: fname = '' + if is_test and not has_main: print('running pytest because no main') + cmd = f'{pypath} {python} -m pytest {pytest_args} {module_fname} {fname}' + elif fname.endswith('.py') and has_main and basename != 'conftest.py': + cmd = f'{pypath} {python} {fname}' + else: + cmd = f'{pypath} {python} -mpytest {pytest_args}' + return cmd, _post[basename] + +def run_commands(cmds, out): + exitcodes = {} + output = '' + for cmd in cmds: + if out is sys.__stdout__: + exitcodes.setdefault(cmd, []).append(os.system(cmd)) + else: + result = subprocess.run( + cmd, + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + output += result.stdout + exitcodes.setdefault(cmd, []).append(result.returncode) + return output + +def main(projects, quiet=False, filter_output=False, inputfile=None, **kw): + stdout = sys.stdout + if filter_output: sys.__stderr__ = sys.stdout = sys.stderr = StringIO() + try: + if inputfile: + cmd, post = dispatch(projects, inputfile, **kw) + if os.path.basename(inputfile) == os.path.basename(__file__): + test_testfile_of() + sys.exit() + else: + cmd, post = f'{sys.executable} -mpytest', '' + if not quiet: + print('call:', sys.argv) + print('cwd:', os.getcwd()) + print('cmd:', cmd) + print(f'{" run_tests_for_file.py running cmd in cwd ":=^69}') + + output = run_commands([cmd, post], sys.stdout) + print(output, end='') + + t_total = perf_counter() - t_start + print(f'{f" run_tests_for_file.py done, time {t_total:7.3f} ":=^69}', flush=True) + finally: + if isinstance(sys.stdout, StringIO): + sys.stdout.seek(0) + stdout.write(sys.stdout.read()) + +if __name__ == '__main__': + args = get_args(sys.argv) + main(file_mappings=_file_mappings, overrides=_overrides, **args) diff --git a/lib/evn/evn/tree/__init__.py b/lib/evn/evn/tree/__init__.py new file mode 100644 index 00000000..06848d9b --- /dev/null +++ b/lib/evn/evn/tree/__init__.py @@ -0,0 +1,4 @@ +from evn.tree.tree_metrics import * +from evn.tree.tree_sanitize import * +from evn.tree.tree_diff import * +from evn.tree.tree_format import * diff --git a/lib/evn/evn/tree/tree_diff.py b/lib/evn/evn/tree/tree_diff.py new file mode 100644 index 00000000..d966cc38 --- /dev/null +++ b/lib/evn/evn/tree/tree_diff.py @@ -0,0 +1,233 @@ +from enum import Enum +from typing import Any, Callable, Optional, Tuple, Dict, List, Set +from collections.abc import Mapping +import evn + + +@evn.dispatch(dict, dict) +def diff_impl(tree1, tree2, out=print, **kw): + differ = evn.kwcall(kw, TreeDiffer) + diff = evn.kwcall(kw, differ.diff, tree1, tree2) + return diff + + +class ShallowHandling(Enum): + DEEP = 'deep' + SHALLOW = 'shallow' + SUMMARY = 'summary' + + +class TreeDiffStrategy: + + def is_shallow(self, path: Tuple[str, ...], val1: Any, + val2: Any) -> ShallowHandling: + return ShallowHandling.DEEP + + def values_equal(self, val1: Any, val2: Any) -> bool: + return val1 == val2 + + def summarize(self, val: Any, maxlen: int = 80) -> str: + return repr(val)[:maxlen] + ('...' if len(repr(val)) > maxlen else '') + + def should_ignore_key(self, path: Tuple[str, ...], key: str) -> bool: + return False + + def classify_lists(self, l1: list, + l2: list) -> Dict[str, List[Tuple[int, int]]]: + + def is_nested(x): + return isinstance(x, (Mapping, list)) + + flat, nested = [], [] + for i, (x1, x2) in enumerate(zip(l1, l2)): + if is_nested(x1) or is_nested(x2): + nested.append((i, i)) + else: + flat.append((i, i)) + return {'flat': flat, 'nested': nested} + + def compare_flat_lists(self, l1: list, l2: list) -> Tuple[list, list]: + return sorted(set(l1) - set(l2)), sorted(set(l2) - set(l1)) + + def match_lists_by_type(self, path: Tuple[str, ...], l1: list, + l2: list) -> Optional[str]: + if all(isinstance(x, Mapping) for x in l1 + l2) and len(l1) == len(l2): + return 'zip' + return None + + +class TreeDiffer: + + def __init__( + self, + *, + strategy: Optional[TreeDiffStrategy] = None, + flatpaths: bool = False, + summarize_subtrees: bool = False, + summary_len: int = 80, + max_depth: Optional[int] = None, + path_fmt: Optional[Callable[[Tuple[str, ...]], str]] = None, + ): + self.strategy = strategy or TreeDiffStrategy() + self.flatpaths = flatpaths + self.summarize_subtrees = summarize_subtrees + self.summary_len = summary_len + self.max_depth = max_depth + self.path_fmt = path_fmt or (lambda p: '.'.join(p)) + self._seen_pairs: Set[Tuple[int, int]] = set() + + def diff(self, cfg1: Any, cfg2: Any) -> dict: + self._seen_pairs.clear() + result = self._walk(cfg1, cfg2, path=()) + return self._flatten(result) if self.flatpaths else result + + def _walk(self, val1: Any, val2: Any, path: Tuple[str, ...]) -> Any: + if self.max_depth is not None and len(path) > self.max_depth: + return None + + id_pair = (id(val1), id(val2)) + if id_pair in self._seen_pairs: + return None + self._seen_pairs.add(id_pair) + + mode = self.strategy.is_shallow(path, val1, val2) + if mode == ShallowHandling.SUMMARY: + return (self._summarize(val1), self._summarize(val2)) + elif mode == ShallowHandling.SHALLOW: + if not self.strategy.values_equal(val1, val2): + return (val1, val2) + return None + + if isinstance(val1, Mapping) and isinstance(val2, Mapping): + all_keys = set(val1) | set(val2) + diff = {} + subtree1 = {} + subtree2 = {} + for key in sorted(all_keys): + if self.strategy.should_ignore_key(path, key): + continue + in1 = key in val1 + in2 = key in val2 + if in1 and in2: + sub = self._walk(val1[key], val2[key], path + (key, )) + if sub is not None: + diff[key] = sub + elif in1: + subtree1[key] = val1[key] + elif in2: + subtree2[key] = val2[key] + if not diff and not subtree1 and not subtree2: + return None + elif not diff: + return (self._summarize(subtree1), self._summarize(subtree2)) + else: + node = {**diff} + if subtree1 or subtree2: + node['_diff'] = (self._summarize(subtree1), + self._summarize(subtree2)) + return node + + elif isinstance(val1, list) and isinstance(val2, list): + match_mode = self.strategy.match_lists_by_type(path, val1, val2) + out = {} + if match_mode == 'zip': + for i, (x1, x2) in enumerate(zip(val1, val2)): + d = self._walk(x1, x2, path + (f'[{i}]', )) + if d is not None: + out.setdefault('_nested', {})[i] = d + else: + idxs = self.strategy.classify_lists(val1, val2) + + if idxs['flat']: + l1 = [val1[i] for i, _ in idxs['flat']] + l2 = [val2[j] for _, j in idxs['flat']] + flat_diff = self.strategy.compare_flat_lists(l1, l2) + if flat_diff[0] or flat_diff[1]: + out['_flat'] = flat_diff + + nested_diffs = [] + for i, j in idxs['nested']: + d = self._walk(val1[i], val2[j], path + (f'[{i}]', )) + if d is not None: + nested_diffs.append((i, d)) + + if nested_diffs: + out['_nested'] = dict(nested_diffs) + + return out if out else None + + if not self.strategy.values_equal(val1, val2): + return (val1, val2) + + return None + + def _summarize(self, val: Any) -> Any: + if self.summarize_subtrees: + return self.strategy.summarize(val, self.summary_len) + return val + + def _flatten(self, nested: dict) -> dict: + flat = {} + + def walk(node: Any, path: Tuple[str, ...]): + if isinstance(node, dict): + for k, v in node.items(): + if k in ('_diff', '_flat') and not isinstance(v, dict): + flat[self.path_fmt(path) + f':{k}'] = v + elif k == '_nested': + for idx, sub in v.items(): + walk(sub, path + (f'[{idx}]', )) + else: + walk(v, path + (k, )) + else: + flat[self.path_fmt(path)] = node + + walk(nested, ()) + return flat + + +class SummaryStrategy(TreeDiffStrategy): + + def is_shallow(self, path, v1, v2): + return ShallowHandling.SUMMARY + + +class IgnoreKeyStrategy(TreeDiffStrategy): + + def should_ignore_key(self, path, key): + return key.startswith('_') + + +class IgnoreUnderscoreKeysStrategy(TreeDiffStrategy): + + def should_ignore_key(self, path, key): + return key.startswith('_') + + +class FloatToleranceStrategy(TreeDiffStrategy): + + def values_equal(self, val1, val2): + if isinstance(val1, float) and isinstance(val2, float): + return abs(val1 - val2) < 1e-6 + return val1 == val2 + + def compare_flat_lists(self, l1, l2): + only1, only2 = [], [] + only1.extend(v for v in l1 + if not any(self.values_equal(v, w) for w in l2)) + only2.extend(v for v in l2 + if not any(self.values_equal(v, w) for w in l1)) + return only1, only2 + + +class ShallowOnPathStrategy(TreeDiffStrategy): + + def is_shallow(self, path, v1, v2): + return ShallowHandling.SHALLOW if 'meta' in path else ShallowHandling.DEEP + + +class SummaryOnPathStrategy(TreeDiffStrategy): + + def is_shallow(self, path, v1, v2): + return ShallowHandling.SUMMARY if path and path[ + -1] == 'data' else ShallowHandling.DEEP diff --git a/lib/evn/evn/tree/tree_format.py b/lib/evn/evn/tree/tree_format.py new file mode 100644 index 00000000..2568f87b --- /dev/null +++ b/lib/evn/evn/tree/tree_format.py @@ -0,0 +1,525 @@ +import itertools +import shutil +import os + +from enum import Enum +import typing as t +import evn + +import enum +import evn.tree +from pprint import pprint + +class Format(str, enum.Enum): + TREE = 'tree' + FOREST = 'forest' + FANCY = 'fancy' + FANCYHORIZ = 'fancyhoriz' + SPIDER = 'spider' + PPRINT = 'pprint' + JSON = 'json' + YAML = 'yaml' + RICH = 'rich' + + +@evn.dispatch(dict) +def show_impl(dict_, format='forest', **kw): + dict_ = evn.tree.sanitize(dict_) + + if not any(isinstance(d, (dict, list)) for d in dict_.values()): + pprint(dict_) + return + + try: + fmt = Format(format) + except ValueError as e: + print(f"โš ๏ธ Invalid format '{format}': {e}", flush=True) + return + + # Define a mapping from Format to callable + format_handlers = { + Format.SPIDER: lambda: evn.tree.print_spider_tree(dict_, **kw), + Format.FOREST: lambda: evn.tree.print_tree(dict_, **kw), + Format.FANCY: lambda: evn.tree.print_spider_tree(dict_, mirror=False, **kw), + Format.FANCYHORIZ: lambda: evn.tree.print_spider_tree(dict_, mirror=False, orient='horiz', **kw), + Format.TREE: lambda: evn.tree.print_tree(dict_, max_width=1, **kw), + Format.JSON: lambda: print(__import__('json5').dumps(dict_, indent=2)), + Format.YAML: lambda: print(__import__('yaml').dump(evn.unbunchify(dict_))), + Format.RICH: lambda: __import__('rich').print(evn.tree.rich_tree(dict_, **kw)), + Format.PPRINT: lambda: pprint(dict_), + } + + # Try the requested format first, then fall back to others + formats_to_try = [fmt] + [f for f in Format if f != fmt] + + for f in formats_to_try: + handler = format_handlers.get(f) + try: + handler() + break + except evn.tree.TreeFormatError: + print(f'tree_format failed {f}, continuing...', flush=True) + continue + except Exception as e: + print(f"โš ๏ธ Error in format '{f.value}': {e}", flush=True) + break + + +# class Format(str, enum.Enum): +# TREE = 'tree' +# FOREST = 'forest' +# FANCY = 'fancy' +# FANCYHORIZ = 'fancyhoriz' +# SPIDER = 'spider' +# PPRINT = 'pprint' +# JSON = 'json' +# YAML = 'yaml' +# RICH = 'rich' + + +# @evn.dispatch(dict) +# def show_impl(dict_, format='forest', **kw): +# dict_ = evn.tree.sanitize(dict_) +# if not any(isinstance(d, (dict, list)) for d in dict_.values()): +# import pprint +# pprint.pprint(dict_) +# return +# try: +# fmt = Format(format) +# except ValueError as e: +# print(f"โš ๏ธ Invalid format '{format}': {e}", flush=True) +# return +# if fmt is Format.SPIDER: +# evn.tree.print_spider_tree(dict_, **kw) +# elif fmt is Format.FOREST: +# evn.tree.print_tree(dict_, **kw) +# elif fmt is Format.FANCY: +# evn.tree.print_spider_tree(dict_, mirror=False, **kw) +# elif fmt is Format.FANCYHORIZ: +# evn.tree.print_spider_tree(dict_, mirror=False, orient='horiz', **kw) +# elif fmt is Format.TREE: +# evn.tree.print_tree(dict_, max_width=1, **kw) +# elif fmt is Format.JSON: +# import json5 as json +# print(json.dumps(dict_, indent=2)) +# elif fmt is Format.YAML: +# import yaml +# print(yaml.dump(evn.unbunchify(dict_))) +# elif fmt is Format.RICH: +# import rich +# tree = evn.tree.rich_tree(dict_, **kw) +# rich.print(tree) +# elif fmt is Format.PPRINT: +# import pprint +# pprint.pprint(dict_) + + +class DiffMode(str, Enum): + COMPACT = ('compact', ) + SYMMETRIC = ('symmetric', ) + EXPLICIT = ('explicit', ) + + +def treediff(tree1: dict[str, t.Any], + tree2: dict[str, t.Any], + mode=DiffMode.SYMMETRIC): + """ + Walks two trees in parallel, applying a function to each pair of nodes. + """ + result = evn.diff(tree1, tree2, syntax=mode) + + return result + + +def str_replace_multiple(text, replacements): + translation_table = str.maketrans(replacements) + return text.translate(translation_table) + + +def rich_tree(data, name='root', compact=True, style='bold green', **kw): + """ + Create and display a Rich Tree from a nested dictionary. + + Parameters + ---------- + data : dict or value + The nested dictionary (or scalar value). + name : str + Label for the root node. + compact : bool + Collapse chains of single-key dicts into path strings. + style : str + Rich style to apply to tree labels (default: "bold green"). + """ + from rich.console import Console + from rich.tree import Tree as RichTree + from rich.text import Text + + console = Console() + tree = RichTree(Text(name, style=style)) + + def add_node(rich_node, obj, path=None): + path = path or [] + + if isinstance(obj, dict): + keys = list(obj.keys()) + i = 0 + while compact and i < len(keys): + k = keys[i] + child = obj[k] + if isinstance(child, dict) and len(child) == 1: + path.append(k) + obj = child + keys = list(obj.keys()) + i = 0 + else: + break + + if path: + collapsed = '.'.join(path + [keys[i]]) if i < len( + keys) else '.'.join(path) + child_obj = obj[keys[i]] if i < len(keys) else obj + new_node = rich_node.add(Text(collapsed, style=style)) + add_node(new_node, child_obj) + for k in keys[i + 1:]: + add_node(rich_node, obj[k], path=[k]) + else: + for k in keys: + add_node(rich_node, obj[k], path=[k]) + else: + label = '.'.join(path) + f': {obj}' if path else str(obj) + rich_node.add(Text(label, style=style)) + + add_node(tree, data) + console.print(tree) + + +def print_spider_tree( + input: dict, + name='dict', + orient='vertical', + width=80, + mirror=True, + out=print, + **kw, +): + from PrettyPrint import PrettyPrintTree + + if not input: + return + pt = PrettyPrintTree() + if orient.startswith('vert'): + orient = PrettyPrintTree.Vertical + elif orient.startswith('hori'): + orient = PrettyPrintTree.Horizontal + else: + raise ValueError(f'Unknown orientation {orient}') + if orient == PrettyPrintTree.Horizontal: + pt.print_json(input, + name=f'{name} at {id(input)}', + color='', + orientation=orient) + return + + trees = [] + head, tail = [], input + while len(tail.keys()) == 1: + head += list(tail.keys()) + tail = tail[head[-1]] + if not isinstance(tail, dict): + pt.print_json(input, + name=f'{name} at {id(input)}', + color='', + orientation=orient) + return + keysleft = list(tail.keys()) + assert len(keysleft) > 1 + kw = dict(name=f'{name} at {id(input)}', + color='', + orientation=orient, + return_instead_of_print=True) | kw + while keysleft: + last = None + for i in range(1, len(keysleft)): + subinput = input + subtree_tail = subtree = dict() + for key in head: + subtree_tail[key] = subtree_tail = {} + subinput = subinput[key] + subtree_tail |= {k: subinput[k] for k in keysleft[:i]} + tree = pt.print_json(subtree, **kw) + j, w = None, max(len(line) for line in tree.splitlines()) + if w > width and last: + j, tree = i, last + elif w >= width or i + 1 == len(keysleft): + j = i + 1 + if j is not None: + trees.append(tree) + keysleft = keysleft[j:] + # print(i, j, w, keysleft) + break + if mirror and len(trees) > 1: + flip_top = { + 'โ”Œ': 'โ””', + 'โ”': 'โ”˜', + 'โ””': 'โ”Œ', + 'โ”˜': 'โ”', + 'โ”ด': 'โ”ฌ', + 'โ”ฌ': 'โ”ด', + } + bar = '|'.center(width) + for i in range(0, len(trees), 2): + trees[i] = str_replace_multiple(trees[i], flip_top) + top = list(reversed(trees[i].splitlines()))[:-1] + bottom = trees[i + 1].splitlines() + if head: + root = bottom[0].strip().center(width) + else: + root = os.linesep.join( + [bar, bottom[0].strip().center(width), bar]) + bottom = bottom[1:] + for k in head: + top = top[:-2] + bottom = bottom[2:] + k = k.center(width) + root = os.linesep.join([bar, k, root, k, bar]) + top[-1] = top[-1].replace('โ”ฌ', 'โ”€') + top[-1] = top[-1][:width // 2 - 1] + 'โ”ฌ' + top[-1][width // 2:] + bottom[0] = bottom[0].replace('โ”ด', 'โ”€') + bottom[0] = bottom[0][:width // 2 - 1] + 'โ”ด' + bottom[0][width // + 2:] + print(os.linesep.join(top + [root] + bottom)) + if len(trees) % 2: + print('continued below>') + print(trees[-1], flush=True) + else: + for tree in trees[int(bool(mirror)):-1]: + print(tree) + print('') + print(trees[-1], flush=True) + + +def print_tree(d, + name='root', + compact=True, + max_width=None, + style='unicode', + **kw): + """ + Print a nested dictionary starting at the first branching point. + Each key under that point is rendered as a tree block in columns. + + Parameters + ---------- + d : dict + The nested dictionary to print. + compact : bool + Collapse chains of single-key dicts into 'a/b/c' form. + max_width : int or None + Maximum output width in characters (default: terminal width). + style : str + Use 'unicode' or 'ascii' line characters. + """ + width = max_width or shutil.get_terminal_size((80, 20)).columns + prefix_path, blocks = build_tree_blocks_from_branching_aligned( + d, compact=compact, style=style) + label = '.'.join(prefix_path) if prefix_path else name + lines, colwidth = _columnize_blocks(blocks, width) + if len(lines) == 1: + print(lines[0]) + return + header, wtot = label, 0 + for i, w in enumerate(colwidth[:-1]): + wtot += w + s = wtot - len(header) + if s > 0: + header += 'โ”€' * s + ('โ”' if i + 2 == len(colwidth) else 'โ”ฌ') + + print(header) + for line in lines: + print(line) + + +def find_first_branching_path(d, path=None): + if path is None: + path = [] + if len(d) != 1: + return path + key = evn.first(d) + if not isinstance(d[key], dict): + return [] + return find_first_branching_path(d[key], path + [key]) + + +def descend_to_path(d, path): + for key in path: + d = d[key] + return d + + +def collect_blocks_at_branching_point(d): + branching_path = find_first_branching_path(d) + subdict = descend_to_path(d, branching_path) + return branching_path, subdict + + +def _collapse_path(d, path): + while isinstance(d, dict) and len(d) == 1: + k, v = next(iter(d.items())) + path.append(k) + d = v + return d + + +def build_tree_blocks_from_branching_aligned(d, compact=True, style='unicode'): + BOX = { + 'tee': 'โ”œโ”€ ' if style == 'unicode' else '|-- ', + 'corner': 'โ””โ”€ ' if style == 'unicode' else '`-- ', + 'vert': 'โ”‚ ' if style == 'unicode' else '| ', + 'space': ' ', + } + + def recurse(node, prefix, key, islast): + connector = BOX['corner'] if islast else BOX['tee'] + branch = prefix + connector if prefix else '' + lines = [] + + path = [key] + value = _collapse_path(node, path) if compact else node + path_str = '.'.join(path) + + if isinstance(value, dict): + lines.append((branch + path_str, None)) + children = list(value.items()) + for i, (k, v) in enumerate(children): + child_lines = recurse( + v, prefix + (BOX['space' if islast else 'vert']), k, + i == len(children) - 1) + lines.extend(child_lines) + else: + lines.append((branch + path_str, str(value))) + return lines + + path, subdict = collect_blocks_at_branching_point(d) + # keys = list(subdict.keys()) + blocks = [] + + def padkey(k): + if len(k) > 4: + return k + k = k.ljust(4, 'โ”€') + 'โ”' + return k + + for i, (k, v) in enumerate(subdict.items()): + islast = False # (i == len(keys) - 1) + raw_lines = recurse(v, '', k, islast) + # key_width = max(len(key) for key, val in raw_lines) + aligned = [ + f'{key}: {val}' if val is not None else padkey(key) + # f"{key.ljust(key_width)}: {val}" if val is not None else key + for key, val in raw_lines + ] + blocks.append((aligned, islast)) + + return path, blocks + + +def _columnize_blocks(blocks, max_width, spacing=2): + widths = [max(len(line) for line in block) for block, _ in blocks] + if len(blocks) == 1: + return blocks, widths + lens = [len(block) for block, _ in blocks] + ncol = max_width // max(widths) + parts = partition_balanced(lens, ncol, reorder=True) + # print(len(parts)) + # assert 0, f'parts: {parts}' + colwidth = [max(widths[i] for i in part) + 2 for part in parts] + cols = [evn.addreduce([blocks[i][0] for i in part]) for part in parts] + cols = [[line.rstrip().ljust(colwidth[i]) for line in cols[i]] + for i in range(len(cols))] + for c, w in zip(cols, colwidth): + for _ in range(max(map(len, cols)) - len(c)): + c.append(' ' * w) + assert len({len(c) for c in cols}) == 1 + alllines = [''.join(lines) for lines in zip(*cols)] + return alllines, colwidth + + +def partition_balanced(nums: list[int], + n_parts: int, + reorder: bool = True) -> list[list[int]]: + """ + Partition a list of integers into `n_parts` sublists to balance the sums as evenly as possible. + + If `reorder` is False, the partitioning must preserve the input order and return + contiguous, non-overlapping partitions. The output is a list of lists of indices into `nums`. + + Parameters: + nums (list[int]): The list of integers to partition. + n_parts (int): Number of partitions. + reorder (bool): If True, reorder nums to optimize balance. If False, preserve original order. + + Returns: + list[list[int]]: A list of `n_parts` sublists of indices into `nums`. + """ + if n_parts < 2: + return [list(range(len(nums)))] + if n_parts > len(nums): + return [[i] for i in range(len(nums)) + ] + [[] for _ in range(n_parts - len(nums))] + + if reorder: + # Use greedy load balancing (same as before) + import heapq + + indexed = sorted(enumerate(nums), key=lambda x: -x[1]) + partitions: list[list[int]] = [[] for _ in range(n_parts)] + heap = [(0, i) for i in range(n_parts)] + heapq.heapify(heap) + + for idx, val in indexed: + current_sum, i = heapq.heappop(heap) + partitions[i].append(idx) + heapq.heappush(heap, (current_sum + val, i)) + + return [list(sorted(p)) for p in partitions] + + # --- ORDER-PRESERVING PARTITIONING (Linear Partition DP) --- + n = len(nums) + prefix_sums = list(itertools.accumulate(nums)) + + # DP tables + cost = [[0] * n for _ in range(n_parts)] + dividers = [[0] * n for _ in range(n_parts)] + + # Base case: 1 partition + for i in range(n): + cost[0][i] = prefix_sums[i] + + # Fill DP table + for k in range(1, n_parts): + for i in range(n): + best_cost = float('inf') + best_j = -1 + for j in range(k - 1, i): + left = cost[k - 1][j] + right = prefix_sums[i] - prefix_sums[j] + this_cost = max(left, right) + if this_cost < best_cost: + best_cost = this_cost + best_j = j + cost[k][i] = best_cost + dividers[k][i] = best_j + + # Reconstruct partition indices + def reconstruct(dividers, n, k): + result = [] + end = n - 1 + for part in reversed(range(1, k)): + start = dividers[part][end] + 1 + result.append(list(range(start, end + 1))) + end = dividers[part][end] + result.append(list(range(0, end + 1))) + return list(reversed(result)) + + return reconstruct(dividers, n, n_parts) diff --git a/lib/evn/evn/tree/tree_metrics.py b/lib/evn/evn/tree/tree_metrics.py new file mode 100644 index 00000000..d8113dc6 --- /dev/null +++ b/lib/evn/evn/tree/tree_metrics.py @@ -0,0 +1,160 @@ +import evn + +evn.dispatch(dict) + + +def inspect(dct): + return tree_metrics(dct) + + +def tree_metrics(tree, subtree_pattern_threshold=2.0): + """ + Analyze a nested dictionary (tree) and compute a wide range of structural, + semantic, and integrity metrics. + + The function safely handles cycles (self-references), gathers statistics + on depth, width, branching, and key usage, and detects repeated subtree + patterns if runtime permits. + + Parameters + ---------- + tree : dict + The nested dictionary to analyze. + subtree_pattern_threshold : float, optional + Maximum allowed multiple of baseline runtime to spend on + repeated subtree pattern analysis (default is 2.0). + + Returns + ------- + metrics : dict + A nested dictionary with the following structure: + + { + 'structure': { + 'max_depth': int, + 'min_leaf_depth': int, + 'avg_leaf_depth': float, + 'leaf_depth_stddev': float, + 'max_width': int, + 'avg_branching_factor': float, + 'num_internal_nodes': int, + 'num_leaves': int, + 'total_elements': int, + 'total_leaf_size': int + }, + 'keys': { + 'all_keys': set, + 'key_reuse_ratio': float + }, + 'integrity': { + 'num_cycles': int, + 'cycle_paths': list of tuples + }, + 'subtrees': { + 'repeated_subtrees': dict or str + }, + 'timing': { + 'runtime_sec': float + } + } + """ + from collections import deque, Counter + from math import sqrt + import time + + visited_ids = set() + key_counter = Counter() + depth_list = [] + subtree_counter = Counter() + # seen_nodes = {} + + total_nodes = 0 + num_internal_nodes = 0 + num_leaves = 0 + total_leaf_size = 0 + max_depth = 0 + max_width = 0 + branch_counts = [] + cycle_paths = [] + + all_keys = set() + cycle_ids = set() + + queue = deque([(tree, 1, 'root', id(tree), ())]) + start_time = time.time() + + while queue: + level_size = len(queue) + max_width = max(max_width, level_size) + + for _ in range(level_size): + node, depth, path_label, node_id, path = queue.popleft() + current_path = path + (path_label, ) + + if node_id in visited_ids: + if node_id not in cycle_ids: + cycle_ids.add(node_id) + cycle_paths.append(current_path) + continue + visited_ids.add(node_id) + + if isinstance(node, dict): + num_internal_nodes += 1 + keys = list(node.keys()) + children = list(node.values()) + key_counter.update(keys) + all_keys.update(keys) + branch_counts.append(len(children)) + + subtree_repr = tuple( + sorted((k, id(v)) for k, v in node.items())) + subtree_counter[subtree_repr] += 1 + + for k, v in node.items(): + queue.append((v, depth + 1, k, id(v), current_path)) + else: + num_leaves += 1 + total_nodes += 1 + depth_list.append(depth) + total_leaf_size += len(node) if hasattr( + node, '__len__') and not isinstance(node, str) else 1 + + max_depth = max(max_depth, depth) + + elapsed = time.time() - start_time + + total_elements = total_nodes + num_internal_nodes + avg_leaf_depth = sum(depth_list) / len(depth_list) if depth_list else 0 + min_leaf_depth = min(depth_list) if depth_list else 0 + leaf_depth_stddev = (sqrt( + sum((d - avg_leaf_depth)**2 + for d in depth_list) / len(depth_list)) if depth_list else 0) + avg_branching = sum(branch_counts) / len( + branch_counts) if branch_counts else 0 + key_reuse_ratio = sum( + key_counter.values()) / len(key_counter) if key_counter else 0 + + repeated_subtrees = ({ + k: v + for k, v in subtree_counter.items() if v > 1 + } if elapsed < subtree_pattern_threshold else + 'Not computed (runtime threshold exceeded)') + + return { + 'max_depth': max_depth, + 'min_leaf_depth': min_leaf_depth, + 'avg_leaf_depth': round(avg_leaf_depth, 2), + 'leaf_depth_stddev': round(leaf_depth_stddev, 2), + 'max_width': max_width, + 'avg_branching_factor': round(avg_branching, 2), + 'num_internal_nodes': num_internal_nodes, + 'num_leaves': num_leaves, + 'total_elements': total_elements, + 'total_leaf_size': total_leaf_size, + 'all_keys': all_keys, + 'key_reuse_ratio': round(key_reuse_ratio, 2), + 'num_cycles': len(cycle_paths), + 'cycle_paths': cycle_paths, + 'repeated_subtrees': repeated_subtrees, + 'runtime': round(elapsed, 3), + } diff --git a/lib/evn/evn/tree/tree_sanitize.py b/lib/evn/evn/tree/tree_sanitize.py new file mode 100644 index 00000000..6629eaf7 --- /dev/null +++ b/lib/evn/evn/tree/tree_sanitize.py @@ -0,0 +1,42 @@ +from pathlib import Path +from enum import Enum +from datetime import datetime, date +from collections.abc import Mapping, Sequence +import evn + + +def sanitize(obj): + if obj is None or isinstance(obj, (str, int, float, bool)): + return obj + if isinstance(obj, Path): + return str(obj) + if isinstance(obj, (datetime, date)): + return obj.isoformat() + if isinstance(obj, Enum): + return obj.value + if isinstance(obj, set): + return sorted(sanitize(x) for x in obj) + if isinstance(obj, Sequence) and not isinstance(obj, (str, bytes)): + return [sanitize(x) for x in obj] + if isinstance(obj, Mapping): + return {sanitize(k): sanitize(v) for k, v in obj.items()} + if evn.installed.numpy: + np = evn.lazyimport('numpy') + if isinstance(obj, np.ndarray): + return obj.tolist() + if evn.installed.torch: + import torch + + if isinstance(obj, torch.Tensor): + return obj.detach().cpu().tolist() + if evn.installed.biotite: + from biotite.structure import AtomArray, AtomArrayStack + + if isinstance(obj, (AtomArray, AtomArrayStack)): + return obj.as_array().tolist() + if evn.installed.omegaconf: + from omegaconf import OmegaConf + + if OmegaConf.is_config(obj): + return OmegaConf.to_container(obj, resolve=True) + return repr(obj) diff --git a/lib/evn/ide/evn.sublime-project b/lib/evn/ide/evn.sublime-project new file mode 100644 index 00000000..63a9b1e1 --- /dev/null +++ b/lib/evn/ide/evn.sublime-project @@ -0,0 +1,152 @@ +{ + "folders": + [ + { + "path": "/home/sheffler/evn", + "folder_exclude_patterns": + [ + "site-packages", + ".test*" + ] + } + ], + "settings": + { + "PyYapf":{ + "yapf_command": "yapf", + "on_save": false + // "yapf_command": "/home/sheffler/src/willutil/willutil/app/codealign", + }, + "project_environment": { + "env": { + "PYTHON": "/home/sheffler/sw/MambaForge/envs/evn/bin/python", + "PYTEST": "/home/sheffler/sw/MambaForge/envs/evn/bin/pytest", + "PIP": "/home/sheffler/sw/MambaForge/envs/evn/bin/pip", + // "PYTHON": "/home/sheffler/sw/MambaForge/envs/wu/bin/python", + "QT_QPA_PLATFORM": "xcb" + } + } + }, + "build_systems": + [ + { + "name": "run_tests_for_file", + "working_dir": "$folder", + "shell_cmd": "PYTHONPATH=. \\$PYTHON evn/tool/run_tests_for_file.py --quiet evn --filter-output $file 2>&1 | tee $folder/ide/sublime_build.log", + "file_regex": "^\\s*File \"(...*?)\", line ([0-9]*)" + }, + { + "name": "run_tests_for_file pytest", + "working_dir": "$folder", + "shell_cmd": "PYTHONPATH=. \\$PYTHON evn/tool/run_tests_for_file.py --quiet evn --pytest --filter-output $file 2>&1 | tee $folder/ide/sublime_build.log", + "file_regex": "^\\s*File \"(...*?)\", line ([0-9]*)" + }, + { + "name": "run evn", + "working_dir": "$folder", + "shell_cmd": "PYTHONPATH=. \\$PYTHON -m evn run dispatch file 2>&1 | tee $folder/ide/sublime_build.log", + "file_regex": "^\\s*File \"(...*?)\", line ([0-9]*)" + }, + { + "name": "ruff", + "working_dir": "$folder", + "shell_cmd": "PYTHONPATH=. ruff check evn 2>&1 | tee $folder/ide/sublime_build.log", + // "file_regex": "^\\s*File \"(...*?)\", line ([0-9]*)" + "file_regex": "^(.+\\.py):([0-9]+):(\\d+): (.*)$" + }, + { + "name": "pytest all", + "working_dir": "$folder", + "shell_cmd": "uv run pytest --doctest-modules -k 'not Chrono' evn -xs 2>&1 | tee $folder/ide/sublime_build.log", + // "file_regex": "^(?:\\s*File \"(.*?)\", line ([0-9]+)|(.+\\.py):([0-9]+):)" + // "file_regex": "^\\s*File \"(...*?)\", line ([0-9]*)" + "file_regex": "^E?(.+\\.py):([0-9]+):" + }, + { + "name": "run file", + "shell_cmd": "cd $folder; PYTHONPATH=. \\$PYTEST $file 2>&1 | tee $folder/ide/sublime_build.log", + // "file_regex": "^\\s*File \"(...*?)\", line ([0-9]*)" + "file_regex": "^(.+\\.py):([0-9]+): " + }, + { + "name": "pytest cli", + "working_dir": "$folder", + "shell_cmd": "PYTHONPATH=. \\$PYTHON evn/tests/cli/test_cli_config.py 2>&1 | tee $folder/ide/sublime_build.log", + // "file_regex": "^(?:\\s*File \"(.*?)\", line ([0-9]+)|(.+\\.py):([0-9]+):)" + // "file_regex": "^\\s*File \"(...*?)\", line ([0-9]*)" + "file_regex": "^E?(.+\\.py):([0-9]+): " + }, + { + "name": "pytest treediff", + "working_dir": "$folder", + "shell_cmd": "PYTHONPATH=. \\$PYTEST evn/tests/tree 2>&1 | tee $folder/ide/sublime_build.log", + // "file_regex": "^(?:\\s*File \"(.*?)\", line ([0-9]+)|(.+\\.py):([0-9]+):)" + // "file_regex": "^\\s*File \"(...*?)\", line ([0-9]*)" + "file_regex": "^E?(.+\\.py):([0-9]+): " + }, + { + "name": "pytest file", + "working_dir": "$folder", + "shell_cmd": "PYTHONPATH=. \\$PYTEST --doctest-modules $file -svx 2>&1 | tee $folder/ide/sublime_build.log", + // "file_regex": "^(?:\\s*File \"(.*?)\", line ([0-9]+)|(.+\\.py):([0-9]+):)" + // "file_regex": "^\\s*File \"(...*?)\", line ([0-9]*)" + "file_regex": "^(.+\\.py):([0-9]+): " + }, + { + "name": "testapp", + "working_dir": "$folder", + "shell_cmd": "(PYTHONPATH=. \\$PYTEST evn/tests/cli evn/cli evn/tests/config -s && PYTHONPATH=. \\$PYTHON -m evn.tests.cli.evn_test_app root --help) 2>&1 | tee $folder/ide/sublime_build.log", + // "file_regex": "^(?:\\s*File \"(.*?)\", line ([0-9]+)|(.+\\.py):([0-9]+):)" + // "file_regex": "^\\s*File \"(...*?)\", line ([0-9]*)" + "file_regex": "^(.+\\.py):([0-9]+): " + }, + { + "name": "doit", + "shell_cmd": "cd $folder; doit 2>&1 | tee $folder/ide/sublime_build.log; evn -i -f boilerplate $folder/ide/sublime_build.log", + // "file_regex": "^\\s*File \"(...*?)\", line ([0-9]*)" + "file_regex": "^(.+\\.py):([0-9]+): " + } + // { + // "name": "test filter", + // "shell_cmd": "cd $folder/evn; evn --filter boilerplate ~/rfd/sublime_build.log 2>&1 | tee $folder/ide/sublime_build.log", + // "file_regex": "^\\s*File \"(...*?)\", line ([0-9]*)" + // "file_regex": "^(.+\\.py):([0-9]+): " + // } + // { + // "name": "install and pytest file", + // "shell_cmd": "cd $folder; (\\$PIP uninstall -y evn && \\$PIP install .[dev] && \\$PYTHON -m pytest $file) 2>&1 | tee $folder/ide/sublime_build.log", + // // "file_regex": "^\\s*File \"(...*?)\", line ([0-9]*)" + // "file_regex": "^\\s*(.+?):(\\d+):(\\d+): error:" + // }, + // { + // "name": "nox", + // "shell_cmd": "cd $folder; nox 2>&1 | tee $folder/ide/sublime_build.log", + // "file_regex": "^\\s*File \"(...*?)\", line ([0-9]*)" + // }, + // { + // "name": "nox files", + // "shell_cmd": "cd $folder; uv pip install pip pytest pytest-xdist numpy; uv pip install --no-index --find-links=wheelhouse evn]; uv run pytest $file 2>&1 | tee $folder/ide/sublime_build.log", + // "file_regex": "^\\s*File \"(...*?)\", line ([0-9]*)" + // }, + // { + // "name": "tmp build", + // "shell_cmd": "(cd /tmp; mamba activate wcpp; pip uninstall -y evn; pip install ~/evn; python -mpytest --pyargs evn) 2>&1 | tee $folder/ide/sublime_build.log", + // "file_regex": "^\\s*File \"(...*?)\", line ([0-9]*)" + // }, + // { + // "name": "uv run pytest", + // "shell_cmd": "mkdir -p /tmp/evn; cd /tmp/evn; uv pip install --upgrade /home/sheffler/evn; uv run pytest /home/sheffler/evn 2>&1 | tee $folder/ide/sublime_build.log", + // "file_regex": "^\\s*File \"(...*?)\", line ([0-9]*)" + // }, + // { + // "name": "pytest ", + // "shell_cmd": "cd $folder; \\$PYTHON -m pytest evn/tests/align/test_line_align.py 2>&1 | tee $folder/ide/sublime_build.log", + // "file_regex": "^\\s*File \"(...*?)\", line ([0-9]*)" + // }, + // { + // "name": "evn runtestfile", + // "shell_cmd": "cd $folder; PYTHONPATH=. evn.ools.run_tests_for_file evn $file 2>&1 | tee $folder/ide/sublime_build.log", + // "file_regex": "^\\s*File \"(...*?)\", line ([0-9]*)" + // } + ] +} diff --git a/lib/evn/ide/validate_sublime_project.py b/lib/evn/ide/validate_sublime_project.py new file mode 100644 index 00000000..f100ef58 --- /dev/null +++ b/lib/evn/ide/validate_sublime_project.py @@ -0,0 +1,35 @@ +from pathlib import Path +from jsonschema import validate +import json5 as json +schema = { + "type": "object", + "properties": { + "folders": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "name": { + "type": "string" + }, + }, + "required": ["path"] + } + }, + "settings": { + "type": "object" + }, + }, + "required": ["folders"] +} +def main(): + path = Path(__file__).parent / "evn.sublime-project" + project_json = json.load(open(path)) + # import evn.tree.tree_format + # evn.show(project_json, format='tree') + validate(instance=project_json, schema=schema) +if __name__ == '__main__': + main() diff --git a/lib/evn/legacy/__main__.py b/lib/evn/legacy/__main__.py new file mode 100644 index 00000000..a4354112 --- /dev/null +++ b/lib/evn/legacy/__main__.py @@ -0,0 +1,43 @@ +import argparse +import sys +import evn as evn + + +def get_args(sysargv): + """get command line arguments""" + parser = argparse.ArgumentParser() + parser.add_argument('input', + type=str, + nargs='+', + default='', + help='use - for stdin') + parser.add_argument('-f', + '--filter', + default='boilerplate', + choices=['', 'boilerplate']) + parser.add_argument('-i', '--inplace', action='store_true') + args = parser.parse_args(sysargv[1:]) + return args + + +def main(): + """Main function to execute the evn module.""" + args = get_args(sys.argv) + for input_file in args.input: + if input_file == '-': + text = sys.stdin.read() + args.inplace = False + else: + with open(input_file, 'r') as inp: + text = inp.read() + if args.filter: + output = evn.filter_python_output(text, preset=args.filter) + else: + output = evn.format_buffer(text) + ctx = open(input_file, 'w') if args.inplace else evn.just_stdout() + with ctx as out: + out.write(output) + + +if __name__ == '__main__': + main() diff --git a/lib/evn/noxfile.py b/lib/evn/noxfile.py new file mode 100644 index 00000000..50bbaddb --- /dev/null +++ b/lib/evn/noxfile.py @@ -0,0 +1,72 @@ +import sys +import tomli +import json5 as json +import glob +import nox +import os +from packaging.tags import sys_tags + +nox.options.sessions = ['test_matrix'] +# nox.options.sessions = ['test_matrix', 'build'] +sesh = dict(python=['3.10', '3.11', '3.12', '3.13'], venv_backend='uv') + + +@nox.session(**sesh) +def test_matrix(session): + nprocs = min(8, os.cpu_count() or 1) + session.install('packaging') + if session.posargs and (session.python) != session.posargs[0]: + session.skip(f"Skipping {session.python} because it's not in posargs {session.posargs}") + # session.install(*'.[dev]'.split()) + # session.run('doit test') + with open('pyproject.toml', 'rb') as f: + conf = tomli.load(f) + deps = conf['project']['dependencies'] + deps += conf['project']['optional-dependencies']['dev'] + session.install(*deps) + print(deps) + whl = select_wheel(session) + print(f'Installing {whl}') + session.install(whl) + session.run(*'mkdir -p tmp; cd tmp'.split()) + session.run(*'pytest --doctest-modules --ignore evn/tests/_prelude/test_chrono.py --ignore env/tests/tool/test_filter_python_output.py --ignore evn/format --ignore evn/tests/format --pyargs evn'.split()) + + +def get_supported_tags_session(session): + result = session.run( + 'python', + '-c', + ( + 'from packaging.tags import sys_tags; import json5 as json;' + 'print(json.dumps([str(tag) for tag in sys_tags()]))' + ), + silent=True, + ) + result = json.loads(result) + return result + + +def get_supported_tags(session=None): + if session: + return get_supported_tags_session(session) + return {(tag.interpreter, tag.abi, tag.platform) for tag in sys_tags()} + + +def parse_wheel_tags(filename): + parts = filename.split('-') + if len(parts) < 5: + return None + tag = '-'.join([parts[2], parts[3], parts[4].split('.')[0]]) + return tag + + +def select_wheel(session): + supported = get_supported_tags(session) + wheels = glob.glob('wheelhouse/*.whl') + picks = [] + for wheel in wheels: + tags = parse_wheel_tags(wheel) + if tags and tags in supported: + picks.append(wheel) + assert picks + return picks[0] diff --git a/lib/evn/pyproject.toml b/lib/evn/pyproject.toml new file mode 100644 index 00000000..9e31b691 --- /dev/null +++ b/lib/evn/pyproject.toml @@ -0,0 +1,97 @@ +[build-system] +requires = ['scikit-build-core', 'pybind11', 'cibuildwheel'] +build-backend = 'scikit_build_core.build' + +[project] +name = 'evn' +version = '0.7.3' +# dynamic = ["version"] +# version_variable = "evn/__init__.py:__version__" +requires-python = '>=3.9' +dependencies = [ + 'assertpy', + 'click>=8.0', + 'icecream', + 'json5', + 'ninja_import', + 'more_itertools', + 'multipledispatch', + 'PrettyPrintTree', + 'pyyaml', + 'rapidfuzz', + 'rich', + 'ruff', + 'tomli', + 'typing_extensions', + 'wrapt', +] + +[project.optional-dependencies] +dev = [ + 'nox', + 'tomlkit', + 'doit>=0.36.0', + 'hypothesis', + 'jsonschema', + 'pytest', + 'pytest-xdist', + 'pytest-benchmark', + 'pytest-cov', + 'pytest-sugar', +] + +[project.scripts] +evn = 'evn.__main__:main' + +[tool.pytest.ini_options] +minversion = '8' +addopts = '--doctest-modules' +pythonpath = ['.'] +testpaths = ['evn'] +markers = [ + 'slow: marks tests as slow to run', + 'ci: tests that should only run in ci', + 'noci: tests that should not run in ci', +] + + +[tool.ruff] +# include = ['evn'] +exclude = ["dodo.py", 'noxfile.py'] +lint.ignore = [ + 'E731', # [ ] Do not assign a `lambda` expression, use a `def` + 'E402', # [ ] Module level import not at top of file + 'E701', # [ ] Multiple statements on one line (colon) + # 'F403', # [ ] `from Attention_module import *` used; unable to detect undefined names + # 'F405', + # 'F821', + # 'F841', +] +lint.dummy-variable-rgx = '^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?)|ic)$' +line-length = 113 +target-version = 'py312' +format.quote-style = 'single' +format.indent-style = 'space' +format.docstring-code-format = true +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["F403", "F401", "F405"] +"**/test_*.py" = ["E501", "F841", "F403", 'F401', "F405", "F821", "F841", "F811"] + +[tool.pyright] +exclude = [ + "build", + "dist", + "**/__pycache__", + "evn/tests", +] +ignore = ["*.pyi"] +include = ["evn"] +venvPath = '/home/sheffler/sw/MambaForge/envs' +venv = 'evn' +# defineConstant = { DEBUG = true } +typeCheckingMode = 'standard' +reportMissingImports = true +reportMissingTypeStubs = false +reportUnusedExpression = false +pythonPlatform = 'Linux' +pythonVersion = '3.13' diff --git a/lib/evn/pyrightconfig.json b/lib/evn/pyrightconfig.json new file mode 100644 index 00000000..73103989 --- /dev/null +++ b/lib/evn/pyrightconfig.json @@ -0,0 +1,14 @@ +{ + "venvPath": "/home/sheffler/sw/MambaForge/envs", + "venv": "rfdsym312", + // "venvPath": "/home/sheffler/rfd/lib/evn", + // "venv": ".venv", + "defineConstant": { "DEBUG": true }, + // "typeCheckingMode": "enhanced", + "typeCheckingMode": "standard", + "reportMissingImports": true, + "reportMissingTypeStubs": false, + "reportUnusedExpression": false, + "pythonPlatform": "Linux", + "pythonVersion": "3.13" +} diff --git a/lib/evn/release.config.js b/lib/evn/release.config.js new file mode 100644 index 00000000..aa799b6c --- /dev/null +++ b/lib/evn/release.config.js @@ -0,0 +1,10 @@ +module.exports = { + branches : [ "main" ], + plugins : + [ + "@semantic-release/commit-analyzer", + "@semantic-release/release-notes-generator", + "@semantic-release/changelog", "@semantic-release/git", + "@semantic-release/github" + ] +}; diff --git a/lib/evn/uv.lock b/lib/evn/uv.lock new file mode 100644 index 00000000..5ff7820a --- /dev/null +++ b/lib/evn/uv.lock @@ -0,0 +1,1128 @@ +version = 1 +revision = 1 +requires-python = ">=3.9" + +[[package]] +name = "argcomplete" +version = "3.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/16/0f/861e168fc813c56a78b35f3c30d91c6757d1fd185af1110f1aec784b35d0/argcomplete-3.6.2.tar.gz", hash = "sha256:d0519b1bc867f5f4f4713c41ad0aba73a4a5f007449716b16f385f2166dc6adf", size = 73403 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/da/e42d7a9d8dd33fa775f467e4028a47936da2f01e4b0e561f9ba0d74cb0ca/argcomplete-3.6.2-py3-none-any.whl", hash = "sha256:65b3133a29ad53fb42c48cf5114752c7ab66c1c38544fdf6460f450c09b42591", size = 43708 }, +] + +[[package]] +name = "assertpy" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/39/720b5d4463612a40a166d00999cbb715fce3edaf08a9a7588ba5985699ec/assertpy-1.1.tar.gz", hash = "sha256:acc64329934ad71a3221de185517a43af33e373bb44dc05b5a9b174394ef4833", size = 25421 } + +[[package]] +name = "asttokens" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/e7/82da0a03e7ba5141f05cce0d302e6eed121ae055e0456ca228bf693984bc/asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7", size = 61978 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918 }, +] + +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815 }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, +] + +[[package]] +name = "cloudpickle" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/39/069100b84d7418bc358d81669d5748efb14b9cceacd2f9c75f550424132f/cloudpickle-3.1.1.tar.gz", hash = "sha256:b216fa8ae4019d5482a8ac3c95d8f6346115d8835911fd4aefd1a445e4242c64", size = 22113 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/e8/64c37fadfc2816a7701fa8a6ed8d87327c7d54eacfbfb6edab14a2f2be75/cloudpickle-3.1.1-py3-none-any.whl", hash = "sha256:c8c5a44295039331ee9dad40ba100a9c7297b6f988e50e87ccdf3765a668350e", size = 20992 }, +] + +[[package]] +name = "cmd2" +version = "2.5.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gnureadline", marker = "sys_platform == 'darwin'" }, + { name = "pyperclip" }, + { name = "pyreadline3", marker = "sys_platform == 'win32'" }, + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/2a/018fe937e25e1db0cafeb358c117644a58cdba24f5bbee69c003faf0b454/cmd2-2.5.11.tar.gz", hash = "sha256:30a0d385021fbe4a4116672845e5695bbe56eb682f9096066776394f954a7429", size = 883350 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/79/1c9e717306a702e232cac9e60ef1cee6482f1f43fc7460a44848b4dcbf0a/cmd2-2.5.11-py3-none-any.whl", hash = "sha256:cbc79525e423dc2085ef7922cdc5586d1fedaecb768cdfb05e5482ee0740b755", size = 152751 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "colorlog" +version = "6.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/7a/359f4d5df2353f26172b3cc39ea32daa39af8de522205f512f458923e677/colorlog-6.9.0.tar.gz", hash = "sha256:bfba54a1b93b94f54e1f4fe48395725a3d92fd2a4af702f6bd70946bdc0c6ac2", size = 16624 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/51/9b208e85196941db2f0654ad0357ca6388ab3ed67efdbfc799f35d1f83aa/colorlog-6.9.0-py3-none-any.whl", hash = "sha256:5906e71acd67cb07a71e779c47c4bcb45fb8c2993eebe9e5adcd6a6f1b283eff", size = 11424 }, +] + +[[package]] +name = "coverage" +version = "7.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/4f/2251e65033ed2ce1e68f00f91a0294e0f80c80ae8c3ebbe2f12828c4cd53/coverage-7.8.0.tar.gz", hash = "sha256:7a3d62b3b03b4b6fd41a085f3574874cf946cb4604d2b4d3e8dca8cd570ca501", size = 811872 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/01/1c5e6ee4ebaaa5e079db933a9a45f61172048c7efa06648445821a201084/coverage-7.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2931f66991175369859b5fd58529cd4b73582461877ecfd859b6549869287ffe", size = 211379 }, + { url = "https://files.pythonhosted.org/packages/e9/16/a463389f5ff916963471f7c13585e5f38c6814607306b3cb4d6b4cf13384/coverage-7.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52a523153c568d2c0ef8826f6cc23031dc86cffb8c6aeab92c4ff776e7951b28", size = 211814 }, + { url = "https://files.pythonhosted.org/packages/b8/b1/77062b0393f54d79064dfb72d2da402657d7c569cfbc724d56ac0f9c67ed/coverage-7.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c8a5c139aae4c35cbd7cadca1df02ea8cf28a911534fc1b0456acb0b14234f3", size = 240937 }, + { url = "https://files.pythonhosted.org/packages/d7/54/c7b00a23150083c124e908c352db03bcd33375494a4beb0c6d79b35448b9/coverage-7.8.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a26c0c795c3e0b63ec7da6efded5f0bc856d7c0b24b2ac84b4d1d7bc578d676", size = 238849 }, + { url = "https://files.pythonhosted.org/packages/f7/ec/a6b7cfebd34e7b49f844788fda94713035372b5200c23088e3bbafb30970/coverage-7.8.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:821f7bcbaa84318287115d54becb1915eece6918136c6f91045bb84e2f88739d", size = 239986 }, + { url = "https://files.pythonhosted.org/packages/21/8c/c965ecef8af54e6d9b11bfbba85d4f6a319399f5f724798498387f3209eb/coverage-7.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a321c61477ff8ee705b8a5fed370b5710c56b3a52d17b983d9215861e37b642a", size = 239896 }, + { url = "https://files.pythonhosted.org/packages/40/83/070550273fb4c480efa8381735969cb403fa8fd1626d74865bfaf9e4d903/coverage-7.8.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ed2144b8a78f9d94d9515963ed273d620e07846acd5d4b0a642d4849e8d91a0c", size = 238613 }, + { url = "https://files.pythonhosted.org/packages/07/76/fbb2540495b01d996d38e9f8897b861afed356be01160ab4e25471f4fed1/coverage-7.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:042e7841a26498fff7a37d6fda770d17519982f5b7d8bf5278d140b67b61095f", size = 238909 }, + { url = "https://files.pythonhosted.org/packages/a3/7e/76d604db640b7d4a86e5dd730b73e96e12a8185f22b5d0799025121f4dcb/coverage-7.8.0-cp310-cp310-win32.whl", hash = "sha256:f9983d01d7705b2d1f7a95e10bbe4091fabc03a46881a256c2787637b087003f", size = 213948 }, + { url = "https://files.pythonhosted.org/packages/5c/a7/f8ce4aafb4a12ab475b56c76a71a40f427740cf496c14e943ade72e25023/coverage-7.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:5a570cd9bd20b85d1a0d7b009aaf6c110b52b5755c17be6962f8ccd65d1dbd23", size = 214844 }, + { url = "https://files.pythonhosted.org/packages/2b/77/074d201adb8383addae5784cb8e2dac60bb62bfdf28b2b10f3a3af2fda47/coverage-7.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7ac22a0bb2c7c49f441f7a6d46c9c80d96e56f5a8bc6972529ed43c8b694e27", size = 211493 }, + { url = "https://files.pythonhosted.org/packages/a9/89/7a8efe585750fe59b48d09f871f0e0c028a7b10722b2172dfe021fa2fdd4/coverage-7.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf13d564d310c156d1c8e53877baf2993fb3073b2fc9f69790ca6a732eb4bfea", size = 211921 }, + { url = "https://files.pythonhosted.org/packages/e9/ef/96a90c31d08a3f40c49dbe897df4f1fd51fb6583821a1a1c5ee30cc8f680/coverage-7.8.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5761c70c017c1b0d21b0815a920ffb94a670c8d5d409d9b38857874c21f70d7", size = 244556 }, + { url = "https://files.pythonhosted.org/packages/89/97/dcd5c2ce72cee9d7b0ee8c89162c24972fb987a111b92d1a3d1d19100c61/coverage-7.8.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5ff52d790c7e1628241ffbcaeb33e07d14b007b6eb00a19320c7b8a7024c040", size = 242245 }, + { url = "https://files.pythonhosted.org/packages/b2/7b/b63cbb44096141ed435843bbb251558c8e05cc835c8da31ca6ffb26d44c0/coverage-7.8.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d39fc4817fd67b3915256af5dda75fd4ee10621a3d484524487e33416c6f3543", size = 244032 }, + { url = "https://files.pythonhosted.org/packages/97/e3/7fa8c2c00a1ef530c2a42fa5df25a6971391f92739d83d67a4ee6dcf7a02/coverage-7.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b44674870709017e4b4036e3d0d6c17f06a0e6d4436422e0ad29b882c40697d2", size = 243679 }, + { url = "https://files.pythonhosted.org/packages/4f/b3/e0a59d8df9150c8a0c0841d55d6568f0a9195692136c44f3d21f1842c8f6/coverage-7.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8f99eb72bf27cbb167b636eb1726f590c00e1ad375002230607a844d9e9a2318", size = 241852 }, + { url = "https://files.pythonhosted.org/packages/9b/82/db347ccd57bcef150c173df2ade97976a8367a3be7160e303e43dd0c795f/coverage-7.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b571bf5341ba8c6bc02e0baeaf3b061ab993bf372d982ae509807e7f112554e9", size = 242389 }, + { url = "https://files.pythonhosted.org/packages/21/f6/3f7d7879ceb03923195d9ff294456241ed05815281f5254bc16ef71d6a20/coverage-7.8.0-cp311-cp311-win32.whl", hash = "sha256:e75a2ad7b647fd8046d58c3132d7eaf31b12d8a53c0e4b21fa9c4d23d6ee6d3c", size = 213997 }, + { url = "https://files.pythonhosted.org/packages/28/87/021189643e18ecf045dbe1e2071b2747901f229df302de01c998eeadf146/coverage-7.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:3043ba1c88b2139126fc72cb48574b90e2e0546d4c78b5299317f61b7f718b78", size = 214911 }, + { url = "https://files.pythonhosted.org/packages/aa/12/4792669473297f7973518bec373a955e267deb4339286f882439b8535b39/coverage-7.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bbb5cc845a0292e0c520656d19d7ce40e18d0e19b22cb3e0409135a575bf79fc", size = 211684 }, + { url = "https://files.pythonhosted.org/packages/be/e1/2a4ec273894000ebedd789e8f2fc3813fcaf486074f87fd1c5b2cb1c0a2b/coverage-7.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4dfd9a93db9e78666d178d4f08a5408aa3f2474ad4d0e0378ed5f2ef71640cb6", size = 211935 }, + { url = "https://files.pythonhosted.org/packages/f8/3a/7b14f6e4372786709a361729164125f6b7caf4024ce02e596c4a69bccb89/coverage-7.8.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f017a61399f13aa6d1039f75cd467be388d157cd81f1a119b9d9a68ba6f2830d", size = 245994 }, + { url = "https://files.pythonhosted.org/packages/54/80/039cc7f1f81dcbd01ea796d36d3797e60c106077e31fd1f526b85337d6a1/coverage-7.8.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0915742f4c82208ebf47a2b154a5334155ed9ef9fe6190674b8a46c2fb89cb05", size = 242885 }, + { url = "https://files.pythonhosted.org/packages/10/e0/dc8355f992b6cc2f9dcd5ef6242b62a3f73264893bc09fbb08bfcab18eb4/coverage-7.8.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a40fcf208e021eb14b0fac6bdb045c0e0cab53105f93ba0d03fd934c956143a", size = 245142 }, + { url = "https://files.pythonhosted.org/packages/43/1b/33e313b22cf50f652becb94c6e7dae25d8f02e52e44db37a82de9ac357e8/coverage-7.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a1f406a8e0995d654b2ad87c62caf6befa767885301f3b8f6f73e6f3c31ec3a6", size = 244906 }, + { url = "https://files.pythonhosted.org/packages/05/08/c0a8048e942e7f918764ccc99503e2bccffba1c42568693ce6955860365e/coverage-7.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:77af0f6447a582fdc7de5e06fa3757a3ef87769fbb0fdbdeba78c23049140a47", size = 243124 }, + { url = "https://files.pythonhosted.org/packages/5b/62/ea625b30623083c2aad645c9a6288ad9fc83d570f9adb913a2abdba562dd/coverage-7.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f2d32f95922927186c6dbc8bc60df0d186b6edb828d299ab10898ef3f40052fe", size = 244317 }, + { url = "https://files.pythonhosted.org/packages/62/cb/3871f13ee1130a6c8f020e2f71d9ed269e1e2124aa3374d2180ee451cee9/coverage-7.8.0-cp312-cp312-win32.whl", hash = "sha256:769773614e676f9d8e8a0980dd7740f09a6ea386d0f383db6821df07d0f08545", size = 214170 }, + { url = "https://files.pythonhosted.org/packages/88/26/69fe1193ab0bfa1eb7a7c0149a066123611baba029ebb448500abd8143f9/coverage-7.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:e5d2b9be5b0693cf21eb4ce0ec8d211efb43966f6657807f6859aab3814f946b", size = 214969 }, + { url = "https://files.pythonhosted.org/packages/f3/21/87e9b97b568e223f3438d93072479c2f36cc9b3f6b9f7094b9d50232acc0/coverage-7.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ac46d0c2dd5820ce93943a501ac5f6548ea81594777ca585bf002aa8854cacd", size = 211708 }, + { url = "https://files.pythonhosted.org/packages/75/be/882d08b28a0d19c9c4c2e8a1c6ebe1f79c9c839eb46d4fca3bd3b34562b9/coverage-7.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:771eb7587a0563ca5bb6f622b9ed7f9d07bd08900f7589b4febff05f469bea00", size = 211981 }, + { url = "https://files.pythonhosted.org/packages/7a/1d/ce99612ebd58082fbe3f8c66f6d8d5694976c76a0d474503fa70633ec77f/coverage-7.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42421e04069fb2cbcbca5a696c4050b84a43b05392679d4068acbe65449b5c64", size = 245495 }, + { url = "https://files.pythonhosted.org/packages/dc/8d/6115abe97df98db6b2bd76aae395fcc941d039a7acd25f741312ced9a78f/coverage-7.8.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:554fec1199d93ab30adaa751db68acec2b41c5602ac944bb19187cb9a41a8067", size = 242538 }, + { url = "https://files.pythonhosted.org/packages/cb/74/2f8cc196643b15bc096d60e073691dadb3dca48418f08bc78dd6e899383e/coverage-7.8.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aaeb00761f985007b38cf463b1d160a14a22c34eb3f6a39d9ad6fc27cb73008", size = 244561 }, + { url = "https://files.pythonhosted.org/packages/22/70/c10c77cd77970ac965734fe3419f2c98665f6e982744a9bfb0e749d298f4/coverage-7.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:581a40c7b94921fffd6457ffe532259813fc68eb2bdda60fa8cc343414ce3733", size = 244633 }, + { url = "https://files.pythonhosted.org/packages/38/5a/4f7569d946a07c952688debee18c2bb9ab24f88027e3d71fd25dbc2f9dca/coverage-7.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f319bae0321bc838e205bf9e5bc28f0a3165f30c203b610f17ab5552cff90323", size = 242712 }, + { url = "https://files.pythonhosted.org/packages/bb/a1/03a43b33f50475a632a91ea8c127f7e35e53786dbe6781c25f19fd5a65f8/coverage-7.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04bfec25a8ef1c5f41f5e7e5c842f6b615599ca8ba8391ec33a9290d9d2db3a3", size = 244000 }, + { url = "https://files.pythonhosted.org/packages/6a/89/ab6c43b1788a3128e4d1b7b54214548dcad75a621f9d277b14d16a80d8a1/coverage-7.8.0-cp313-cp313-win32.whl", hash = "sha256:dd19608788b50eed889e13a5d71d832edc34fc9dfce606f66e8f9f917eef910d", size = 214195 }, + { url = "https://files.pythonhosted.org/packages/12/12/6bf5f9a8b063d116bac536a7fb594fc35cb04981654cccb4bbfea5dcdfa0/coverage-7.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:a9abbccd778d98e9c7e85038e35e91e67f5b520776781d9a1e2ee9d400869487", size = 214998 }, + { url = "https://files.pythonhosted.org/packages/2a/e6/1e9df74ef7a1c983a9c7443dac8aac37a46f1939ae3499424622e72a6f78/coverage-7.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:18c5ae6d061ad5b3e7eef4363fb27a0576012a7447af48be6c75b88494c6cf25", size = 212541 }, + { url = "https://files.pythonhosted.org/packages/04/51/c32174edb7ee49744e2e81c4b1414ac9df3dacfcb5b5f273b7f285ad43f6/coverage-7.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:95aa6ae391a22bbbce1b77ddac846c98c5473de0372ba5c463480043a07bff42", size = 212767 }, + { url = "https://files.pythonhosted.org/packages/e9/8f/f454cbdb5212f13f29d4a7983db69169f1937e869a5142bce983ded52162/coverage-7.8.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e013b07ba1c748dacc2a80e69a46286ff145935f260eb8c72df7185bf048f502", size = 256997 }, + { url = "https://files.pythonhosted.org/packages/e6/74/2bf9e78b321216d6ee90a81e5c22f912fc428442c830c4077b4a071db66f/coverage-7.8.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d766a4f0e5aa1ba056ec3496243150698dc0481902e2b8559314368717be82b1", size = 252708 }, + { url = "https://files.pythonhosted.org/packages/92/4d/50d7eb1e9a6062bee6e2f92e78b0998848a972e9afad349b6cdde6fa9e32/coverage-7.8.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad80e6b4a0c3cb6f10f29ae4c60e991f424e6b14219d46f1e7d442b938ee68a4", size = 255046 }, + { url = "https://files.pythonhosted.org/packages/40/9e/71fb4e7402a07c4198ab44fc564d09d7d0ffca46a9fb7b0a7b929e7641bd/coverage-7.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b87eb6fc9e1bb8f98892a2458781348fa37e6925f35bb6ceb9d4afd54ba36c73", size = 256139 }, + { url = "https://files.pythonhosted.org/packages/49/1a/78d37f7a42b5beff027e807c2843185961fdae7fe23aad5a4837c93f9d25/coverage-7.8.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d1ba00ae33be84066cfbe7361d4e04dec78445b2b88bdb734d0d1cbab916025a", size = 254307 }, + { url = "https://files.pythonhosted.org/packages/58/e9/8fb8e0ff6bef5e170ee19d59ca694f9001b2ec085dc99b4f65c128bb3f9a/coverage-7.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f3c38e4e5ccbdc9198aecc766cedbb134b2d89bf64533973678dfcf07effd883", size = 255116 }, + { url = "https://files.pythonhosted.org/packages/56/b0/d968ecdbe6fe0a863de7169bbe9e8a476868959f3af24981f6a10d2b6924/coverage-7.8.0-cp313-cp313t-win32.whl", hash = "sha256:379fe315e206b14e21db5240f89dc0774bdd3e25c3c58c2c733c99eca96f1ada", size = 214909 }, + { url = "https://files.pythonhosted.org/packages/87/e9/d6b7ef9fecf42dfb418d93544af47c940aa83056c49e6021a564aafbc91f/coverage-7.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2e4b6b87bb0c846a9315e3ab4be2d52fac905100565f4b92f02c445c8799e257", size = 216068 }, + { url = "https://files.pythonhosted.org/packages/60/0c/5da94be095239814bf2730a28cffbc48d6df4304e044f80d39e1ae581997/coverage-7.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa260de59dfb143af06dcf30c2be0b200bed2a73737a8a59248fcb9fa601ef0f", size = 211377 }, + { url = "https://files.pythonhosted.org/packages/d5/cb/b9e93ebf193a0bb89dbcd4f73d7b0e6ecb7c1b6c016671950e25f041835e/coverage-7.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:96121edfa4c2dfdda409877ea8608dd01de816a4dc4a0523356067b305e4e17a", size = 211803 }, + { url = "https://files.pythonhosted.org/packages/78/1a/cdbfe9e1bb14d3afcaf6bb6e1b9ba76c72666e329cd06865bbd241efd652/coverage-7.8.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8af63b9afa1031c0ef05b217faa598f3069148eeee6bb24b79da9012423b82", size = 240561 }, + { url = "https://files.pythonhosted.org/packages/59/04/57f1223f26ac018d7ce791bfa65b0c29282de3e041c1cd3ed430cfeac5a5/coverage-7.8.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89b1f4af0d4afe495cd4787a68e00f30f1d15939f550e869de90a86efa7e0814", size = 238488 }, + { url = "https://files.pythonhosted.org/packages/b7/b1/0f25516ae2a35e265868670384feebe64e7857d9cffeeb3887b0197e2ba2/coverage-7.8.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94ec0be97723ae72d63d3aa41961a0b9a6f5a53ff599813c324548d18e3b9e8c", size = 239589 }, + { url = "https://files.pythonhosted.org/packages/e0/a4/99d88baac0d1d5a46ceef2dd687aac08fffa8795e4c3e71b6f6c78e14482/coverage-7.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8a1d96e780bdb2d0cbb297325711701f7c0b6f89199a57f2049e90064c29f6bd", size = 239366 }, + { url = "https://files.pythonhosted.org/packages/ea/9e/1db89e135feb827a868ed15f8fc857160757f9cab140ffee21342c783ceb/coverage-7.8.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f1d8a2a57b47142b10374902777e798784abf400a004b14f1b0b9eaf1e528ba4", size = 237591 }, + { url = "https://files.pythonhosted.org/packages/1b/6d/ac4d6fdfd0e201bc82d1b08adfacb1e34b40d21a22cdd62cfaf3c1828566/coverage-7.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cf60dd2696b457b710dd40bf17ad269d5f5457b96442f7f85722bdb16fa6c899", size = 238572 }, + { url = "https://files.pythonhosted.org/packages/25/5e/917cbe617c230f7f1745b6a13e780a3a1cd1cf328dbcd0fd8d7ec52858cd/coverage-7.8.0-cp39-cp39-win32.whl", hash = "sha256:be945402e03de47ba1872cd5236395e0f4ad635526185a930735f66710e1bd3f", size = 213966 }, + { url = "https://files.pythonhosted.org/packages/bd/93/72b434fe550135869f9ea88dd36068af19afce666db576e059e75177e813/coverage-7.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:90e7fbc6216ecaffa5a880cdc9c77b7418c1dcb166166b78dbc630d07f278cc3", size = 214852 }, + { url = "https://files.pythonhosted.org/packages/c4/f1/1da77bb4c920aa30e82fa9b6ea065da3467977c2e5e032e38e66f1c57ffd/coverage-7.8.0-pp39.pp310.pp311-none-any.whl", hash = "sha256:b8194fb8e50d556d5849753de991d390c5a1edeeba50f68e3a9253fbd8bf8ccd", size = 203443 }, + { url = "https://files.pythonhosted.org/packages/59/f1/4da7717f0063a222db253e7121bd6a56f6fb1ba439dcc36659088793347c/coverage-7.8.0-py3-none-any.whl", hash = "sha256:dbf364b4c5e7bae9250528167dfe40219b62e2d573c854d74be213e1e52069f7", size = 203435 }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "dependency-groups" +version = "1.3.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/b4/57/cd53c3e335eafbb0894449af078e2b71db47e9939ce2b45013e5a9fe89b7/dependency_groups-1.3.0.tar.gz", hash = "sha256:5b9751d5d98fbd6dfd038a560a69c8382e41afcbf7ffdbcc28a2a3f85498830f", size = 9832 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/2c/3e3afb1df3dc8a8deeb143f6ac41acbfdfae4f03a54c760871c56832a554/dependency_groups-1.3.0-py3-none-any.whl", hash = "sha256:1abf34d712deda5581e80d507512664d52b35d1c2d7caf16c85e58ca508547e0", size = 8597 }, +] + +[[package]] +name = "distlib" +version = "0.3.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 }, +] + +[[package]] +name = "doit" +version = "0.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cloudpickle" }, + { name = "importlib-metadata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/36/66b7dea1bb5688ba0d2d7bc113e9c0d57df697bd3f39ce2a139d9612aeee/doit-0.36.0.tar.gz", hash = "sha256:71d07ccc9514cb22fe59d98999577665eaab57e16f644d04336ae0b4bae234bc", size = 1448096 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/83/a2960d2c975836daa629a73995134fd86520c101412578c57da3d2aa71ee/doit-0.36.0-py3-none-any.whl", hash = "sha256:ebc285f6666871b5300091c26eafdff3de968a6bd60ea35dd1e3fc6f2e32479a", size = 85937 }, +] + +[[package]] +name = "evn" +version = "0.7.2" +source = { editable = "." } +dependencies = [ + { name = "assertpy" }, + { name = "click" }, + { name = "icecream" }, + { name = "json5" }, + { name = "more-itertools" }, + { name = "multipledispatch" }, + { name = "ninja-import" }, + { name = "prettyprinttree" }, + { name = "pyyaml" }, + { name = "rapidfuzz" }, + { name = "rich" }, + { name = "ruff" }, + { name = "tomli" }, + { name = "typing-extensions" }, + { name = "wrapt" }, +] + +[package.optional-dependencies] +dev = [ + { name = "doit" }, + { name = "hypothesis" }, + { name = "jsonschema" }, + { name = "nox" }, + { name = "pytest" }, + { name = "pytest-benchmark" }, + { name = "pytest-cov" }, + { name = "pytest-sugar" }, + { name = "pytest-xdist" }, + { name = "tomlkit" }, +] + +[package.metadata] +requires-dist = [ + { name = "assertpy" }, + { name = "click", specifier = ">=8.0" }, + { name = "doit", marker = "extra == 'dev'", specifier = ">=0.36.0" }, + { name = "hypothesis", marker = "extra == 'dev'" }, + { name = "icecream" }, + { name = "json5" }, + { name = "jsonschema", marker = "extra == 'dev'" }, + { name = "more-itertools" }, + { name = "multipledispatch" }, + { name = "ninja-import" }, + { name = "nox", marker = "extra == 'dev'" }, + { name = "prettyprinttree" }, + { name = "pytest", marker = "extra == 'dev'" }, + { name = "pytest-benchmark", marker = "extra == 'dev'" }, + { name = "pytest-cov", marker = "extra == 'dev'" }, + { name = "pytest-sugar", marker = "extra == 'dev'" }, + { name = "pytest-xdist", marker = "extra == 'dev'" }, + { name = "pyyaml" }, + { name = "rapidfuzz" }, + { name = "rich" }, + { name = "ruff" }, + { name = "tomli" }, + { name = "tomlkit", marker = "extra == 'dev'" }, + { name = "typing-extensions" }, + { name = "wrapt" }, +] +provides-extras = ["dev"] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, +] + +[[package]] +name = "execnet" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/ff/b4c0dc78fbe20c3e59c0c7334de0c27eb4001a2b2017999af398bf730817/execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3", size = 166524 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", size = 40612 }, +] + +[[package]] +name = "executing" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/50/a9d80c47ff289c611ff12e63f7c5d13942c65d68125160cefd768c73e6e4/executing-2.2.0.tar.gz", hash = "sha256:5d108c028108fe2551d1a7b2e8b713341e2cb4fc0aa7dcf966fa4327a5226755", size = 978693 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/8f/c4d9bafc34ad7ad5d8dc16dd1347ee0e507a52c3adb6bfa8887e1c6a26ba/executing-2.2.0-py2.py3-none-any.whl", hash = "sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa", size = 26702 }, +] + +[[package]] +name = "filelock" +version = "3.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215 }, +] + +[[package]] +name = "gnureadline" +version = "8.2.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/92/20723aa239b9a8024e6f8358c789df8859ab1085a1ae106e5071727ad20f/gnureadline-8.2.13.tar.gz", hash = "sha256:c9b9e1e7ba99a80bb50c12027d6ce692574f77a65bf57bc97041cf81c0f49bd1", size = 3224991 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/4f/81ff367156444f67d16cc8d9023b4a0a3f4bd29acaf8f8e510c7872b6927/gnureadline-8.2.13-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0ca03501ce0939d7ecf9d075860d6f6ceb2f49f30331b4e96e4678ce03687bab", size = 160572 }, + { url = "https://files.pythonhosted.org/packages/48/06/0297bdde1e4a842ec786b9b7c9fca53116bac8fe2aed9769000f652fd1e3/gnureadline-8.2.13-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c28e33bfc56d4204693f213abeab927f65c505ce91f668a039720bc7c46b0353", size = 162590 }, + { url = "https://files.pythonhosted.org/packages/ff/ad/a6c59fcdbc8173bc538dad042696b732d39bc8de95adb07664b124c07942/gnureadline-8.2.13-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:561a60b12f74ea7234036cc4fe558f3b46023be0dac5ed73541ece58cba2f88a", size = 160575 }, + { url = "https://files.pythonhosted.org/packages/f7/9b/464929f1e81ba4ea4fafb033c38eefedc533b503d777e91ffa12751ad34e/gnureadline-8.2.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:daa405028b9fe92bfbb93624e13e0674a242a1c5434b70ef61a04294502fdb65", size = 162528 }, + { url = "https://files.pythonhosted.org/packages/68/bd/df8fd060e43efd3dbdd3b210bf558ce3ef854843cd093f910f4115ebe2e9/gnureadline-8.2.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9c152a82613fa012ab4331bb9a0ffddb415e37561d376b910bf9e7d535607faf", size = 160504 }, + { url = "https://files.pythonhosted.org/packages/97/ee/322e5340c8cdfa40e71bd0485a82404ad4cf9aed2260cca090f3c1a3a032/gnureadline-8.2.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:85e362d2d0e85e45f0affae7bbfaf998b00167c55a78d31ee0f214de9ff429d2", size = 162380 }, + { url = "https://files.pythonhosted.org/packages/a1/b0/4a3c55a05b4c1c240fd6dc204ff597432008c4649ce500688a2441d27cf4/gnureadline-8.2.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d3e33d2e0dd694d623a2ca1fae6990b52f1d25955504b7293a9350fb9912940", size = 160646 }, + { url = "https://files.pythonhosted.org/packages/3a/41/8821db40f2b0dd9cc935d6838bc63776fb5bfb1df092f8d4698ec29ada6a/gnureadline-8.2.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6c550d08c4d2882a83293a724b14a262ee5078fd4fa7acdc78aa59cab26ae343", size = 162630 }, + { url = "https://files.pythonhosted.org/packages/03/f1/be0297498c20df97525ddd1bb48bc3a3237321f323e9c24fe45ff576decb/gnureadline-8.2.13-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dcfa601d95c00aa670ec5e4bf791caf6ba0bcf266de940fb54d44c278bd302fe", size = 160569 }, + { url = "https://files.pythonhosted.org/packages/87/7d/9834bc32cf6531c2ec21998d0b0631ddc5f69c31bf2358f9489e27e06dec/gnureadline-8.2.13-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c7b8d3f2a2c9b7e6feaf1f20bdb6ebb8210e207b8c5360ffe407a47efeeb3fb8", size = 162587 }, +] + +[[package]] +name = "hypothesis" +version = "6.130.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/ae/fa9f4dff4ead766ffd280e710aa06083ee185938526535b808e366871fbc/hypothesis-6.130.8.tar.gz", hash = "sha256:1b719943011375b1d66f01f858181c3c3ae49324cd7d7b359229cbf95a7785ce", size = 427941 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/74/ff7d6642dd21288947fb9f6e5a767c415326b0a02bc710842f191dab9768/hypothesis-6.130.8-py3-none-any.whl", hash = "sha256:8ddafde382fb96163c2a51c5a7c540d2642b2802a596347c13ac8fbc9de50d70", size = 492487 }, +] + +[[package]] +name = "icecream" +version = "2.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "colorama" }, + { name = "executing" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/5e/9f41831f032b9ce456c919c4989952562fcc2b0eb8c038080c24ce20d6cd/icecream-2.1.4.tar.gz", hash = "sha256:58755e58397d5350a76f25976dee7b607f5febb3c6e1cddfe6b1951896e91573", size = 15872 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/1d/43ef7a6875190e6745ffcd1b12c7aaa7efed082897401e311ee1cd75c8b2/icecream-2.1.4-py3-none-any.whl", hash = "sha256:7bb715f69102cae871b3a361c3b656536db02cfcadac9664c673581cac4df4fd", size = 14782 }, +] + +[[package]] +name = "importlib-metadata" +version = "8.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/08/c1395a292bb23fd03bdf572a1357c5a733d3eecbab877641ceacab23db6e/importlib_metadata-8.6.1.tar.gz", hash = "sha256:310b41d755445d74569f993ccfc22838295d9fe005425094fad953d7f15c8580", size = 55767 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/9d/0fb148dc4d6fa4a7dd1d8378168d9b4cd8d4560a6fbf6f0121c5fc34eb68/importlib_metadata-8.6.1-py3-none-any.whl", hash = "sha256:02a89390c1e15fdfdc0d7c6b25cb3e62650d0494005c97d6f148bf5b9787525e", size = 26971 }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, +] + +[[package]] +name = "json5" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/be/c6c745ec4c4539b25a278b70e29793f10382947df0d9efba2fa09120895d/json5-0.12.0.tar.gz", hash = "sha256:0b4b6ff56801a1c7dc817b0241bca4ce474a0e6a163bfef3fc594d3fd263ff3a", size = 51907 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/9f/3500910d5a98549e3098807493851eeef2b89cdd3032227558a104dfe926/json5-0.12.0-py3-none-any.whl", hash = "sha256:6d37aa6c08b0609f16e1ec5ff94697e2cbbfbad5ac112afa05794da9ab7810db", size = 36079 }, +] + +[[package]] +name = "jsonschema" +version = "4.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4", size = 325778 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/4a/4f9dbeb84e8850557c02365a0eee0649abe5eb1d84af92a25731c6c0f922/jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566", size = 88462 }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2024.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/db/58f950c996c793472e336ff3655b13fbcf1e3b359dcf52dcf3ed3b52c352/jsonschema_specifications-2024.10.1.tar.gz", hash = "sha256:0f38b83639958ce1152d02a7f062902c41c8fd20d558b0c34344292d417ae272", size = 15561 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/0f/8910b19ac0670a0f80ce1008e5e751c4a57e14d2c4c13a482aa6079fa9d6/jsonschema_specifications-2024.10.1-py3-none-any.whl", hash = "sha256:a09a0680616357d9a0ecf05c12ad234479f549239d0f5b55f3deea67475da9bf", size = 18459 }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "more-itertools" +version = "10.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/3b/7fa1fe835e2e93fd6d7b52b2f95ae810cf5ba133e1845f726f5a992d62c2/more-itertools-10.6.0.tar.gz", hash = "sha256:2cd7fad1009c31cc9fb6a035108509e6547547a7a738374f10bd49a09eb3ee3b", size = 125009 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/62/0fe302c6d1be1c777cab0616e6302478251dfbf9055ad426f5d0def75c89/more_itertools-10.6.0-py3-none-any.whl", hash = "sha256:6eb054cb4b6db1473f6e15fcc676a08e4732548acd47c708f0e179c2c7c01e89", size = 63038 }, +] + +[[package]] +name = "multipledispatch" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/3e/a62c3b824c7dec33c4a1578bcc842e6c30300051033a4e5975ed86cc2536/multipledispatch-1.0.0.tar.gz", hash = "sha256:5c839915465c68206c3e9c473357908216c28383b425361e5d144594bf85a7e0", size = 12385 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/c0/00c9809d8b9346eb238a6bbd5f83e846a4ce4503da94a4c08cb7284c325b/multipledispatch-1.0.0-py3-none-any.whl", hash = "sha256:0c53cd8b077546da4e48869f49b13164bebafd0c2a5afceb6bb6a316e7fb46e4", size = 12818 }, +] + +[[package]] +name = "ninja-import" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/17/d3/d2335e11464e18715356b2cc26a35a15467ede1f0986fe706fccb4c0a5f8/ninja_import-1.0.1.tar.gz", hash = "sha256:6b109a9da4c49abcd6ae371dd83d9513025951b1414fb947b76364f17eac1875", size = 7188 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/d1/f311803ef57a241a4742afe59ee36e7d176a1064b6439ca25753b15f1cb3/ninja_import-1.0.1-py3-none-any.whl", hash = "sha256:2922d201912480820e95b8ffd7485e92b7ecbaf0ea197c36c8d2dc9f139d5bbe", size = 2753 }, +] + +[[package]] +name = "nox" +version = "2025.2.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argcomplete" }, + { name = "attrs" }, + { name = "colorlog" }, + { name = "dependency-groups" }, + { name = "packaging" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/22/84a2d3442cb33e6fb1af18172a15deb1eea3f970417f1f4c5fa1600143e8/nox-2025.2.9.tar.gz", hash = "sha256:d50cd4ca568bd7621c2e6cbbc4845b3b7f7697f25d5fb0190ce8f4600be79768", size = 4021103 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/ca/64e634c056cba463cac743735660a772ab78eb26ec9759e88de735f2cd27/nox-2025.2.9-py3-none-any.whl", hash = "sha256:7d1e92d1918c6980d70aee9cf1c1d19d16faa71c4afe338fffd39e8a460e2067", size = 71315 }, +] + +[[package]] +name = "packaging" +version = "24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, +] + +[[package]] +name = "platformdirs" +version = "4.3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/2d/7d512a3913d60623e7eb945c6d1b4f0bddf1d0b7ada5225274c87e5b53d1/platformdirs-4.3.7.tar.gz", hash = "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351", size = 21291 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/45/59578566b3275b8fd9157885918fcd0c4d74162928a5310926887b856a51/platformdirs-4.3.7-py3-none-any.whl", hash = "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94", size = 18499 }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + +[[package]] +name = "prettyprinttree" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmd2" }, + { name = "colorama" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/0b/bb2004917b4f763e00dddd3915f0b26876d4bd6b24368e3cdd191ac23bc2/prettyprinttree-2.0.1.tar.gz", hash = "sha256:c31f9966bfe312feff59aab9022cbceb68de5bf60e13be32c1a8183a5fd45272", size = 13077 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/24/fa17e12f5f268395f34962e41cc12aec045d5841c9e2c474417ce2811d93/PrettyPrintTree-2.0.1-py3-none-any.whl", hash = "sha256:8b0e92b064731ffc38658a115b3834665a02728f51c5cbabaee32c1ca6b3d306", size = 14941 }, +] + +[[package]] +name = "py-cpuinfo" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/a8/d832f7293ebb21690860d2e01d8115e5ff6f2ae8bbdc953f0eb0fa4bd2c7/py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690", size = 104716 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335 }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, +] + +[[package]] +name = "pyperclip" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/23/2f0a3efc4d6a32f3b63cdff36cd398d9701d26cda58e3ab97ac79fb5e60d/pyperclip-1.9.0.tar.gz", hash = "sha256:b7de0142ddc81bfc5c7507eea19da920b92252b548b96186caf94a5e2527d310", size = 20961 } + +[[package]] +name = "pyreadline3" +version = "3.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7", size = 99839 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178 }, +] + +[[package]] +name = "pytest" +version = "8.3.5" +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 = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, +] + +[[package]] +name = "pytest-benchmark" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "py-cpuinfo" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/d0/a8bd08d641b393db3be3819b03e2d9bb8760ca8479080a26a5f6e540e99c/pytest-benchmark-5.1.0.tar.gz", hash = "sha256:9ea661cdc292e8231f7cd4c10b0319e56a2118e2c09d9f50e1b3d150d2aca105", size = 337810 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/d6/b41653199ea09d5969d4e385df9bbfd9a100f28ca7e824ce7c0a016e3053/pytest_benchmark-5.1.0-py3-none-any.whl", hash = "sha256:922de2dfa3033c227c96da942d1878191afa135a29485fb942e85dff1c592c89", size = 44259 }, +] + +[[package]] +name = "pytest-cov" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/8c/039a7793f23f5cb666c834da9e944123f498ccc0753bed5fbfb2e2c11f87/pytest_cov-6.1.0.tar.gz", hash = "sha256:ec55e828c66755e5b74a21bd7cc03c303a9f928389c0563e50ba454a6dbe71db", size = 66651 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/c5/8d6ffe9fc8f7f57b3662156ae8a34f2b8e7a754c73b48e689ce43145e98c/pytest_cov-6.1.0-py3-none-any.whl", hash = "sha256:cd7e1d54981d5185ef2b8d64b50172ce97e6f357e6df5cb103e828c7f993e201", size = 23743 }, +] + +[[package]] +name = "pytest-sugar" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pytest" }, + { name = "termcolor" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/ac/5754f5edd6d508bc6493bc37d74b928f102a5fff82d9a80347e180998f08/pytest-sugar-1.0.0.tar.gz", hash = "sha256:6422e83258f5b0c04ce7c632176c7732cab5fdb909cb39cca5c9139f81276c0a", size = 14992 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/fb/889f1b69da2f13691de09a111c16c4766a433382d44aa0ecf221deded44a/pytest_sugar-1.0.0-py3-none-any.whl", hash = "sha256:70ebcd8fc5795dc457ff8b69d266a4e2e8a74ae0c3edc749381c64b5246c8dfd", size = 10171 }, +] + +[[package]] +name = "pytest-xdist" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/c4/3c310a19bc1f1e9ef50075582652673ef2bfc8cd62afef9585683821902f/pytest_xdist-3.6.1.tar.gz", hash = "sha256:ead156a4db231eec769737f57668ef58a2084a34b2e55c4a8fa20d861107300d", size = 84060 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/82/1d96bf03ee4c0fdc3c0cbe61470070e659ca78dc0086fb88b66c185e2449/pytest_xdist-3.6.1-py3-none-any.whl", hash = "sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7", size = 46108 }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199 }, + { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758 }, + { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463 }, + { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280 }, + { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239 }, + { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802 }, + { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527 }, + { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052 }, + { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774 }, + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612 }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040 }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829 }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167 }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952 }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301 }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638 }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850 }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980 }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, + { url = "https://files.pythonhosted.org/packages/65/d8/b7a1db13636d7fb7d4ff431593c510c8b8fca920ade06ca8ef20015493c5/PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d", size = 184777 }, + { url = "https://files.pythonhosted.org/packages/0a/02/6ec546cd45143fdf9840b2c6be8d875116a64076218b61d68e12548e5839/PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f", size = 172318 }, + { url = "https://files.pythonhosted.org/packages/0e/9a/8cc68be846c972bda34f6c2a93abb644fb2476f4dcc924d52175786932c9/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290", size = 720891 }, + { url = "https://files.pythonhosted.org/packages/e9/6c/6e1b7f40181bc4805e2e07f4abc10a88ce4648e7e95ff1abe4ae4014a9b2/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12", size = 722614 }, + { url = "https://files.pythonhosted.org/packages/3d/32/e7bd8535d22ea2874cef6a81021ba019474ace0d13a4819c2a4bce79bd6a/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19", size = 737360 }, + { url = "https://files.pythonhosted.org/packages/d7/12/7322c1e30b9be969670b672573d45479edef72c9a0deac3bb2868f5d7469/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e", size = 699006 }, + { url = "https://files.pythonhosted.org/packages/82/72/04fcad41ca56491995076630c3ec1e834be241664c0c09a64c9a2589b507/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725", size = 723577 }, + { url = "https://files.pythonhosted.org/packages/ed/5e/46168b1f2757f1fcd442bc3029cd8767d88a98c9c05770d8b420948743bb/PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631", size = 144593 }, + { url = "https://files.pythonhosted.org/packages/19/87/5124b1c1f2412bb95c59ec481eaf936cd32f0fe2a7b16b97b81c4c017a6a/PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", size = 162312 }, +] + +[[package]] +name = "rapidfuzz" +version = "3.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/be/8dff25a6157dfbde9867720b1282157fe7b809e085130bb89d7655c62186/rapidfuzz-3.12.2.tar.gz", hash = "sha256:b0ba1ccc22fff782e7152a3d3d0caca44ec4e32dc48ba01c560b8593965b5aa3", size = 57907839 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/47/55413211ec32f76c39a6e4f88d024d2194fd4c23abe8102cdbcf28cf80eb/rapidfuzz-3.12.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b9a75e0385a861178adf59e86d6616cbd0d5adca7228dc9eeabf6f62cf5b0b1", size = 1959750 }, + { url = "https://files.pythonhosted.org/packages/a3/7f/7350c9a68952b52f669b50528b0e53fca2a9d633457fc2a53d8a5e4b1bb2/rapidfuzz-3.12.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6906a7eb458731e3dd2495af1d0410e23a21a2a2b7ced535e6d5cd15cb69afc5", size = 1433727 }, + { url = "https://files.pythonhosted.org/packages/43/b0/148a34adc92f49582add349faaad9d8f4462a76cc30ad2f1d86bdba4fa44/rapidfuzz-3.12.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4b3334a8958b689f292d5ce8a928140ac98919b51e084f04bf0c14276e4c6ba", size = 1423353 }, + { url = "https://files.pythonhosted.org/packages/1e/8f/923ca60dcd814dba1772420c38c8b70e1fe4e6f0b5699bb3afcbe8c4bed1/rapidfuzz-3.12.2-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:85a54ce30345cff2c79cbcffa063f270ad1daedd0d0c3ff6e541d3c3ba4288cf", size = 5641810 }, + { url = "https://files.pythonhosted.org/packages/b8/91/b57ea560a8ff54e0ebb131a62740501ff7f6ffa14dc16e9853a97289614c/rapidfuzz-3.12.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acb63c5072c08058f8995404201a52fc4e1ecac105548a4d03c6c6934bda45a3", size = 1683536 }, + { url = "https://files.pythonhosted.org/packages/fd/5b/fba390383a82353b72c32b5d14f0f7669a542e7205c55f6d2ae6112369bf/rapidfuzz-3.12.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5385398d390c6571f0f2a7837e6ddde0c8b912dac096dc8c87208ce9aaaa7570", size = 1685847 }, + { url = "https://files.pythonhosted.org/packages/15/6f/5211f2e80d4e82ff793f214429cbc8a8a69ef7978fd299112ae1c5595ae8/rapidfuzz-3.12.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5032cbffa245b4beba0067f8ed17392ef2501b346ae3c1f1d14b950edf4b6115", size = 3142196 }, + { url = "https://files.pythonhosted.org/packages/92/fc/d2b4efecf81180c49da09ff97657e0517a5ea55a99b16a1adc56d2900c0b/rapidfuzz-3.12.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:195adbb384d89d6c55e2fd71e7fb262010f3196e459aa2f3f45f31dd7185fe72", size = 2521222 }, + { url = "https://files.pythonhosted.org/packages/ef/5f/a27e284d37632c808eb7cd6c49178dd52354bfb290843e253af4bd46fa61/rapidfuzz-3.12.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f43b773a4d4950606fb25568ecde5f25280daf8f97b87eb323e16ecd8177b328", size = 7867428 }, + { url = "https://files.pythonhosted.org/packages/45/68/59168dd67d319a958c525a4eeada5d62a83f83a42b79f9b55917da70f1a7/rapidfuzz-3.12.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:55a43be0e0fa956a919043c19d19bd988991d15c59f179d413fe5145ed9deb43", size = 2904044 }, + { url = "https://files.pythonhosted.org/packages/5e/40/6bbe014b94d3cef718cfe0be41eb0cecf6fda4b1cd31ba1dddf1984afa08/rapidfuzz-3.12.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:71cf1ea16acdebe9e2fb62ee7a77f8f70e877bebcbb33b34e660af2eb6d341d9", size = 3551416 }, + { url = "https://files.pythonhosted.org/packages/e4/6b/2f8e0f7de4a5ac54258be885c2e735a315c71187481a7f3d655d650c5c4c/rapidfuzz-3.12.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a3692d4ab36d44685f61326dca539975a4eda49b2a76f0a3df177d8a2c0de9d2", size = 4589777 }, + { url = "https://files.pythonhosted.org/packages/51/b3/84927233624d5e308e4739c748d2cb4ba46675efb7e021661c68b7a7b941/rapidfuzz-3.12.2-cp310-cp310-win32.whl", hash = "sha256:09227bd402caa4397ba1d6e239deea635703b042dd266a4092548661fb22b9c6", size = 1862195 }, + { url = "https://files.pythonhosted.org/packages/c9/49/e101be3e62b6524ea8b271b2e949878c8b58c31a0dc5d30b90f4f5c980e7/rapidfuzz-3.12.2-cp310-cp310-win_amd64.whl", hash = "sha256:0f05b7b95f9f87254b53fa92048367a8232c26cee7fc8665e4337268c3919def", size = 1625063 }, + { url = "https://files.pythonhosted.org/packages/ed/21/a7cbb1eacad92a840a62a22f49d98b423154da49874b787e24bb630f4689/rapidfuzz-3.12.2-cp310-cp310-win_arm64.whl", hash = "sha256:6938738e00d9eb6e04097b3f565097e20b0c398f9c58959a2bc64f7f6be3d9da", size = 870054 }, + { url = "https://files.pythonhosted.org/packages/8e/41/985b8786f7895f7a7f03f80b547e04a5b9f41187f43de386ad2f32b9f9fc/rapidfuzz-3.12.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e9c4d984621ae17404c58f8d06ed8b025e167e52c0e6a511dfec83c37e9220cd", size = 1960568 }, + { url = "https://files.pythonhosted.org/packages/90/9e/9278b4160bf86346fc5f110b5daf07af629343bfcd04a9366d355bc6104e/rapidfuzz-3.12.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f9132c55d330f0a1d34ce6730a76805323a6250d97468a1ca766a883d6a9a25", size = 1434362 }, + { url = "https://files.pythonhosted.org/packages/e7/53/fe3fb50111e203da4e82b8694c29cbf44101cdbf1efd7ef721cdf85e0aca/rapidfuzz-3.12.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:39b343b6cb4b2c3dbc8d2d4c5ee915b6088e3b144ddf8305a57eaab16cf9fc74", size = 1417839 }, + { url = "https://files.pythonhosted.org/packages/fd/c4/aa11749bc9d9c0539061d32f2c525d99e11588867d3d6e94693ccd4e0dd0/rapidfuzz-3.12.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:24081077b571ec4ee6d5d7ea0e49bc6830bf05b50c1005028523b9cd356209f3", size = 5620525 }, + { url = "https://files.pythonhosted.org/packages/5f/62/463c618a5a8a44bf6b087325353e13dbd5bc19c44cc06134d3c9eff0d04a/rapidfuzz-3.12.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c988a4fc91856260355773bf9d32bebab2083d4c6df33fafeddf4330e5ae9139", size = 1671267 }, + { url = "https://files.pythonhosted.org/packages/ca/b6/ec87c56ed0fab59f8220f5b832d5c1dd374667bee73318a01392ccc8c23d/rapidfuzz-3.12.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:780b4469ee21cf62b1b2e8ada042941fd2525e45d5fb6a6901a9798a0e41153c", size = 1683415 }, + { url = "https://files.pythonhosted.org/packages/46/08/862e65a1022cbfa2935e7b3f04cdaa73b0967ebf4762ddf509735da47d73/rapidfuzz-3.12.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:edd84b0a323885493c893bad16098c5e3b3005d7caa995ae653da07373665d97", size = 3139234 }, + { url = "https://files.pythonhosted.org/packages/ee/fa/7e8c0d1d26a4b892344c743f17e2c8482f749b616cd651590bd60994b49f/rapidfuzz-3.12.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efa22059c765b3d8778083805b199deaaf643db070f65426f87d274565ddf36a", size = 2523730 }, + { url = "https://files.pythonhosted.org/packages/8a/52/1d5b80e990c2e9998e47be118c2dbabda75daa2a5f5ff978df1ed76d7f81/rapidfuzz-3.12.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:095776b11bb45daf7c2973dd61cc472d7ea7f2eecfa454aef940b4675659b92f", size = 7880525 }, + { url = "https://files.pythonhosted.org/packages/0c/18/9c8cd7378272590a1eb0855b587f3a1fbd3492bd1612825d675320eeeb1b/rapidfuzz-3.12.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7e2574cf4aa86065600b664a1ac7b8b8499107d102ecde836aaaa403fc4f1784", size = 2905180 }, + { url = "https://files.pythonhosted.org/packages/4b/94/992de5d0fc9269a93ce62979aced028e0939d3477ea99d87fd0e22f44e8d/rapidfuzz-3.12.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d5a3425a6c50fd8fbd991d8f085ddb504791dae6ef9cc3ab299fea2cb5374bef", size = 3548613 }, + { url = "https://files.pythonhosted.org/packages/9b/25/ed3a0317f118131ee297de5936e1587e48b059e6438f4bbf92ef3bbc4927/rapidfuzz-3.12.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:97fb05e1ddb7b71a054040af588b0634214ee87cea87900d309fafc16fd272a4", size = 4583047 }, + { url = "https://files.pythonhosted.org/packages/4d/27/10585a5a62ff6ebbefa3e836a3fd8c123e2ed0bbde8cfcdd7477032cd458/rapidfuzz-3.12.2-cp311-cp311-win32.whl", hash = "sha256:b4c5a0413589aef936892fbfa94b7ff6f7dd09edf19b5a7b83896cc9d4e8c184", size = 1863208 }, + { url = "https://files.pythonhosted.org/packages/38/4c/faacecf70a4e202a02f029ec6f6e04e910d95c4ef36d7d63b83b160f7f3e/rapidfuzz-3.12.2-cp311-cp311-win_amd64.whl", hash = "sha256:58d9ae5cf9246d102db2a2558b67fe7e73c533e5d769099747921232d88b9be2", size = 1630876 }, + { url = "https://files.pythonhosted.org/packages/a7/4b/4931da26e0677880a9a533ef75ccbe564c091aa4a3579aed0355c7e06900/rapidfuzz-3.12.2-cp311-cp311-win_arm64.whl", hash = "sha256:7635fe34246cd241c8e35eb83084e978b01b83d5ef7e5bf72a704c637f270017", size = 870896 }, + { url = "https://files.pythonhosted.org/packages/a7/d2/e071753227c9e9f7f3550b983f30565f6e994581529815fa5a8879e7cd10/rapidfuzz-3.12.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1d982a651253ffe8434d9934ff0c1089111d60502228464721a2a4587435e159", size = 1944403 }, + { url = "https://files.pythonhosted.org/packages/aa/d1/4a10d21cc97aa36f4019af24382b5b4dc5ea6444499883c1c1286c6089ba/rapidfuzz-3.12.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:02e6466caa0222d5233b1f05640873671cd99549a5c5ba4c29151634a1e56080", size = 1430287 }, + { url = "https://files.pythonhosted.org/packages/6a/2d/76d39ab0beeb884d432096fe288c41850e37608e0145264081d0cb809f3c/rapidfuzz-3.12.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e956b3f053e474abae69ac693a52742109d860ac2375fe88e9387d3277f4c96c", size = 1403693 }, + { url = "https://files.pythonhosted.org/packages/85/1a/719b0f6498c003627e4b83b841bdcd48b11de8a9908a9051c4d2a0bc2245/rapidfuzz-3.12.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dee7d740a2d5418d4f964f39ab8d89923e6b945850db833e798a1969b19542a", size = 5555878 }, + { url = "https://files.pythonhosted.org/packages/af/48/14d952a73254b4b0e517141acd27979bd23948adaf197f6ca2dc722fde6a/rapidfuzz-3.12.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a057cdb0401e42c84b6516c9b1635f7aedd5e430c6e388bd5f6bcd1d6a0686bb", size = 1655301 }, + { url = "https://files.pythonhosted.org/packages/db/3f/b093e154e9752325d7459aa6dca43b7acbcaffa05133507e2403676e3e75/rapidfuzz-3.12.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dccf8d4fb5b86d39c581a59463c596b1d09df976da26ff04ae219604223d502f", size = 1678069 }, + { url = "https://files.pythonhosted.org/packages/d6/7e/88853ecae5b5456eb1a1d8a01cbd534e25b671735d5d974609cbae082542/rapidfuzz-3.12.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21d5b3793c6f5aecca595cd24164bf9d3c559e315ec684f912146fc4e769e367", size = 3137119 }, + { url = "https://files.pythonhosted.org/packages/4d/d2/b1f809b815aaf682ddac9c57929149f740b90feeb4f8da2f535c196de821/rapidfuzz-3.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:46a616c0e13cff2de1761b011e0b14bb73b110182f009223f1453d505c9a975c", size = 2491639 }, + { url = "https://files.pythonhosted.org/packages/61/e4/a908d7b8db6e52ba2f80f6f0d0709ef9fdedb767db4307084331742b67f0/rapidfuzz-3.12.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:19fa5bc4301a1ee55400d4a38a8ecf9522b0391fc31e6da5f4d68513fe5c0026", size = 7821561 }, + { url = "https://files.pythonhosted.org/packages/f3/83/0250c49deefff15c46f5e590d8ee6abbd0f056e20b85994db55c16ac6ead/rapidfuzz-3.12.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:544a47190a0d25971658a9365dba7095397b4ce3e897f7dd0a77ca2cf6fa984e", size = 2874048 }, + { url = "https://files.pythonhosted.org/packages/6c/3f/8d433d964c6e476476ee53eae5fa77b9f16b38d312eb1571e9099a6a3b12/rapidfuzz-3.12.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:f21af27c5e001f0ba1b88c36a0936437dfe034c452548d998891c21125eb640f", size = 3522801 }, + { url = "https://files.pythonhosted.org/packages/82/85/4931bfa41ef837b1544838e46e0556640d18114b3da9cf05e10defff00ae/rapidfuzz-3.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b63170d9db00629b5b3f2862114d8d6ee19127eaba0eee43762d62a25817dbe0", size = 4567304 }, + { url = "https://files.pythonhosted.org/packages/b1/fe/fdae322869885115dd19a38c1da71b73a8832aa77757c93f460743d4f54c/rapidfuzz-3.12.2-cp312-cp312-win32.whl", hash = "sha256:6c7152d77b2eb6bfac7baa11f2a9c45fd5a2d848dbb310acd0953b3b789d95c9", size = 1845332 }, + { url = "https://files.pythonhosted.org/packages/ca/a4/2ccebda5fb8a266d163d57a42c2a6ef6f91815df5d89cf38c12e8aa6ed0b/rapidfuzz-3.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:1a314d170ee272ac87579f25a6cf8d16a031e1f7a7b07663434b41a1473bc501", size = 1617926 }, + { url = "https://files.pythonhosted.org/packages/a5/bc/aa8a4dc4ebff966dd039cce017c614cfd202049b4d1a2daafee7d018521b/rapidfuzz-3.12.2-cp312-cp312-win_arm64.whl", hash = "sha256:d41e8231326e94fd07c4d8f424f6bed08fead6f5e6688d1e6e787f1443ae7631", size = 864737 }, + { url = "https://files.pythonhosted.org/packages/96/59/2ea3b5bb82798eae73d6ee892264ebfe42727626c1f0e96c77120f0d5cf6/rapidfuzz-3.12.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:941f31038dba5d3dedcfcceba81d61570ad457c873a24ceb13f4f44fcb574260", size = 1936870 }, + { url = "https://files.pythonhosted.org/packages/54/85/4e486bf9ea05e771ad231731305ed701db1339157f630b76b246ce29cf71/rapidfuzz-3.12.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fe2dfc454ee51ba168a67b1e92b72aad251e45a074972cef13340bbad2fd9438", size = 1424231 }, + { url = "https://files.pythonhosted.org/packages/dc/60/aeea3eed402c40a8cf055d554678769fbee0dd95c22f04546070a22bb90e/rapidfuzz-3.12.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78fafaf7f5a48ee35ccd7928339080a0136e27cf97396de45259eca1d331b714", size = 1398055 }, + { url = "https://files.pythonhosted.org/packages/33/6b/757106f4c21fe3f20ce13ba3df560da60e52fe0dc390fd22bf613761669c/rapidfuzz-3.12.2-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e0c7989ff32c077bb8fd53253fd6ca569d1bfebc80b17557e60750e6909ba4fe", size = 5526188 }, + { url = "https://files.pythonhosted.org/packages/1e/a2/7c680cdc5532746dba67ecf302eed975252657094e50ae334fa9268352e8/rapidfuzz-3.12.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:96fa00bc105caa34b6cd93dca14a29243a3a7f0c336e4dcd36348d38511e15ac", size = 1648483 }, + { url = "https://files.pythonhosted.org/packages/f6/b0/ce942a1448b1a75d64af230dd746dede502224dd29ca9001665bbfd4bee6/rapidfuzz-3.12.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bccfb30c668620c5bc3490f2dc7d7da1cca0ead5a9da8b755e2e02e2ef0dff14", size = 1676076 }, + { url = "https://files.pythonhosted.org/packages/ba/71/81f77b08333200be6984b6cdf2bdfd7cfca4943f16b478a2f7838cba8d66/rapidfuzz-3.12.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2f9b0adc3d894beb51f5022f64717b6114a6fabaca83d77e93ac7675911c8cc5", size = 3114169 }, + { url = "https://files.pythonhosted.org/packages/01/16/f3f34b207fdc8c61a33f9d2d61fc96b62c7dadca88bda1df1be4b94afb0b/rapidfuzz-3.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:32691aa59577f42864d5535cb6225d0f47e2c7bff59cf4556e5171e96af68cc1", size = 2485317 }, + { url = "https://files.pythonhosted.org/packages/b2/a6/b954f0766f644eb8dd8df44703e024ab4f5f15a8f8f5ea969963dd036f50/rapidfuzz-3.12.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:758b10380ad34c1f51753a070d7bb278001b5e6fcf544121c6df93170952d705", size = 7844495 }, + { url = "https://files.pythonhosted.org/packages/fb/8f/1dc604d05e07150a02b56a8ffc47df75ce316c65467259622c9edf098451/rapidfuzz-3.12.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:50a9c54c0147b468363119132d514c5024fbad1ed8af12bd8bd411b0119f9208", size = 2873242 }, + { url = "https://files.pythonhosted.org/packages/78/a9/9c649ace4b7f885e0a5fdcd1f33b057ebd83ecc2837693e6659bd944a2bb/rapidfuzz-3.12.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e3ceb87c11d2d0fbe8559bb795b0c0604b84cfc8bb7b8720b5c16e9e31e00f41", size = 3519124 }, + { url = "https://files.pythonhosted.org/packages/f5/81/ce0b774e540a2e22ec802e383131d7ead18347197304d584c4ccf7b8861a/rapidfuzz-3.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f7c9a003002434889255ff5676ca0f8934a478065ab5e702f75dc42639505bba", size = 4557831 }, + { url = "https://files.pythonhosted.org/packages/13/28/7bf0ee8d35efa7ab14e83d1795cdfd54833aa0428b6f87e987893136c372/rapidfuzz-3.12.2-cp313-cp313-win32.whl", hash = "sha256:cf165a76870cd875567941cf861dfd361a0a6e6a56b936c5d30042ddc9def090", size = 1842802 }, + { url = "https://files.pythonhosted.org/packages/ef/7e/792d609484776c8a40e1695ebd28b62196be9f8347b785b9104604dc7268/rapidfuzz-3.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:55bcc003541f5f16ec0a73bf6de758161973f9e8d75161954380738dd147f9f2", size = 1615808 }, + { url = "https://files.pythonhosted.org/packages/4b/43/ca3d1018b392f49131843648e10b08ace23afe8dad3bee5f136e4346b7cd/rapidfuzz-3.12.2-cp313-cp313-win_arm64.whl", hash = "sha256:69f6ecdf1452139f2b947d0c169a605de578efdb72cbb2373cb0a94edca1fd34", size = 863535 }, + { url = "https://files.pythonhosted.org/packages/15/67/e35d9193badb9e5c2271af2619fcdc5c5bfc3eded2f1290aa623cf12ac64/rapidfuzz-3.12.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c4c852cd8bed1516a64fd6e2d4c6f270d4356196ee03fda2af1e5a9e13c34643", size = 1963182 }, + { url = "https://files.pythonhosted.org/packages/f1/62/ba3fc527043f3aedc9260e249aea7ad284878fa97e57e2fdf3b8c253bed8/rapidfuzz-3.12.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:42e7f747b55529a6d0d1588695d71025e884ab48664dca54b840413dea4588d8", size = 1436741 }, + { url = "https://files.pythonhosted.org/packages/f4/ae/2133b1a9a96e23e0d4f8b050681aee12560f7fc37982f815c8b86b2a3978/rapidfuzz-3.12.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a749fd2690f24ef256b264a781487746bbb95344364fe8fe356f0eef7ef206ba", size = 1431434 }, + { url = "https://files.pythonhosted.org/packages/cf/f8/4236af04f4de6609a7b392fbad010caf4dd69694399d7dac4db188408887/rapidfuzz-3.12.2-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9a11e1d036170bbafa43a9e63d8c309273564ec5bdfc5439062f439d1a16965a", size = 5641842 }, + { url = "https://files.pythonhosted.org/packages/97/39/2f5c3973abda8cf80666922204bab408f8b8538a010c2797b38edf12d80c/rapidfuzz-3.12.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dfb337f1832c1231e3d5621bd0ebebb854e46036aedae3e6a49c1fc08f16f249", size = 1678859 }, + { url = "https://files.pythonhosted.org/packages/d3/63/2732e64ae6e42c6a72cb66549d968fb85be17456780a0a080328781f86cd/rapidfuzz-3.12.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e88c6e68fca301722fa3ab7fd3ca46998012c14ada577bc1e2c2fc04f2067ca6", size = 1682144 }, + { url = "https://files.pythonhosted.org/packages/7f/0b/22a4299b534a24c660a0bba597834320943b76692d65ec648767833adfdf/rapidfuzz-3.12.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17e1a3a8b4b5125cfb63a6990459b25b87ea769bdaf90d05bb143f8febef076a", size = 3147458 }, + { url = "https://files.pythonhosted.org/packages/d4/0c/beb68a732668f29e2d1ac24100c70ab83694b111291a855d7107fdd15d17/rapidfuzz-3.12.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:b9f8177b24ccc0a843e85932b1088c5e467a7dd7a181c13f84c684b796bea815", size = 2519335 }, + { url = "https://files.pythonhosted.org/packages/37/c3/1a60df1bfe4145552f0afd23aeeedfe060dd1db2fae1106c3fe9966265a0/rapidfuzz-3.12.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:6c506bdc2f304051592c0d3b0e82eed309248ec10cdf802f13220251358375ea", size = 7862504 }, + { url = "https://files.pythonhosted.org/packages/c3/70/8faebb311218fb9d4c92549dc0283a2fb9082a585463153310f627c2f727/rapidfuzz-3.12.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:30bf15c1ecec2798b713d551df17f23401a3e3653ad9ed4e83ad1c2b06e86100", size = 2899948 }, + { url = "https://files.pythonhosted.org/packages/9b/80/e512f552ef64dd43f0359633f59293515276ae47d853abc42eb914be1df5/rapidfuzz-3.12.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:bd9a67cfc83e8453ef17ddd1c2c4ce4a74d448a197764efb54c29f29fb41f611", size = 3547701 }, + { url = "https://files.pythonhosted.org/packages/2a/0e/8d5eff5de34846da426c93460c130672908cdf5fb1967cd23b3367c03e5d/rapidfuzz-3.12.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7a6eaec2ef658dd650c6eb9b36dff7a361ebd7d8bea990ce9d639b911673b2cb", size = 4583294 }, + { url = "https://files.pythonhosted.org/packages/02/9f/2be30d436ebf13d89d19abc8c6b1a4cdbef3f343daac10c3b89fd039a6ef/rapidfuzz-3.12.2-cp39-cp39-win32.whl", hash = "sha256:d7701769f110332cde45c41759cb2a497de8d2dca55e4c519a46aed5fbb19d1a", size = 1865017 }, + { url = "https://files.pythonhosted.org/packages/e7/c9/780b83ce66b5e1115b017fed1f4144ada00bf2e2406fa6c8809481ab0c29/rapidfuzz-3.12.2-cp39-cp39-win_amd64.whl", hash = "sha256:296bf0fd4f678488670e262c87a3e4f91900b942d73ae38caa42a417e53643b1", size = 1627824 }, + { url = "https://files.pythonhosted.org/packages/2a/72/94c45478866bced213aa36cf3de08ed061434352c2b92584f4a1ef170697/rapidfuzz-3.12.2-cp39-cp39-win_arm64.whl", hash = "sha256:7957f5d768de14f6b2715303ccdf224b78416738ee95a028a2965c95f73afbfb", size = 871672 }, + { url = "https://files.pythonhosted.org/packages/92/77/a72abb16c5cb093980570871aa152e6d47fc9cf2482daeea9687708be655/rapidfuzz-3.12.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e5fd3ce849b27d063755829cda27a9dab6dbd63be3801f2a40c60ec563a4c90f", size = 1858463 }, + { url = "https://files.pythonhosted.org/packages/8c/93/06a29076722ef6b05a81132eac9847592185ee97a1dadc7ead2f37334ebe/rapidfuzz-3.12.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:54e53662d71ed660c83c5109127c8e30b9e607884b7c45d2aff7929bbbd00589", size = 1368517 }, + { url = "https://files.pythonhosted.org/packages/f9/4f/36e8ae37e82a617b8d8da8162744bf69b15091743c3f70699090cb793dd5/rapidfuzz-3.12.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b9e43cf2213e524f3309d329f1ad8dbf658db004ed44f6ae1cd2919aa997da5", size = 1364411 }, + { url = "https://files.pythonhosted.org/packages/63/f5/ac535622eb163b9a242c40633587916e71f23233bcd6e3d3e70ae2a99a4c/rapidfuzz-3.12.2-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:29ca445e320e5a8df3bd1d75b4fa4ecfa7c681942b9ac65b55168070a1a1960e", size = 5486500 }, + { url = "https://files.pythonhosted.org/packages/6f/de/87fcb20fda640a2cf0cebe4b0dc3ab970b1ef8a9d48d05363e375fc05982/rapidfuzz-3.12.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83eb7ef732c2f8533c6b5fbe69858a722c218acc3e1fc190ab6924a8af7e7e0e", size = 3064900 }, + { url = "https://files.pythonhosted.org/packages/c3/67/c7c4129e8b8b674a7b1d82edc36ed093418fdcf011e3a25150895b24a963/rapidfuzz-3.12.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:648adc2dd2cf873efc23befcc6e75754e204a409dfa77efd0fea30d08f22ef9d", size = 1555181 }, + { url = "https://files.pythonhosted.org/packages/ee/4d/e910b70839d88d1c38ba806b0ddaa94b478cca8a09f4e7155b2b607c34b2/rapidfuzz-3.12.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:9b1e6f48e1ffa0749261ee23a1c6462bdd0be5eac83093f4711de17a42ae78ad", size = 1860425 }, + { url = "https://files.pythonhosted.org/packages/fd/62/54914f63e185539fbcca65acb1f7c879740a278d240527ed5ddd40bd7690/rapidfuzz-3.12.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:1ae9ded463f2ca4ba1eb762913c5f14c23d2e120739a62b7f4cc102eab32dc90", size = 1369066 }, + { url = "https://files.pythonhosted.org/packages/56/4a/de2cfab279497d0b2529d3fec398f60cf8e27a51d667b6529081fbdb0af2/rapidfuzz-3.12.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dda45f47b559be72ecbce45c7f71dc7c97b9772630ab0f3286d97d2c3025ab71", size = 1365330 }, + { url = "https://files.pythonhosted.org/packages/dd/48/170c37cfdf04efa34e7cafc688a8517c9098c1d27e1513393ad71bf3165c/rapidfuzz-3.12.2-pp311-pypy311_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3745c6443890265513a3c8777f2de4cb897aeb906a406f97741019be8ad5bcc", size = 5481251 }, + { url = "https://files.pythonhosted.org/packages/4e/2d/107c489443f6438780d2e40747d5880c8d9374a64e17487eb4085fe7f1f5/rapidfuzz-3.12.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36d3ef4f047ed1bc96fa29289f9e67a637ddca5e4f4d3dc7cb7f50eb33ec1664", size = 3060633 }, + { url = "https://files.pythonhosted.org/packages/09/f6/fa777f336629aee8938f3d5c95c09df38459d4eadbdbe34642889857fb6a/rapidfuzz-3.12.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:54bb69ebe5ca0bd7527357e348f16a4c0c52fe0c2fcc8a041010467dcb8385f7", size = 1555000 }, + { url = "https://files.pythonhosted.org/packages/c1/89/43139cfdcd523024fcef1a5a6f2544f25919d80d18fe495be7e7275ed0ec/rapidfuzz-3.12.2-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3f2ddd5b99b254039a8c82be5749d4d75943f62eb2c2918acf6ffd586852834f", size = 1863971 }, + { url = "https://files.pythonhosted.org/packages/be/c9/b37bc91ec12dedc8d7eff0aeb921909b51e6593f4264c9927a4e04a1f8ea/rapidfuzz-3.12.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:8117dab9b26a1aaffab59b4e30f80ac4d55e61ad4139a637c149365960933bee", size = 1373461 }, + { url = "https://files.pythonhosted.org/packages/56/4f/0e4844c0e0848de9993f453337e0e7255f687da37545e539cf000b41a74c/rapidfuzz-3.12.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40c0f16d62d6553527de3dab2fb69709c4383430ea44bce8fb4711ed4cbc6ae3", size = 1372105 }, + { url = "https://files.pythonhosted.org/packages/5c/87/59dc6c5b3601c476ac12d0f978607c618daa1b35e3805a7092a91bf7c2d2/rapidfuzz-3.12.2-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f177e1eb6e4f5261a89c475e21bce7a99064a8f217d2336fb897408f46f0ceaf", size = 5486722 }, + { url = "https://files.pythonhosted.org/packages/27/87/d041dc29a99e376ebb5a7c35d11e1a52c5a5a962543c4d81bcbea958e56e/rapidfuzz-3.12.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5df0cecc2852fcb078ed1b4482fac4fc2c2e7787f3edda8920d9a4c0f51b1c95", size = 3071880 }, + { url = "https://files.pythonhosted.org/packages/a1/51/0d7b1eecd83982fe190baa8ea7060307854436e349bc8ccc4dcea5087ff4/rapidfuzz-3.12.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:3b3c4df0321df6f8f0b61afbaa2ced9622750ee1e619128db57a18533d139820", size = 1556257 }, +] + +[[package]] +name = "referencing" +version = "0.36.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775 }, +] + +[[package]] +name = "rich" +version = "14.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229 }, +] + +[[package]] +name = "rpds-py" +version = "0.24.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/b3/52b213298a0ba7097c7ea96bee95e1947aa84cc816d48cebb539770cdf41/rpds_py-0.24.0.tar.gz", hash = "sha256:772cc1b2cd963e7e17e6cc55fe0371fb9c704d63e44cacec7b9b7f523b78919e", size = 26863 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/21/cbc43b220c9deb536b07fbd598c97d463bbb7afb788851891252fc920742/rpds_py-0.24.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:006f4342fe729a368c6df36578d7a348c7c716be1da0a1a0f86e3021f8e98724", size = 377531 }, + { url = "https://files.pythonhosted.org/packages/42/15/cc4b09ef160483e49c3aab3b56f3d375eadf19c87c48718fb0147e86a446/rpds_py-0.24.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2d53747da70a4e4b17f559569d5f9506420966083a31c5fbd84e764461c4444b", size = 362273 }, + { url = "https://files.pythonhosted.org/packages/8c/a2/67718a188a88dbd5138d959bed6efe1cc7413a4caa8283bd46477ed0d1ad/rpds_py-0.24.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8acd55bd5b071156bae57b555f5d33697998752673b9de554dd82f5b5352727", size = 388111 }, + { url = "https://files.pythonhosted.org/packages/e5/e6/cbf1d3163405ad5f4a1a6d23f80245f2204d0c743b18525f34982dec7f4d/rpds_py-0.24.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7e80d375134ddb04231a53800503752093dbb65dad8dabacce2c84cccc78e964", size = 394447 }, + { url = "https://files.pythonhosted.org/packages/21/bb/4fe220ccc8a549b38b9e9cec66212dc3385a82a5ee9e37b54411cce4c898/rpds_py-0.24.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60748789e028d2a46fc1c70750454f83c6bdd0d05db50f5ae83e2db500b34da5", size = 448028 }, + { url = "https://files.pythonhosted.org/packages/a5/41/d2d6e0fd774818c4cadb94185d30cf3768de1c2a9e0143fc8bc6ce59389e/rpds_py-0.24.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6e1daf5bf6c2be39654beae83ee6b9a12347cb5aced9a29eecf12a2d25fff664", size = 447410 }, + { url = "https://files.pythonhosted.org/packages/a7/a7/6d04d438f53d8bb2356bb000bea9cf5c96a9315e405b577117e344cc7404/rpds_py-0.24.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b221c2457d92a1fb3c97bee9095c874144d196f47c038462ae6e4a14436f7bc", size = 389531 }, + { url = "https://files.pythonhosted.org/packages/23/be/72e6df39bd7ca5a66799762bf54d8e702483fdad246585af96723109d486/rpds_py-0.24.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:66420986c9afff67ef0c5d1e4cdc2d0e5262f53ad11e4f90e5e22448df485bf0", size = 420099 }, + { url = "https://files.pythonhosted.org/packages/8c/c9/ca100cd4688ee0aa266197a5cb9f685231676dd7d573041ca53787b23f4e/rpds_py-0.24.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:43dba99f00f1d37b2a0265a259592d05fcc8e7c19d140fe51c6e6f16faabeb1f", size = 564950 }, + { url = "https://files.pythonhosted.org/packages/05/98/908cd95686d33b3ac8ac2e582d7ae38e2c3aa2c0377bf1f5663bafd1ffb2/rpds_py-0.24.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a88c0d17d039333a41d9bf4616bd062f0bd7aa0edeb6cafe00a2fc2a804e944f", size = 591778 }, + { url = "https://files.pythonhosted.org/packages/7b/ac/e143726f1dd3215efcb974b50b03bd08a8a1556b404a0a7872af6d197e57/rpds_py-0.24.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc31e13ce212e14a539d430428cd365e74f8b2d534f8bc22dd4c9c55b277b875", size = 560421 }, + { url = "https://files.pythonhosted.org/packages/60/28/add1c1d2fcd5aa354f7225d036d4492261759a22d449cff14841ef36a514/rpds_py-0.24.0-cp310-cp310-win32.whl", hash = "sha256:fc2c1e1b00f88317d9de6b2c2b39b012ebbfe35fe5e7bef980fd2a91f6100a07", size = 222089 }, + { url = "https://files.pythonhosted.org/packages/b0/ac/81f8066c6de44c507caca488ba336ae30d35d57f61fe10578824d1a70196/rpds_py-0.24.0-cp310-cp310-win_amd64.whl", hash = "sha256:c0145295ca415668420ad142ee42189f78d27af806fcf1f32a18e51d47dd2052", size = 234622 }, + { url = "https://files.pythonhosted.org/packages/80/e6/c1458bbfb257448fdb2528071f1f4e19e26798ed5ef6d47d7aab0cb69661/rpds_py-0.24.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:2d3ee4615df36ab8eb16c2507b11e764dcc11fd350bbf4da16d09cda11fcedef", size = 377679 }, + { url = "https://files.pythonhosted.org/packages/dd/26/ea4181ef78f58b2c167548c6a833d7dc22408e5b3b181bda9dda440bb92d/rpds_py-0.24.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e13ae74a8a3a0c2f22f450f773e35f893484fcfacb00bb4344a7e0f4f48e1f97", size = 362571 }, + { url = "https://files.pythonhosted.org/packages/56/fa/1ec54dd492c64c280a2249a047fc3369e2789dc474eac20445ebfc72934b/rpds_py-0.24.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf86f72d705fc2ef776bb7dd9e5fbba79d7e1f3e258bf9377f8204ad0fc1c51e", size = 388012 }, + { url = "https://files.pythonhosted.org/packages/3a/be/bad8b0e0f7e58ef4973bb75e91c472a7d51da1977ed43b09989264bf065c/rpds_py-0.24.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c43583ea8517ed2e780a345dd9960896afc1327e8cf3ac8239c167530397440d", size = 394730 }, + { url = "https://files.pythonhosted.org/packages/35/56/ab417fc90c21826df048fc16e55316ac40876e4b790104ececcbce813d8f/rpds_py-0.24.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4cd031e63bc5f05bdcda120646a0d32f6d729486d0067f09d79c8db5368f4586", size = 448264 }, + { url = "https://files.pythonhosted.org/packages/b6/75/4c63862d5c05408589196c8440a35a14ea4ae337fa70ded1f03638373f06/rpds_py-0.24.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:34d90ad8c045df9a4259c47d2e16a3f21fdb396665c94520dbfe8766e62187a4", size = 446813 }, + { url = "https://files.pythonhosted.org/packages/e7/0c/91cf17dffa9a38835869797a9f041056091ebba6a53963d3641207e3d467/rpds_py-0.24.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e838bf2bb0b91ee67bf2b889a1a841e5ecac06dd7a2b1ef4e6151e2ce155c7ae", size = 389438 }, + { url = "https://files.pythonhosted.org/packages/1b/b0/60e6c72727c978276e02851819f3986bc40668f115be72c1bc4d922c950f/rpds_py-0.24.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04ecf5c1ff4d589987b4d9882872f80ba13da7d42427234fce8f22efb43133bc", size = 420416 }, + { url = "https://files.pythonhosted.org/packages/a1/d7/f46f85b9f863fb59fd3c534b5c874c48bee86b19e93423b9da8784605415/rpds_py-0.24.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:630d3d8ea77eabd6cbcd2ea712e1c5cecb5b558d39547ac988351195db433f6c", size = 565236 }, + { url = "https://files.pythonhosted.org/packages/2a/d1/1467620ded6dd70afc45ec822cdf8dfe7139537780d1f3905de143deb6fd/rpds_py-0.24.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ebcb786b9ff30b994d5969213a8430cbb984cdd7ea9fd6df06663194bd3c450c", size = 592016 }, + { url = "https://files.pythonhosted.org/packages/5d/13/fb1ded2e6adfaa0c0833106c42feb290973f665300f4facd5bf5d7891d9c/rpds_py-0.24.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:174e46569968ddbbeb8a806d9922f17cd2b524aa753b468f35b97ff9c19cb718", size = 560123 }, + { url = "https://files.pythonhosted.org/packages/1e/df/09fc1857ac7cc2eb16465a7199c314cbce7edde53c8ef21d615410d7335b/rpds_py-0.24.0-cp311-cp311-win32.whl", hash = "sha256:5ef877fa3bbfb40b388a5ae1cb00636a624690dcb9a29a65267054c9ea86d88a", size = 222256 }, + { url = "https://files.pythonhosted.org/packages/ff/25/939b40bc4d54bf910e5ee60fb5af99262c92458f4948239e8c06b0b750e7/rpds_py-0.24.0-cp311-cp311-win_amd64.whl", hash = "sha256:e274f62cbd274359eff63e5c7e7274c913e8e09620f6a57aae66744b3df046d6", size = 234718 }, + { url = "https://files.pythonhosted.org/packages/1a/e0/1c55f4a3be5f1ca1a4fd1f3ff1504a1478c1ed48d84de24574c4fa87e921/rpds_py-0.24.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:d8551e733626afec514b5d15befabea0dd70a343a9f23322860c4f16a9430205", size = 366945 }, + { url = "https://files.pythonhosted.org/packages/39/1b/a3501574fbf29118164314dbc800d568b8c1c7b3258b505360e8abb3902c/rpds_py-0.24.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e374c0ce0ca82e5b67cd61fb964077d40ec177dd2c4eda67dba130de09085c7", size = 351935 }, + { url = "https://files.pythonhosted.org/packages/dc/47/77d3d71c55f6a374edde29f1aca0b2e547325ed00a9da820cabbc9497d2b/rpds_py-0.24.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d69d003296df4840bd445a5d15fa5b6ff6ac40496f956a221c4d1f6f7b4bc4d9", size = 390817 }, + { url = "https://files.pythonhosted.org/packages/4e/ec/1e336ee27484379e19c7f9cc170f4217c608aee406d3ae3a2e45336bff36/rpds_py-0.24.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8212ff58ac6dfde49946bea57474a386cca3f7706fc72c25b772b9ca4af6b79e", size = 401983 }, + { url = "https://files.pythonhosted.org/packages/07/f8/39b65cbc272c635eaea6d393c2ad1ccc81c39eca2db6723a0ca4b2108fce/rpds_py-0.24.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:528927e63a70b4d5f3f5ccc1fa988a35456eb5d15f804d276709c33fc2f19bda", size = 451719 }, + { url = "https://files.pythonhosted.org/packages/32/05/05c2b27dd9c30432f31738afed0300659cb9415db0ff7429b05dfb09bbde/rpds_py-0.24.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a824d2c7a703ba6daaca848f9c3d5cb93af0505be505de70e7e66829affd676e", size = 442546 }, + { url = "https://files.pythonhosted.org/packages/7d/e0/19383c8b5d509bd741532a47821c3e96acf4543d0832beba41b4434bcc49/rpds_py-0.24.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44d51febb7a114293ffd56c6cf4736cb31cd68c0fddd6aa303ed09ea5a48e029", size = 393695 }, + { url = "https://files.pythonhosted.org/packages/9d/15/39f14e96d94981d0275715ae8ea564772237f3fa89bc3c21e24de934f2c7/rpds_py-0.24.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3fab5f4a2c64a8fb64fc13b3d139848817a64d467dd6ed60dcdd6b479e7febc9", size = 427218 }, + { url = "https://files.pythonhosted.org/packages/22/b9/12da7124905a680f690da7a9de6f11de770b5e359f5649972f7181c8bf51/rpds_py-0.24.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9be4f99bee42ac107870c61dfdb294d912bf81c3c6d45538aad7aecab468b6b7", size = 568062 }, + { url = "https://files.pythonhosted.org/packages/88/17/75229017a2143d915f6f803721a6d721eca24f2659c5718a538afa276b4f/rpds_py-0.24.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:564c96b6076a98215af52f55efa90d8419cc2ef45d99e314fddefe816bc24f91", size = 596262 }, + { url = "https://files.pythonhosted.org/packages/aa/64/8e8a1d8bd1b6b638d6acb6d41ab2cec7f2067a5b8b4c9175703875159a7c/rpds_py-0.24.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:75a810b7664c17f24bf2ffd7f92416c00ec84b49bb68e6a0d93e542406336b56", size = 564306 }, + { url = "https://files.pythonhosted.org/packages/68/1c/a7eac8d8ed8cb234a9b1064647824c387753343c3fab6ed7c83481ed0be7/rpds_py-0.24.0-cp312-cp312-win32.whl", hash = "sha256:f6016bd950be4dcd047b7475fdf55fb1e1f59fc7403f387be0e8123e4a576d30", size = 224281 }, + { url = "https://files.pythonhosted.org/packages/bb/46/b8b5424d1d21f2f2f3f2d468660085318d4f74a8df8289e3dd6ad224d488/rpds_py-0.24.0-cp312-cp312-win_amd64.whl", hash = "sha256:998c01b8e71cf051c28f5d6f1187abbdf5cf45fc0efce5da6c06447cba997034", size = 239719 }, + { url = "https://files.pythonhosted.org/packages/9d/c3/3607abc770395bc6d5a00cb66385a5479fb8cd7416ddef90393b17ef4340/rpds_py-0.24.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3d2d8e4508e15fc05b31285c4b00ddf2e0eb94259c2dc896771966a163122a0c", size = 367072 }, + { url = "https://files.pythonhosted.org/packages/d8/35/8c7ee0fe465793e3af3298dc5a9f3013bd63e7a69df04ccfded8293a4982/rpds_py-0.24.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f00c16e089282ad68a3820fd0c831c35d3194b7cdc31d6e469511d9bffc535c", size = 351919 }, + { url = "https://files.pythonhosted.org/packages/91/d3/7e1b972501eb5466b9aca46a9c31bcbbdc3ea5a076e9ab33f4438c1d069d/rpds_py-0.24.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:951cc481c0c395c4a08639a469d53b7d4afa252529a085418b82a6b43c45c240", size = 390360 }, + { url = "https://files.pythonhosted.org/packages/a2/a8/ccabb50d3c91c26ad01f9b09a6a3b03e4502ce51a33867c38446df9f896b/rpds_py-0.24.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c9ca89938dff18828a328af41ffdf3902405a19f4131c88e22e776a8e228c5a8", size = 400704 }, + { url = "https://files.pythonhosted.org/packages/53/ae/5fa5bf0f3bc6ce21b5ea88fc0ecd3a439e7cb09dd5f9ffb3dbe1b6894fc5/rpds_py-0.24.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0ef550042a8dbcd657dfb284a8ee00f0ba269d3f2286b0493b15a5694f9fe8", size = 450839 }, + { url = "https://files.pythonhosted.org/packages/e3/ac/c4e18b36d9938247e2b54f6a03746f3183ca20e1edd7d3654796867f5100/rpds_py-0.24.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b2356688e5d958c4d5cb964af865bea84db29971d3e563fb78e46e20fe1848b", size = 441494 }, + { url = "https://files.pythonhosted.org/packages/bf/08/b543969c12a8f44db6c0f08ced009abf8f519191ca6985509e7c44102e3c/rpds_py-0.24.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78884d155fd15d9f64f5d6124b486f3d3f7fd7cd71a78e9670a0f6f6ca06fb2d", size = 393185 }, + { url = "https://files.pythonhosted.org/packages/da/7e/f6eb6a7042ce708f9dfc781832a86063cea8a125bbe451d663697b51944f/rpds_py-0.24.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6a4a535013aeeef13c5532f802708cecae8d66c282babb5cd916379b72110cf7", size = 426168 }, + { url = "https://files.pythonhosted.org/packages/38/b0/6cd2bb0509ac0b51af4bb138e145b7c4c902bb4b724d6fd143689d6e0383/rpds_py-0.24.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:84e0566f15cf4d769dade9b366b7b87c959be472c92dffb70462dd0844d7cbad", size = 567622 }, + { url = "https://files.pythonhosted.org/packages/64/b0/c401f4f077547d98e8b4c2ec6526a80e7cb04f519d416430ec1421ee9e0b/rpds_py-0.24.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:823e74ab6fbaa028ec89615ff6acb409e90ff45580c45920d4dfdddb069f2120", size = 595435 }, + { url = "https://files.pythonhosted.org/packages/9f/ec/7993b6e803294c87b61c85bd63e11142ccfb2373cf88a61ec602abcbf9d6/rpds_py-0.24.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c61a2cb0085c8783906b2f8b1f16a7e65777823c7f4d0a6aaffe26dc0d358dd9", size = 563762 }, + { url = "https://files.pythonhosted.org/packages/1f/29/4508003204cb2f461dc2b83dd85f8aa2b915bc98fe6046b9d50d4aa05401/rpds_py-0.24.0-cp313-cp313-win32.whl", hash = "sha256:60d9b630c8025b9458a9d114e3af579a2c54bd32df601c4581bd054e85258143", size = 223510 }, + { url = "https://files.pythonhosted.org/packages/f9/12/09e048d1814195e01f354155fb772fb0854bd3450b5f5a82224b3a319f0e/rpds_py-0.24.0-cp313-cp313-win_amd64.whl", hash = "sha256:6eea559077d29486c68218178ea946263b87f1c41ae7f996b1f30a983c476a5a", size = 239075 }, + { url = "https://files.pythonhosted.org/packages/d2/03/5027cde39bb2408d61e4dd0cf81f815949bb629932a6c8df1701d0257fc4/rpds_py-0.24.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:d09dc82af2d3c17e7dd17120b202a79b578d79f2b5424bda209d9966efeed114", size = 362974 }, + { url = "https://files.pythonhosted.org/packages/bf/10/24d374a2131b1ffafb783e436e770e42dfdb74b69a2cd25eba8c8b29d861/rpds_py-0.24.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5fc13b44de6419d1e7a7e592a4885b323fbc2f46e1f22151e3a8ed3b8b920405", size = 348730 }, + { url = "https://files.pythonhosted.org/packages/7a/d1/1ef88d0516d46cd8df12e5916966dbf716d5ec79b265eda56ba1b173398c/rpds_py-0.24.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c347a20d79cedc0a7bd51c4d4b7dbc613ca4e65a756b5c3e57ec84bd43505b47", size = 387627 }, + { url = "https://files.pythonhosted.org/packages/4e/35/07339051b8b901ecefd449ebf8e5522e92bcb95e1078818cbfd9db8e573c/rpds_py-0.24.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20f2712bd1cc26a3cc16c5a1bfee9ed1abc33d4cdf1aabd297fe0eb724df4272", size = 394094 }, + { url = "https://files.pythonhosted.org/packages/dc/62/ee89ece19e0ba322b08734e95441952062391065c157bbd4f8802316b4f1/rpds_py-0.24.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aad911555286884be1e427ef0dc0ba3929e6821cbeca2194b13dc415a462c7fd", size = 449639 }, + { url = "https://files.pythonhosted.org/packages/15/24/b30e9f9e71baa0b9dada3a4ab43d567c6b04a36d1cb531045f7a8a0a7439/rpds_py-0.24.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0aeb3329c1721c43c58cae274d7d2ca85c1690d89485d9c63a006cb79a85771a", size = 438584 }, + { url = "https://files.pythonhosted.org/packages/28/d9/49f7b8f3b4147db13961e19d5e30077cd0854ccc08487026d2cb2142aa4a/rpds_py-0.24.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a0f156e9509cee987283abd2296ec816225145a13ed0391df8f71bf1d789e2d", size = 391047 }, + { url = "https://files.pythonhosted.org/packages/49/b0/e66918d0972c33a259ba3cd7b7ff10ed8bd91dbcfcbec6367b21f026db75/rpds_py-0.24.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:aa6800adc8204ce898c8a424303969b7aa6a5e4ad2789c13f8648739830323b7", size = 418085 }, + { url = "https://files.pythonhosted.org/packages/e1/6b/99ed7ea0a94c7ae5520a21be77a82306aac9e4e715d4435076ead07d05c6/rpds_py-0.24.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a18fc371e900a21d7392517c6f60fe859e802547309e94313cd8181ad9db004d", size = 564498 }, + { url = "https://files.pythonhosted.org/packages/28/26/1cacfee6b800e6fb5f91acecc2e52f17dbf8b0796a7c984b4568b6d70e38/rpds_py-0.24.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:9168764133fd919f8dcca2ead66de0105f4ef5659cbb4fa044f7014bed9a1797", size = 590202 }, + { url = "https://files.pythonhosted.org/packages/a9/9e/57bd2f9fba04a37cef673f9a66b11ca8c43ccdd50d386c455cd4380fe461/rpds_py-0.24.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f6e3cec44ba05ee5cbdebe92d052f69b63ae792e7d05f1020ac5e964394080c", size = 561771 }, + { url = "https://files.pythonhosted.org/packages/9f/cf/b719120f375ab970d1c297dbf8de1e3c9edd26fe92c0ed7178dd94b45992/rpds_py-0.24.0-cp313-cp313t-win32.whl", hash = "sha256:8ebc7e65ca4b111d928b669713865f021b7773350eeac4a31d3e70144297baba", size = 221195 }, + { url = "https://files.pythonhosted.org/packages/2d/e5/22865285789f3412ad0c3d7ec4dc0a3e86483b794be8a5d9ed5a19390900/rpds_py-0.24.0-cp313-cp313t-win_amd64.whl", hash = "sha256:675269d407a257b8c00a6b58205b72eec8231656506c56fd429d924ca00bb350", size = 237354 }, + { url = "https://files.pythonhosted.org/packages/22/ef/a194eaef0d0f2cd3f4c893c5b809a7458aaa7c0a64e60a45a72a04835ed4/rpds_py-0.24.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a36b452abbf29f68527cf52e181fced56685731c86b52e852053e38d8b60bc8d", size = 378126 }, + { url = "https://files.pythonhosted.org/packages/c3/8d/9a07f69933204c098760c884f03835ab8fb66e28d2d5f3dd6741720cf29c/rpds_py-0.24.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8b3b397eefecec8e8e39fa65c630ef70a24b09141a6f9fc17b3c3a50bed6b50e", size = 362887 }, + { url = "https://files.pythonhosted.org/packages/29/74/315f42060f2e3cedd77d382a98484a68ef727bd3b5fd7b91825b859a3e85/rpds_py-0.24.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdabcd3beb2a6dca7027007473d8ef1c3b053347c76f685f5f060a00327b8b65", size = 388661 }, + { url = "https://files.pythonhosted.org/packages/29/22/7ee7bb2b25ecdfcf1265d5a51472814fe60b580f9e1e2746eed9c476310a/rpds_py-0.24.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5db385bacd0c43f24be92b60c857cf760b7f10d8234f4bd4be67b5b20a7c0b6b", size = 394993 }, + { url = "https://files.pythonhosted.org/packages/46/7b/5f40e278d81cd23eea6b88bbac62bacc27ed19412051a1fc4229e8f9367a/rpds_py-0.24.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8097b3422d020ff1c44effc40ae58e67d93e60d540a65649d2cdaf9466030791", size = 448706 }, + { url = "https://files.pythonhosted.org/packages/5a/7a/06aada7ecdb0d02fbc041daee998ae841882fcc8ed3c0f84e72d6832fef1/rpds_py-0.24.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:493fe54318bed7d124ce272fc36adbf59d46729659b2c792e87c3b95649cdee9", size = 447369 }, + { url = "https://files.pythonhosted.org/packages/c6/f3/428a9367077268f852db9b3b68b6eda6ee4594ab7dc2d603a2c370619cc0/rpds_py-0.24.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8aa362811ccdc1f8dadcc916c6d47e554169ab79559319ae9fae7d7752d0d60c", size = 390012 }, + { url = "https://files.pythonhosted.org/packages/55/66/24b61f14cd54e525583404afe6e3c221b309d1abd4b0b597a566dd8ee42d/rpds_py-0.24.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d8f9a6e7fd5434817526815f09ea27f2746c4a51ee11bb3439065f5fc754db58", size = 421576 }, + { url = "https://files.pythonhosted.org/packages/22/56/18b81a4f0550e0d4be700cdcf1415ebf250fd21f9a5a775843dd3588dbf6/rpds_py-0.24.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8205ee14463248d3349131bb8099efe15cd3ce83b8ef3ace63c7e976998e7124", size = 565562 }, + { url = "https://files.pythonhosted.org/packages/42/80/82a935d78f74974f82d38e83fb02430f8e8cc09ad35e06d9a5d2e9b907a7/rpds_py-0.24.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:921ae54f9ecba3b6325df425cf72c074cd469dea843fb5743a26ca7fb2ccb149", size = 592924 }, + { url = "https://files.pythonhosted.org/packages/0d/49/b717e7b93c2ca881d2dac8b23b3a87a4c30f7c762bfd3df0b3953e655f13/rpds_py-0.24.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:32bab0a56eac685828e00cc2f5d1200c548f8bc11f2e44abf311d6b548ce2e45", size = 560847 }, + { url = "https://files.pythonhosted.org/packages/1e/26/ba630a291238e7f42d25bc5569d152623f18c21e9183e506585b23325c48/rpds_py-0.24.0-cp39-cp39-win32.whl", hash = "sha256:f5c0ed12926dec1dfe7d645333ea59cf93f4d07750986a586f511c0bc61fe103", size = 222570 }, + { url = "https://files.pythonhosted.org/packages/2d/84/01126e25e21f2ed6e63ec4030f78793dfee1a21aff1842136353c9caaed9/rpds_py-0.24.0-cp39-cp39-win_amd64.whl", hash = "sha256:afc6e35f344490faa8276b5f2f7cbf71f88bc2cda4328e00553bd451728c571f", size = 234931 }, + { url = "https://files.pythonhosted.org/packages/99/48/11dae46d0c7f7e156ca0971a83f89c510af0316cd5d42c771b7cef945f0c/rpds_py-0.24.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:619ca56a5468f933d940e1bf431c6f4e13bef8e688698b067ae68eb4f9b30e3a", size = 378224 }, + { url = "https://files.pythonhosted.org/packages/33/18/e8398d255369e35d312942f3bb8ecaff013c44968904891be2ab63b3aa94/rpds_py-0.24.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:4b28e5122829181de1898c2c97f81c0b3246d49f585f22743a1246420bb8d399", size = 363252 }, + { url = "https://files.pythonhosted.org/packages/17/39/dd73ba691f4df3e6834bf982de214086ac3359ab3ac035adfb30041570e3/rpds_py-0.24.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8e5ab32cf9eb3647450bc74eb201b27c185d3857276162c101c0f8c6374e098", size = 388871 }, + { url = "https://files.pythonhosted.org/packages/2f/2e/da0530b25cabd0feca2a759b899d2df325069a94281eeea8ac44c6cfeff7/rpds_py-0.24.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:208b3a70a98cf3710e97cabdc308a51cd4f28aa6e7bb11de3d56cd8b74bab98d", size = 394766 }, + { url = "https://files.pythonhosted.org/packages/4c/ee/dd1c5040a431beb40fad4a5d7868acf343444b0bc43e627c71df2506538b/rpds_py-0.24.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bbc4362e06f950c62cad3d4abf1191021b2ffaf0b31ac230fbf0526453eee75e", size = 448712 }, + { url = "https://files.pythonhosted.org/packages/f5/ec/6b93ffbb686be948e4d91ec76f4e6757f8551034b2a8176dd848103a1e34/rpds_py-0.24.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ebea2821cdb5f9fef44933617be76185b80150632736f3d76e54829ab4a3b4d1", size = 447150 }, + { url = "https://files.pythonhosted.org/packages/55/d5/a1c23760adad85b432df074ced6f910dd28f222b8c60aeace5aeb9a6654e/rpds_py-0.24.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9a4df06c35465ef4d81799999bba810c68d29972bf1c31db61bfdb81dd9d5bb", size = 390662 }, + { url = "https://files.pythonhosted.org/packages/a5/f3/419cb1f9bfbd3a48c256528c156e00f3349e3edce5ad50cbc141e71f66a5/rpds_py-0.24.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d3aa13bdf38630da298f2e0d77aca967b200b8cc1473ea05248f6c5e9c9bdb44", size = 421351 }, + { url = "https://files.pythonhosted.org/packages/98/8e/62d1a55078e5ede0b3b09f35e751fa35924a34a0d44d7c760743383cd54a/rpds_py-0.24.0-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:041f00419e1da7a03c46042453598479f45be3d787eb837af382bfc169c0db33", size = 566074 }, + { url = "https://files.pythonhosted.org/packages/fc/69/b7d1003166d78685da032b3c4ff1599fa536a3cfe6e5ce2da87c9c431906/rpds_py-0.24.0-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:d8754d872a5dfc3c5bf9c0e059e8107451364a30d9fd50f1f1a85c4fb9481164", size = 592398 }, + { url = "https://files.pythonhosted.org/packages/ea/a8/1c98bc99338c37faadd28dd667d336df7409d77b4da999506a0b6b1c0aa2/rpds_py-0.24.0-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:896c41007931217a343eff197c34513c154267636c8056fb409eafd494c3dcdc", size = 561114 }, + { url = "https://files.pythonhosted.org/packages/2b/41/65c91443685a4c7b5f1dd271beadc4a3e063d57c3269221548dd9416e15c/rpds_py-0.24.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:92558d37d872e808944c3c96d0423b8604879a3d1c86fdad508d7ed91ea547d5", size = 235548 }, + { url = "https://files.pythonhosted.org/packages/65/53/40bcc246a8354530d51a26d2b5b9afd1deacfb0d79e67295cc74df362f52/rpds_py-0.24.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f9e0057a509e096e47c87f753136c9b10d7a91842d8042c2ee6866899a717c0d", size = 378386 }, + { url = "https://files.pythonhosted.org/packages/80/b0/5ea97dd2f53e3618560aa1f9674e896e63dff95a9b796879a201bc4c1f00/rpds_py-0.24.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6e109a454412ab82979c5b1b3aee0604eca4bbf9a02693bb9df027af2bfa91a", size = 363440 }, + { url = "https://files.pythonhosted.org/packages/57/9d/259b6eada6f747cdd60c9a5eb3efab15f6704c182547149926c38e5bd0d5/rpds_py-0.24.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc1c892b1ec1f8cbd5da8de287577b455e388d9c328ad592eabbdcb6fc93bee5", size = 388816 }, + { url = "https://files.pythonhosted.org/packages/94/c1/faafc7183712f89f4b7620c3c15979ada13df137d35ef3011ae83e93b005/rpds_py-0.24.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c39438c55983d48f4bb3487734d040e22dad200dab22c41e331cee145e7a50d", size = 395058 }, + { url = "https://files.pythonhosted.org/packages/6c/96/d7fa9d2a7b7604a61da201cc0306a355006254942093779d7121c64700ce/rpds_py-0.24.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d7e8ce990ae17dda686f7e82fd41a055c668e13ddcf058e7fb5e9da20b57793", size = 448692 }, + { url = "https://files.pythonhosted.org/packages/96/37/a3146c6eebc65d6d8c96cc5ffdcdb6af2987412c789004213227fbe52467/rpds_py-0.24.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9ea7f4174d2e4194289cb0c4e172d83e79a6404297ff95f2875cf9ac9bced8ba", size = 446462 }, + { url = "https://files.pythonhosted.org/packages/1f/13/6481dfd9ac7de43acdaaa416e3a7da40bc4bb8f5c6ca85e794100aa54596/rpds_py-0.24.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb2954155bb8f63bb19d56d80e5e5320b61d71084617ed89efedb861a684baea", size = 390460 }, + { url = "https://files.pythonhosted.org/packages/61/e1/37e36bce65e109543cc4ff8d23206908649023549604fa2e7fbeba5342f7/rpds_py-0.24.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04f2b712a2206e13800a8136b07aaedc23af3facab84918e7aa89e4be0260032", size = 421609 }, + { url = "https://files.pythonhosted.org/packages/20/dd/1f1a923d6cd798b8582176aca8a0784676f1a0449fb6f07fce6ac1cdbfb6/rpds_py-0.24.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:eda5c1e2a715a4cbbca2d6d304988460942551e4e5e3b7457b50943cd741626d", size = 565818 }, + { url = "https://files.pythonhosted.org/packages/56/ec/d8da6df6a1eb3a418944a17b1cb38dd430b9e5a2e972eafd2b06f10c7c46/rpds_py-0.24.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:9abc80fe8c1f87218db116016de575a7998ab1629078c90840e8d11ab423ee25", size = 592627 }, + { url = "https://files.pythonhosted.org/packages/b3/14/c492b9c7d5dd133e13f211ddea6bb9870f99e4f73932f11aa00bc09a9be9/rpds_py-0.24.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6a727fd083009bc83eb83d6950f0c32b3c94c8b80a9b667c87f4bd1274ca30ba", size = 560885 }, + { url = "https://files.pythonhosted.org/packages/ef/e2/16cbbd7aaa4deaaeef5c90fee8b485c8b3312094cdad31e8006f5a3e5e08/rpds_py-0.24.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e0f3ef95795efcd3b2ec3fe0a5bcfb5dadf5e3996ea2117427e524d4fbf309c6", size = 378245 }, + { url = "https://files.pythonhosted.org/packages/d4/8c/5024dd105bf0a515576b7df8aeeba6556ffdbe2d636dee172c1a30497dd1/rpds_py-0.24.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:2c13777ecdbbba2077670285dd1fe50828c8742f6a4119dbef6f83ea13ad10fb", size = 363461 }, + { url = "https://files.pythonhosted.org/packages/a4/6f/3a4efcfa2f4391b69f5d0ed3e6be5d2c5468c24fd2d15b712d2dbefc1749/rpds_py-0.24.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79e8d804c2ccd618417e96720ad5cd076a86fa3f8cb310ea386a3e6229bae7d1", size = 388839 }, + { url = "https://files.pythonhosted.org/packages/6c/d2/b8e5f0a0e97d295a0ebceb5265ef2e44c3d55e0d0f938d64a5ecfffa715e/rpds_py-0.24.0-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd822f019ccccd75c832deb7aa040bb02d70a92eb15a2f16c7987b7ad4ee8d83", size = 394860 }, + { url = "https://files.pythonhosted.org/packages/90/e9/9f1f297bdbc5b871826ad790b6641fc40532d97917916e6bd9f87fdd128d/rpds_py-0.24.0-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0047638c3aa0dbcd0ab99ed1e549bbf0e142c9ecc173b6492868432d8989a046", size = 449314 }, + { url = "https://files.pythonhosted.org/packages/06/ad/62ddbbaead31a1a22f0332958d0ea7c7aeed1b2536c6a51dd66dfae321a2/rpds_py-0.24.0-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5b66d1b201cc71bc3081bc2f1fc36b0c1f268b773e03bbc39066651b9e18391", size = 446376 }, + { url = "https://files.pythonhosted.org/packages/82/a7/05b660d2f3789506e98be69aaf2ccde94e0fc49cd26cd78d7069bc5ba1b8/rpds_py-0.24.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbcbb6db5582ea33ce46a5d20a5793134b5365110d84df4e30b9d37c6fd40ad3", size = 390560 }, + { url = "https://files.pythonhosted.org/packages/66/1b/79fa0abffb802ff817821a148ce752eaaab87ba3a6a5e6b9f244c00c73d0/rpds_py-0.24.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:63981feca3f110ed132fd217bf7768ee8ed738a55549883628ee3da75bb9cb78", size = 421225 }, + { url = "https://files.pythonhosted.org/packages/6e/9b/368893ad2f7b2ece42cad87c7ec71309b5d93188db28b307eadb48cd28e5/rpds_py-0.24.0-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:3a55fc10fdcbf1a4bd3c018eea422c52cf08700cf99c28b5cb10fe97ab77a0d3", size = 566071 }, + { url = "https://files.pythonhosted.org/packages/41/75/1cd0a654d300449411e6fd0821f83c1cfc7223da2e8109f586b4d9b89054/rpds_py-0.24.0-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:c30ff468163a48535ee7e9bf21bd14c7a81147c0e58a36c1078289a8ca7af0bd", size = 592334 }, + { url = "https://files.pythonhosted.org/packages/31/33/5905e2a2e7612218e25307a9255fc8671b977449d40d62fe317775fe4939/rpds_py-0.24.0-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:369d9c6d4c714e36d4a03957b4783217a3ccd1e222cdd67d464a3a479fc17796", size = 561111 }, + { url = "https://files.pythonhosted.org/packages/64/bd/f4cc34ac2261a7cb8a48bc90ce1e36dc05f1ec5ac3b4537def20be5df555/rpds_py-0.24.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:24795c099453e3721fda5d8ddd45f5dfcc8e5a547ce7b8e9da06fecc3832e26f", size = 235168 }, +] + +[[package]] +name = "ruff" +version = "0.11.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/61/fb87430f040e4e577e784e325351186976516faef17d6fcd921fe28edfd7/ruff-0.11.2.tar.gz", hash = "sha256:ec47591497d5a1050175bdf4e1a4e6272cddff7da88a2ad595e1e326041d8d94", size = 3857511 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/99/102578506f0f5fa29fd7e0df0a273864f79af044757aef73d1cae0afe6ad/ruff-0.11.2-py3-none-linux_armv6l.whl", hash = "sha256:c69e20ea49e973f3afec2c06376eb56045709f0212615c1adb0eda35e8a4e477", size = 10113146 }, + { url = "https://files.pythonhosted.org/packages/74/ad/5cd4ba58ab602a579997a8494b96f10f316e874d7c435bcc1a92e6da1b12/ruff-0.11.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2c5424cc1c4eb1d8ecabe6d4f1b70470b4f24a0c0171356290b1953ad8f0e272", size = 10867092 }, + { url = "https://files.pythonhosted.org/packages/fc/3e/d3f13619e1d152c7b600a38c1a035e833e794c6625c9a6cea6f63dbf3af4/ruff-0.11.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ecf20854cc73f42171eedb66f006a43d0a21bfb98a2523a809931cda569552d9", size = 10224082 }, + { url = "https://files.pythonhosted.org/packages/90/06/f77b3d790d24a93f38e3806216f263974909888fd1e826717c3ec956bbcd/ruff-0.11.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c543bf65d5d27240321604cee0633a70c6c25c9a2f2492efa9f6d4b8e4199bb", size = 10394818 }, + { url = "https://files.pythonhosted.org/packages/99/7f/78aa431d3ddebfc2418cd95b786642557ba8b3cb578c075239da9ce97ff9/ruff-0.11.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20967168cc21195db5830b9224be0e964cc9c8ecf3b5a9e3ce19876e8d3a96e3", size = 9952251 }, + { url = "https://files.pythonhosted.org/packages/30/3e/f11186d1ddfaca438c3bbff73c6a2fdb5b60e6450cc466129c694b0ab7a2/ruff-0.11.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:955a9ce63483999d9f0b8f0b4a3ad669e53484232853054cc8b9d51ab4c5de74", size = 11563566 }, + { url = "https://files.pythonhosted.org/packages/22/6c/6ca91befbc0a6539ee133d9a9ce60b1a354db12c3c5d11cfdbf77140f851/ruff-0.11.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:86b3a27c38b8fce73bcd262b0de32e9a6801b76d52cdb3ae4c914515f0cef608", size = 12208721 }, + { url = "https://files.pythonhosted.org/packages/19/b0/24516a3b850d55b17c03fc399b681c6a549d06ce665915721dc5d6458a5c/ruff-0.11.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3b66a03b248c9fcd9d64d445bafdf1589326bee6fc5c8e92d7562e58883e30f", size = 11662274 }, + { url = "https://files.pythonhosted.org/packages/d7/65/76be06d28ecb7c6070280cef2bcb20c98fbf99ff60b1c57d2fb9b8771348/ruff-0.11.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0397c2672db015be5aa3d4dac54c69aa012429097ff219392c018e21f5085147", size = 13792284 }, + { url = "https://files.pythonhosted.org/packages/ce/d2/4ceed7147e05852876f3b5f3fdc23f878ce2b7e0b90dd6e698bda3d20787/ruff-0.11.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:869bcf3f9abf6457fbe39b5a37333aa4eecc52a3b99c98827ccc371a8e5b6f1b", size = 11327861 }, + { url = "https://files.pythonhosted.org/packages/c4/78/4935ecba13706fd60ebe0e3dc50371f2bdc3d9bc80e68adc32ff93914534/ruff-0.11.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2a2b50ca35457ba785cd8c93ebbe529467594087b527a08d487cf0ee7b3087e9", size = 10276560 }, + { url = "https://files.pythonhosted.org/packages/81/7f/1b2435c3f5245d410bb5dc80f13ec796454c21fbda12b77d7588d5cf4e29/ruff-0.11.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7c69c74bf53ddcfbc22e6eb2f31211df7f65054bfc1f72288fc71e5f82db3eab", size = 9945091 }, + { url = "https://files.pythonhosted.org/packages/39/c4/692284c07e6bf2b31d82bb8c32f8840f9d0627d92983edaac991a2b66c0a/ruff-0.11.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6e8fb75e14560f7cf53b15bbc55baf5ecbe373dd5f3aab96ff7aa7777edd7630", size = 10977133 }, + { url = "https://files.pythonhosted.org/packages/94/cf/8ab81cb7dd7a3b0a3960c2769825038f3adcd75faf46dd6376086df8b128/ruff-0.11.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:842a472d7b4d6f5924e9297aa38149e5dcb1e628773b70e6387ae2c97a63c58f", size = 11378514 }, + { url = "https://files.pythonhosted.org/packages/d9/3a/a647fa4f316482dacf2fd68e8a386327a33d6eabd8eb2f9a0c3d291ec549/ruff-0.11.2-py3-none-win32.whl", hash = "sha256:aca01ccd0eb5eb7156b324cfaa088586f06a86d9e5314b0eb330cb48415097cc", size = 10319835 }, + { url = "https://files.pythonhosted.org/packages/86/54/3c12d3af58012a5e2cd7ebdbe9983f4834af3f8cbea0e8a8c74fa1e23b2b/ruff-0.11.2-py3-none-win_amd64.whl", hash = "sha256:3170150172a8f994136c0c66f494edf199a0bbea7a409f649e4bc8f4d7084080", size = 11373713 }, + { url = "https://files.pythonhosted.org/packages/d6/d4/dd813703af8a1e2ac33bf3feb27e8a5ad514c9f219df80c64d69807e7f71/ruff-0.11.2-py3-none-win_arm64.whl", hash = "sha256:52933095158ff328f4c77af3d74f0379e34fd52f175144cefc1b192e7ccd32b4", size = 10441990 }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575 }, +] + +[[package]] +name = "termcolor" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/17/b3/b30506dc4297bb12e209ce455ae53c95f78470b442ae8ef83535cb9493e6/termcolor-3.0.0.tar.gz", hash = "sha256:0cd855c8716383f152ad02bbb39841d6e4694538ff5d424088e56c8b81fde525", size = 12916 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/38/a85380919a38f24b6f2b9b05b7b6970962351ef8d12d32bfbc34ab05380b/termcolor-3.0.0-py3-none-any.whl", hash = "sha256:fdfdc9f2bdb71c69fbbbaeb7ceae3afef0461076dd2ee265bf7b7c49ddb05ebb", size = 6290 }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, +] + +[[package]] +name = "tomlkit" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/09/a439bec5888f00a54b8b9f05fa94d7f901d6735ef4e55dcec9bc37b5d8fa/tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79", size = 192885 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/b6/a447b5e4ec71e13871be01ba81f5dfc9d0af7e473da256ff46bc0e24026f/tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde", size = 37955 }, +] + +[[package]] +name = "typing-extensions" +version = "4.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/ad/cd3e3465232ec2416ae9b983f27b9e94dc8171d56ac99b345319a9475967/typing_extensions-4.13.1.tar.gz", hash = "sha256:98795af00fb9640edec5b8e31fc647597b4691f099ad75f469a2616be1a76dff", size = 106633 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/c5/e7a0b0f5ed69f94c8ab7379c599e6036886bffcde609969a5325f47f1332/typing_extensions-4.13.1-py3-none-any.whl", hash = "sha256:4b6cf02909eb5495cfbc3f6e8fd49217e6cc7944e145cdda8caa3734777f9e69", size = 45739 }, +] + +[[package]] +name = "virtualenv" +version = "20.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/e0/633e369b91bbc664df47dcb5454b6c7cf441e8f5b9d0c250ce9f0546401e/virtualenv-20.30.0.tar.gz", hash = "sha256:800863162bcaa5450a6e4d721049730e7f2dae07720e0902b0e4040bd6f9ada8", size = 4346945 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/ed/3cfeb48175f0671ec430ede81f628f9fb2b1084c9064ca67ebe8c0ed6a05/virtualenv-20.30.0-py3-none-any.whl", hash = "sha256:e34302959180fca3af42d1800df014b35019490b119eba981af27f2fa486e5d6", size = 4329461 }, +] + +[[package]] +name = "wcwidth" +version = "0.2.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166 }, +] + +[[package]] +name = "wrapt" +version = "1.17.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/fc/e91cc220803d7bc4db93fb02facd8461c37364151b8494762cc88b0fbcef/wrapt-1.17.2.tar.gz", hash = "sha256:41388e9d4d1522446fe79d3213196bd9e3b301a336965b9e27ca2788ebd122f3", size = 55531 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/d1/1daec934997e8b160040c78d7b31789f19b122110a75eca3d4e8da0049e1/wrapt-1.17.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3d57c572081fed831ad2d26fd430d565b76aa277ed1d30ff4d40670b1c0dd984", size = 53307 }, + { url = "https://files.pythonhosted.org/packages/1b/7b/13369d42651b809389c1a7153baa01d9700430576c81a2f5c5e460df0ed9/wrapt-1.17.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b5e251054542ae57ac7f3fba5d10bfff615b6c2fb09abeb37d2f1463f841ae22", size = 38486 }, + { url = "https://files.pythonhosted.org/packages/62/bf/e0105016f907c30b4bd9e377867c48c34dc9c6c0c104556c9c9126bd89ed/wrapt-1.17.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:80dd7db6a7cb57ffbc279c4394246414ec99537ae81ffd702443335a61dbf3a7", size = 38777 }, + { url = "https://files.pythonhosted.org/packages/27/70/0f6e0679845cbf8b165e027d43402a55494779295c4b08414097b258ac87/wrapt-1.17.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a6e821770cf99cc586d33833b2ff32faebdbe886bd6322395606cf55153246c", size = 83314 }, + { url = "https://files.pythonhosted.org/packages/0f/77/0576d841bf84af8579124a93d216f55d6f74374e4445264cb378a6ed33eb/wrapt-1.17.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b60fb58b90c6d63779cb0c0c54eeb38941bae3ecf7a73c764c52c88c2dcb9d72", size = 74947 }, + { url = "https://files.pythonhosted.org/packages/90/ec/00759565518f268ed707dcc40f7eeec38637d46b098a1f5143bff488fe97/wrapt-1.17.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b870b5df5b71d8c3359d21be8f0d6c485fa0ebdb6477dda51a1ea54a9b558061", size = 82778 }, + { url = "https://files.pythonhosted.org/packages/f8/5a/7cffd26b1c607b0b0c8a9ca9d75757ad7620c9c0a9b4a25d3f8a1480fafc/wrapt-1.17.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4011d137b9955791f9084749cba9a367c68d50ab8d11d64c50ba1688c9b457f2", size = 81716 }, + { url = "https://files.pythonhosted.org/packages/7e/09/dccf68fa98e862df7e6a60a61d43d644b7d095a5fc36dbb591bbd4a1c7b2/wrapt-1.17.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:1473400e5b2733e58b396a04eb7f35f541e1fb976d0c0724d0223dd607e0f74c", size = 74548 }, + { url = "https://files.pythonhosted.org/packages/b7/8e/067021fa3c8814952c5e228d916963c1115b983e21393289de15128e867e/wrapt-1.17.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3cedbfa9c940fdad3e6e941db7138e26ce8aad38ab5fe9dcfadfed9db7a54e62", size = 81334 }, + { url = "https://files.pythonhosted.org/packages/4b/0d/9d4b5219ae4393f718699ca1c05f5ebc0c40d076f7e65fd48f5f693294fb/wrapt-1.17.2-cp310-cp310-win32.whl", hash = "sha256:582530701bff1dec6779efa00c516496968edd851fba224fbd86e46cc6b73563", size = 36427 }, + { url = "https://files.pythonhosted.org/packages/72/6a/c5a83e8f61aec1e1aeef939807602fb880e5872371e95df2137142f5c58e/wrapt-1.17.2-cp310-cp310-win_amd64.whl", hash = "sha256:58705da316756681ad3c9c73fd15499aa4d8c69f9fd38dc8a35e06c12468582f", size = 38774 }, + { url = "https://files.pythonhosted.org/packages/cd/f7/a2aab2cbc7a665efab072344a8949a71081eed1d2f451f7f7d2b966594a2/wrapt-1.17.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ff04ef6eec3eee8a5efef2401495967a916feaa353643defcc03fc74fe213b58", size = 53308 }, + { url = "https://files.pythonhosted.org/packages/50/ff/149aba8365fdacef52b31a258c4dc1c57c79759c335eff0b3316a2664a64/wrapt-1.17.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4db983e7bca53819efdbd64590ee96c9213894272c776966ca6306b73e4affda", size = 38488 }, + { url = "https://files.pythonhosted.org/packages/65/46/5a917ce85b5c3b490d35c02bf71aedaa9f2f63f2d15d9949cc4ba56e8ba9/wrapt-1.17.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9abc77a4ce4c6f2a3168ff34b1da9b0f311a8f1cfd694ec96b0603dff1c79438", size = 38776 }, + { url = "https://files.pythonhosted.org/packages/ca/74/336c918d2915a4943501c77566db41d1bd6e9f4dbc317f356b9a244dfe83/wrapt-1.17.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b929ac182f5ace000d459c59c2c9c33047e20e935f8e39371fa6e3b85d56f4a", size = 83776 }, + { url = "https://files.pythonhosted.org/packages/09/99/c0c844a5ccde0fe5761d4305485297f91d67cf2a1a824c5f282e661ec7ff/wrapt-1.17.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f09b286faeff3c750a879d336fb6d8713206fc97af3adc14def0cdd349df6000", size = 75420 }, + { url = "https://files.pythonhosted.org/packages/b4/b0/9fc566b0fe08b282c850063591a756057c3247b2362b9286429ec5bf1721/wrapt-1.17.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a7ed2d9d039bd41e889f6fb9364554052ca21ce823580f6a07c4ec245c1f5d6", size = 83199 }, + { url = "https://files.pythonhosted.org/packages/9d/4b/71996e62d543b0a0bd95dda485219856def3347e3e9380cc0d6cf10cfb2f/wrapt-1.17.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:129a150f5c445165ff941fc02ee27df65940fcb8a22a61828b1853c98763a64b", size = 82307 }, + { url = "https://files.pythonhosted.org/packages/39/35/0282c0d8789c0dc9bcc738911776c762a701f95cfe113fb8f0b40e45c2b9/wrapt-1.17.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1fb5699e4464afe5c7e65fa51d4f99e0b2eadcc176e4aa33600a3df7801d6662", size = 75025 }, + { url = "https://files.pythonhosted.org/packages/4f/6d/90c9fd2c3c6fee181feecb620d95105370198b6b98a0770cba090441a828/wrapt-1.17.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9a2bce789a5ea90e51a02dfcc39e31b7f1e662bc3317979aa7e5538e3a034f72", size = 81879 }, + { url = "https://files.pythonhosted.org/packages/8f/fa/9fb6e594f2ce03ef03eddbdb5f4f90acb1452221a5351116c7c4708ac865/wrapt-1.17.2-cp311-cp311-win32.whl", hash = "sha256:4afd5814270fdf6380616b321fd31435a462019d834f83c8611a0ce7484c7317", size = 36419 }, + { url = "https://files.pythonhosted.org/packages/47/f8/fb1773491a253cbc123c5d5dc15c86041f746ed30416535f2a8df1f4a392/wrapt-1.17.2-cp311-cp311-win_amd64.whl", hash = "sha256:acc130bc0375999da18e3d19e5a86403667ac0c4042a094fefb7eec8ebac7cf3", size = 38773 }, + { url = "https://files.pythonhosted.org/packages/a1/bd/ab55f849fd1f9a58ed7ea47f5559ff09741b25f00c191231f9f059c83949/wrapt-1.17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d5e2439eecc762cd85e7bd37161d4714aa03a33c5ba884e26c81559817ca0925", size = 53799 }, + { url = "https://files.pythonhosted.org/packages/53/18/75ddc64c3f63988f5a1d7e10fb204ffe5762bc663f8023f18ecaf31a332e/wrapt-1.17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fc7cb4c1c744f8c05cd5f9438a3caa6ab94ce8344e952d7c45a8ed59dd88392", size = 38821 }, + { url = "https://files.pythonhosted.org/packages/48/2a/97928387d6ed1c1ebbfd4efc4133a0633546bec8481a2dd5ec961313a1c7/wrapt-1.17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8fdbdb757d5390f7c675e558fd3186d590973244fab0c5fe63d373ade3e99d40", size = 38919 }, + { url = "https://files.pythonhosted.org/packages/73/54/3bfe5a1febbbccb7a2f77de47b989c0b85ed3a6a41614b104204a788c20e/wrapt-1.17.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bb1d0dbf99411f3d871deb6faa9aabb9d4e744d67dcaaa05399af89d847a91d", size = 88721 }, + { url = "https://files.pythonhosted.org/packages/25/cb/7262bc1b0300b4b64af50c2720ef958c2c1917525238d661c3e9a2b71b7b/wrapt-1.17.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d18a4865f46b8579d44e4fe1e2bcbc6472ad83d98e22a26c963d46e4c125ef0b", size = 80899 }, + { url = "https://files.pythonhosted.org/packages/2a/5a/04cde32b07a7431d4ed0553a76fdb7a61270e78c5fd5a603e190ac389f14/wrapt-1.17.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc570b5f14a79734437cb7b0500376b6b791153314986074486e0b0fa8d71d98", size = 89222 }, + { url = "https://files.pythonhosted.org/packages/09/28/2e45a4f4771fcfb109e244d5dbe54259e970362a311b67a965555ba65026/wrapt-1.17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6d9187b01bebc3875bac9b087948a2bccefe464a7d8f627cf6e48b1bbae30f82", size = 86707 }, + { url = "https://files.pythonhosted.org/packages/c6/d2/dcb56bf5f32fcd4bd9aacc77b50a539abdd5b6536872413fd3f428b21bed/wrapt-1.17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9e8659775f1adf02eb1e6f109751268e493c73716ca5761f8acb695e52a756ae", size = 79685 }, + { url = "https://files.pythonhosted.org/packages/80/4e/eb8b353e36711347893f502ce91c770b0b0929f8f0bed2670a6856e667a9/wrapt-1.17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e8b2816ebef96d83657b56306152a93909a83f23994f4b30ad4573b00bd11bb9", size = 87567 }, + { url = "https://files.pythonhosted.org/packages/17/27/4fe749a54e7fae6e7146f1c7d914d28ef599dacd4416566c055564080fe2/wrapt-1.17.2-cp312-cp312-win32.whl", hash = "sha256:468090021f391fe0056ad3e807e3d9034e0fd01adcd3bdfba977b6fdf4213ea9", size = 36672 }, + { url = "https://files.pythonhosted.org/packages/15/06/1dbf478ea45c03e78a6a8c4be4fdc3c3bddea5c8de8a93bc971415e47f0f/wrapt-1.17.2-cp312-cp312-win_amd64.whl", hash = "sha256:ec89ed91f2fa8e3f52ae53cd3cf640d6feff92ba90d62236a81e4e563ac0e991", size = 38865 }, + { url = "https://files.pythonhosted.org/packages/ce/b9/0ffd557a92f3b11d4c5d5e0c5e4ad057bd9eb8586615cdaf901409920b14/wrapt-1.17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6ed6ffac43aecfe6d86ec5b74b06a5be33d5bb9243d055141e8cabb12aa08125", size = 53800 }, + { url = "https://files.pythonhosted.org/packages/c0/ef/8be90a0b7e73c32e550c73cfb2fa09db62234227ece47b0e80a05073b375/wrapt-1.17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35621ae4c00e056adb0009f8e86e28eb4a41a4bfa8f9bfa9fca7d343fe94f998", size = 38824 }, + { url = "https://files.pythonhosted.org/packages/36/89/0aae34c10fe524cce30fe5fc433210376bce94cf74d05b0d68344c8ba46e/wrapt-1.17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a604bf7a053f8362d27eb9fefd2097f82600b856d5abe996d623babd067b1ab5", size = 38920 }, + { url = "https://files.pythonhosted.org/packages/3b/24/11c4510de906d77e0cfb5197f1b1445d4fec42c9a39ea853d482698ac681/wrapt-1.17.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cbabee4f083b6b4cd282f5b817a867cf0b1028c54d445b7ec7cfe6505057cf8", size = 88690 }, + { url = "https://files.pythonhosted.org/packages/71/d7/cfcf842291267bf455b3e266c0c29dcb675b5540ee8b50ba1699abf3af45/wrapt-1.17.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49703ce2ddc220df165bd2962f8e03b84c89fee2d65e1c24a7defff6f988f4d6", size = 80861 }, + { url = "https://files.pythonhosted.org/packages/d5/66/5d973e9f3e7370fd686fb47a9af3319418ed925c27d72ce16b791231576d/wrapt-1.17.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8112e52c5822fc4253f3901b676c55ddf288614dc7011634e2719718eaa187dc", size = 89174 }, + { url = "https://files.pythonhosted.org/packages/a7/d3/8e17bb70f6ae25dabc1aaf990f86824e4fd98ee9cadf197054e068500d27/wrapt-1.17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fee687dce376205d9a494e9c121e27183b2a3df18037f89d69bd7b35bcf59e2", size = 86721 }, + { url = "https://files.pythonhosted.org/packages/6f/54/f170dfb278fe1c30d0ff864513cff526d624ab8de3254b20abb9cffedc24/wrapt-1.17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:18983c537e04d11cf027fbb60a1e8dfd5190e2b60cc27bc0808e653e7b218d1b", size = 79763 }, + { url = "https://files.pythonhosted.org/packages/4a/98/de07243751f1c4a9b15c76019250210dd3486ce098c3d80d5f729cba029c/wrapt-1.17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:703919b1633412ab54bcf920ab388735832fdcb9f9a00ae49387f0fe67dad504", size = 87585 }, + { url = "https://files.pythonhosted.org/packages/f9/f0/13925f4bd6548013038cdeb11ee2cbd4e37c30f8bfd5db9e5a2a370d6e20/wrapt-1.17.2-cp313-cp313-win32.whl", hash = "sha256:abbb9e76177c35d4e8568e58650aa6926040d6a9f6f03435b7a522bf1c487f9a", size = 36676 }, + { url = "https://files.pythonhosted.org/packages/bf/ae/743f16ef8c2e3628df3ddfd652b7d4c555d12c84b53f3d8218498f4ade9b/wrapt-1.17.2-cp313-cp313-win_amd64.whl", hash = "sha256:69606d7bb691b50a4240ce6b22ebb319c1cfb164e5f6569835058196e0f3a845", size = 38871 }, + { url = "https://files.pythonhosted.org/packages/3d/bc/30f903f891a82d402ffb5fda27ec1d621cc97cb74c16fea0b6141f1d4e87/wrapt-1.17.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:4a721d3c943dae44f8e243b380cb645a709ba5bd35d3ad27bc2ed947e9c68192", size = 56312 }, + { url = "https://files.pythonhosted.org/packages/8a/04/c97273eb491b5f1c918857cd26f314b74fc9b29224521f5b83f872253725/wrapt-1.17.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:766d8bbefcb9e00c3ac3b000d9acc51f1b399513f44d77dfe0eb026ad7c9a19b", size = 40062 }, + { url = "https://files.pythonhosted.org/packages/4e/ca/3b7afa1eae3a9e7fefe499db9b96813f41828b9fdb016ee836c4c379dadb/wrapt-1.17.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e496a8ce2c256da1eb98bd15803a79bee00fc351f5dfb9ea82594a3f058309e0", size = 40155 }, + { url = "https://files.pythonhosted.org/packages/89/be/7c1baed43290775cb9030c774bc53c860db140397047cc49aedaf0a15477/wrapt-1.17.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d615e4fe22f4ad3528448c193b218e077656ca9ccb22ce2cb20db730f8d306", size = 113471 }, + { url = "https://files.pythonhosted.org/packages/32/98/4ed894cf012b6d6aae5f5cc974006bdeb92f0241775addad3f8cd6ab71c8/wrapt-1.17.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a5aaeff38654462bc4b09023918b7f21790efb807f54c000a39d41d69cf552cb", size = 101208 }, + { url = "https://files.pythonhosted.org/packages/ea/fd/0c30f2301ca94e655e5e057012e83284ce8c545df7661a78d8bfca2fac7a/wrapt-1.17.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a7d15bbd2bc99e92e39f49a04653062ee6085c0e18b3b7512a4f2fe91f2d681", size = 109339 }, + { url = "https://files.pythonhosted.org/packages/75/56/05d000de894c4cfcb84bcd6b1df6214297b8089a7bd324c21a4765e49b14/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e3890b508a23299083e065f435a492b5435eba6e304a7114d2f919d400888cc6", size = 110232 }, + { url = "https://files.pythonhosted.org/packages/53/f8/c3f6b2cf9b9277fb0813418e1503e68414cd036b3b099c823379c9575e6d/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8c8b293cd65ad716d13d8dd3624e42e5a19cc2a2f1acc74b30c2c13f15cb61a6", size = 100476 }, + { url = "https://files.pythonhosted.org/packages/a7/b1/0bb11e29aa5139d90b770ebbfa167267b1fc548d2302c30c8f7572851738/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c82b8785d98cdd9fed4cac84d765d234ed3251bd6afe34cb7ac523cb93e8b4f", size = 106377 }, + { url = "https://files.pythonhosted.org/packages/6a/e1/0122853035b40b3f333bbb25f1939fc1045e21dd518f7f0922b60c156f7c/wrapt-1.17.2-cp313-cp313t-win32.whl", hash = "sha256:13e6afb7fe71fe7485a4550a8844cc9ffbe263c0f1a1eea569bc7091d4898555", size = 37986 }, + { url = "https://files.pythonhosted.org/packages/09/5e/1655cf481e079c1f22d0cabdd4e51733679932718dc23bf2db175f329b76/wrapt-1.17.2-cp313-cp313t-win_amd64.whl", hash = "sha256:eaf675418ed6b3b31c7a989fd007fa7c3be66ce14e5c3b27336383604c9da85c", size = 40750 }, + { url = "https://files.pythonhosted.org/packages/8a/f4/6ed2b8f6f1c832933283974839b88ec7c983fd12905e01e97889dadf7559/wrapt-1.17.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:99039fa9e6306880572915728d7f6c24a86ec57b0a83f6b2491e1d8ab0235b9a", size = 53308 }, + { url = "https://files.pythonhosted.org/packages/a2/a9/712a53f8f4f4545768ac532619f6e56d5d0364a87b2212531685e89aeef8/wrapt-1.17.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2696993ee1eebd20b8e4ee4356483c4cb696066ddc24bd70bcbb80fa56ff9061", size = 38489 }, + { url = "https://files.pythonhosted.org/packages/fa/9b/e172c8f28a489a2888df18f953e2f6cb8d33b1a2e78c9dfc52d8bf6a5ead/wrapt-1.17.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:612dff5db80beef9e649c6d803a8d50c409082f1fedc9dbcdfde2983b2025b82", size = 38776 }, + { url = "https://files.pythonhosted.org/packages/cf/cb/7a07b51762dcd59bdbe07aa97f87b3169766cadf240f48d1cbe70a1be9db/wrapt-1.17.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62c2caa1585c82b3f7a7ab56afef7b3602021d6da34fbc1cf234ff139fed3cd9", size = 83050 }, + { url = "https://files.pythonhosted.org/packages/a5/51/a42757dd41032afd6d8037617aa3bc6803ba971850733b24dfb7d5c627c4/wrapt-1.17.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c958bcfd59bacc2d0249dcfe575e71da54f9dcf4a8bdf89c4cb9a68a1170d73f", size = 74718 }, + { url = "https://files.pythonhosted.org/packages/bf/bb/d552bfe47db02fcfc950fc563073a33500f8108efa5f7b41db2f83a59028/wrapt-1.17.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc78a84e2dfbc27afe4b2bd7c80c8db9bca75cc5b85df52bfe634596a1da846b", size = 82590 }, + { url = "https://files.pythonhosted.org/packages/77/99/77b06b3c3c410dbae411105bf22496facf03a5496bfaca8fbcf9da381889/wrapt-1.17.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ba0f0eb61ef00ea10e00eb53a9129501f52385c44853dbd6c4ad3f403603083f", size = 81462 }, + { url = "https://files.pythonhosted.org/packages/2d/21/cf0bd85ae66f92600829ea1de8e1da778e5e9f6e574ccbe74b66db0d95db/wrapt-1.17.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1e1fe0e6ab7775fd842bc39e86f6dcfc4507ab0ffe206093e76d61cde37225c8", size = 74309 }, + { url = "https://files.pythonhosted.org/packages/6d/16/112d25e9092398a0dd6fec50ab7ac1b775a0c19b428f049785096067ada9/wrapt-1.17.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c86563182421896d73858e08e1db93afdd2b947a70064b813d515d66549e15f9", size = 81081 }, + { url = "https://files.pythonhosted.org/packages/2b/49/364a615a0cc0872685646c495c7172e4fc7bf1959e3b12a1807a03014e05/wrapt-1.17.2-cp39-cp39-win32.whl", hash = "sha256:f393cda562f79828f38a819f4788641ac7c4085f30f1ce1a68672baa686482bb", size = 36423 }, + { url = "https://files.pythonhosted.org/packages/00/ad/5d2c1b34ba3202cd833d9221833e74d6500ce66730974993a8dc9a94fb8c/wrapt-1.17.2-cp39-cp39-win_amd64.whl", hash = "sha256:36ccae62f64235cf8ddb682073a60519426fdd4725524ae38874adf72b5f2aeb", size = 38772 }, + { url = "https://files.pythonhosted.org/packages/2d/82/f56956041adef78f849db6b289b282e72b55ab8045a75abad81898c28d19/wrapt-1.17.2-py3-none-any.whl", hash = "sha256:b18f2d1533a71f069c7f82d524a52599053d4c7166e9dd374ae2136b7f40f7c8", size = 23594 }, +] + +[[package]] +name = "zipp" +version = "3.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/50/bad581df71744867e9468ebd0bcd6505de3b275e06f202c2cb016e3ff56f/zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4", size = 24545 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/1a/7e4798e9339adc931158c9d69ecc34f5e6791489d469f5e50ec15e35f458/zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931", size = 9630 }, +]