pax_global_header00006660000000000000000000000064152031241730014510gustar00rootroot0000000000000052 comment=8913cc5ca5e035dc952e9e21bc142ed8e2f58c6a p1c2u-pathable-263aef1/000077500000000000000000000000001520312417300146315ustar00rootroot00000000000000p1c2u-pathable-263aef1/.github/000077500000000000000000000000001520312417300161715ustar00rootroot00000000000000p1c2u-pathable-263aef1/.github/FUNDING.yml000066400000000000000000000000201520312417300177760ustar00rootroot00000000000000github: [p1c2u] p1c2u-pathable-263aef1/.github/dependabot.yml000066400000000000000000000003151520312417300210200ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: "pip" directory: "/" schedule: interval: "weekly" - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" p1c2u-pathable-263aef1/.github/workflows/000077500000000000000000000000001520312417300202265ustar00rootroot00000000000000p1c2u-pathable-263aef1/.github/workflows/python-bench-baseline.yml000066400000000000000000000022311520312417300251250ustar00rootroot00000000000000name: CI / Benchmarks / Baseline on: push: branches: [master] workflow_dispatch: inputs: quick: description: "Run a shorter benchmark (fewer iterations)" required: false default: true type: boolean repeats: description: "Repeats per scenario (median is reported)" required: false default: "5" type: string warmup_loops: description: "Warmup passes before timing" required: false default: "1" type: string concurrency: group: bench-baseline-${{ github.ref }} cancel-in-progress: true jobs: baseline: name: "Bench baseline (master)" uses: ./.github/workflows/python-bench.yml with: suffix: baseline quick: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.quick == 'true' }} repeats: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.repeats || '5' }} warmup_loops: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.warmup_loops || '1' }} save_cache_key: bench-baseline-py3.12-${{ github.sha }} artifact_name: pathable-bench-baseline-results p1c2u-pathable-263aef1/.github/workflows/python-bench-regression.yml000066400000000000000000000043541520312417300255330ustar00rootroot00000000000000name: CI / Benchmarks / Regression on: pull_request: types: [opened, synchronize, reopened] concurrency: group: bench-pr-${{ github.ref }} cancel-in-progress: true jobs: head: name: "Bench head" uses: ./.github/workflows/python-bench.yml with: suffix: head quick: true repeats: "5" warmup_loops: "1" artifact_name: pathable-bench-pr-head compare: name: "Bench compare (PR vs baseline)" runs-on: ubuntu-latest needs: head env: BENCH_TOLERANCE: "0.20" steps: - uses: actions/checkout@v6 - name: Download head benchmark results uses: actions/download-artifact@v6 with: name: pathable-bench-pr-head path: reports - name: Restore baseline cache id: baseline-cache uses: actions/cache/restore@v4 with: path: | reports/bench-parse.baseline.json reports/bench-lookup.baseline.json key: bench-baseline-py3.12-${{ github.event.pull_request.base.sha }} restore-keys: | bench-baseline-py3.12- - name: Ensure baseline exists shell: bash run: | set -euo pipefail if [[ -f reports/bench-parse.baseline.json && -f reports/bench-lookup.baseline.json ]]; then exit 0 fi echo "Baseline benchmark cache not found for this repository." >&2 echo "Run the baseline workflow on master at least once to populate cache." >&2 exit 1 - name: Compare parse benchmark shell: bash run: | python tests/benchmarks/compare_results.py \ --baseline reports/bench-parse.baseline.json \ --candidate reports/bench-parse.head.json \ --tolerance "$BENCH_TOLERANCE" - name: Compare lookup benchmark shell: bash run: | python tests/benchmarks/compare_results.py \ --baseline reports/bench-lookup.baseline.json \ --candidate reports/bench-lookup.head.json \ --tolerance "$BENCH_TOLERANCE" - name: Upload comparison inputs uses: actions/upload-artifact@v6 with: name: pathable-bench-pr-results path: reports/bench-*.json p1c2u-pathable-263aef1/.github/workflows/python-bench.yml000066400000000000000000000056541520312417300233610ustar00rootroot00000000000000name: Benchmarks / Reusable on: workflow_call: inputs: suffix: required: true type: string git_ref: required: false type: string default: "" quick: required: false type: boolean default: true repeats: required: false type: string default: "3" warmup_loops: required: false type: string default: "0" save_cache_key: required: false type: string default: "" artifact_name: required: false type: string default: "pathable-bench-results" env: PYTHON_VERSION: "3.12" POETRY_VERSION: "2.2.1" PYTHONHASHSEED: "0" jobs: bench: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 with: ref: ${{ inputs.git_ref != '' && inputs.git_ref || github.sha }} - name: Set up Python uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_VERSION }} - name: Set up poetry uses: Gr1N/setup-poetry@v9 with: poetry-version: ${{ env.POETRY_VERSION }} - name: Configure poetry run: poetry config virtualenvs.in-project true - name: Set up cache uses: actions/cache@v5 id: cache with: path: .venv key: venv-bench-${{ runner.os }}-py${{ env.PYTHON_VERSION }}-${{ hashFiles('**/poetry.lock') }} - name: Ensure cache is healthy if: steps.cache.outputs.cache-hit == 'true' shell: bash run: timeout 10s poetry run pip --version || rm -rf .venv - name: Install dependencies run: poetry install --no-interaction - name: Run parse benchmark run: | poetry run python -m tests.benchmarks.bench_parse \ --output "reports/bench-parse.${{ inputs.suffix }}.json" \ ${{ inputs.quick && '--quick' || '' }} \ --repeats "${{ inputs.repeats }}" \ --warmup-loops "${{ inputs.warmup_loops }}" - name: Run lookup benchmark run: | poetry run python -m tests.benchmarks.bench_lookup \ --output "reports/bench-lookup.${{ inputs.suffix }}.json" \ ${{ inputs.quick && '--quick' || '' }} \ --repeats "${{ inputs.repeats }}" \ --warmup-loops "${{ inputs.warmup_loops }}" - name: Save benchmark cache if: inputs.save_cache_key != '' uses: actions/cache/save@v4 with: path: | reports/bench-parse.${{ inputs.suffix }}.json reports/bench-lookup.${{ inputs.suffix }}.json key: ${{ inputs.save_cache_key }} - name: Upload benchmark results uses: actions/upload-artifact@v6 with: name: ${{ inputs.artifact_name }} path: | reports/bench-parse.${{ inputs.suffix }}.json reports/bench-lookup.${{ inputs.suffix }}.json p1c2u-pathable-263aef1/.github/workflows/python-publish.yml000066400000000000000000000015471520312417300237450ustar00rootroot00000000000000# This workflow will upload a Python Package using Twine when a release is created # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries name: CI / Publish on: workflow_dispatch: release: types: - published jobs: publish_pypi: name: "PyPI" runs-on: ubuntu-latest permissions: id-token: write steps: - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: python-version: '3.x' - name: Set up poetry uses: Gr1N/setup-poetry@v9 with: poetry-version: "2.2.1" - name: Build run: poetry build - name: Publish uses: pypa/gh-action-pypi-publish@release/v1 with: packages-dir: dist/ p1c2u-pathable-263aef1/.github/workflows/python-tests.yml000066400000000000000000000040751520312417300234400ustar00rootroot00000000000000# This workflow will install Python dependencies, run tests and lint with a variety of Python versions # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions name: CI / Tests on: push: pull_request: types: [opened, synchronize] jobs: test: name: "py${{ matrix.python-version }}" runs-on: ubuntu-latest strategy: matrix: python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] fail-fast: false steps: - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Get full Python version id: full-python-version shell: bash run: | version="$(python -c 'import sys; print("-".join(map(str, sys.version_info)))')" echo "version=$version" >> "$GITHUB_OUTPUT" - name: Set up poetry uses: Gr1N/setup-poetry@v9 with: poetry-version: "2.2.1" - name: Configure poetry run: poetry config virtualenvs.in-project true - name: Set up cache uses: actions/cache@v5 id: cache with: path: .venv key: venv-${{ github.event_name }}-${{ runner.os }}-${{ steps.full-python-version.outputs.version }}-${{ hashFiles('**/poetry.lock') }} - name: Ensure cache is healthy if: steps.cache.outputs.cache-hit == 'true' shell: bash run: timeout 10s poetry run pip --version || rm -rf .venv - name: Install dependencies run: poetry install - name: Format check (black) run: poetry run black --check pathable tests - name: Import order check (isort) run: poetry run isort --check-only --filter-files pathable tests - name: Test env: PYTEST_ADDOPTS: "--color=yes" run: poetry run pytest - name: Static type check run: poetry run mypy - name: Upload coverage uses: codecov/codecov-action@v5 p1c2u-pathable-263aef1/.gitignore000066400000000000000000000034201520312417300166200ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ reports/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ p1c2u-pathable-263aef1/.pre-commit-config.yaml000066400000000000000000000017511520312417300211160ustar00rootroot00000000000000--- default_stages: [pre-commit, pre-push] default_language_version: # force all unspecified python hooks to run python3 python: python3 minimum_pre_commit_version: "1.20.0" repos: - repo: meta hooks: - id: check-hooks-apply - repo: https://github.com/asottile/pyupgrade rev: v2.19.0 hooks: - id: pyupgrade args: ["--py36-plus"] # - repo: https://github.com/pre-commit/mirrors-mypy # rev: v0.971 # hooks: # - id: mypy # args: ["pathable"] - repo: local hooks: - id: flynt name: Convert to f-strings with flynt entry: flynt language: python additional_dependencies: ['flynt==0.76'] - id: black name: black entry: black language: system require_serial: true types: [python] - id: isort name: isort entry: isort args: ['--filter-files'] language: system require_serial: true types: [python] p1c2u-pathable-263aef1/AGENTS.md000066400000000000000000000067731520312417300161510ustar00rootroot00000000000000# Agent Notes for pathable This file is a concise, repo-specific guide for agentic coding tools. If anything here conflicts with code or CI, follow the code/CI. ## Quick facts - Primary language: Python (>=3.10) - Build/packaging: Poetry - Tests: pytest + coverage - Lint/format: black, isort, flynt (pre-commit) - Types: mypy (strict) ## Build, lint, and test commands ### Setup - Create virtualenv and install deps: `poetry install` - Optional dev hooks: `poetry install --with dev` - Pre-commit hooks: `poetry run pre-commit install` ### Tests - Run full suite: `poetry run pytest` - Run a single test file: `poetry run pytest tests/unit/test_paths.py` - Run a single test function: `poetry run pytest tests/unit/test_paths.py::TestBasePathInit::test_default` - Run tests by keyword: `poetry run pytest -k "BasePath"` - Coverage reports are written to: - `reports/junit.xml` - `reports/coverage.xml` ### Type checking - Run mypy: `poetry run mypy` ### Formatting / linting - Black format: `poetry run black pathable tests` - isort (single-line imports): `poetry run isort --filter-files pathable tests` - flynt (f-string conversion): `poetry run flynt pathable tests` - All hooks (recommended): `poetry run pre-commit run --all-files` ## Code style guidelines ### Imports - Prefer absolute imports from `pathable.*`. - Group imports: standard library, third-party, then local. - Use one import per line (isort is configured with `force_single_line = true`). - Keep ordering stable; let isort manage it. ### Formatting - Black is authoritative; line length is 79. - Favor black-compatible formatting in new code. - Keep docstrings short and focused; use triple double quotes. ### Types - Mypy runs in strict mode; add type hints for public APIs. - Use `typing` generics and `TypeVar` where appropriate (see `pathable/accessors.py`). - Return precise types instead of `Any` unless the API requires it. - Prefer `Optional[T]` or `Union[T, None]` over implicit `None`. ### Naming - Classes: `PascalCase`. - Functions/vars: `snake_case`. - Constants: `UPPER_CASE`. - Type variables: short, uppercase (`T`, `K`, `V`, etc.). ### Error handling - Use precise exceptions (`TypeError`, `ValueError`, `KeyError`, `AttributeError`). - Avoid broad `except Exception` unless re-raising with context. - Ensure exceptions are deterministic and message text is stable. ### Data model and patterns - Paths are immutable; methods return new instances (see `BasePath`). - Preserve cached properties and avoid side effects in `@cached_property`. - Use `@dataclass(frozen=True, init=False)` where immutability is intended. - Keep public API methods small and composable. ### Strings and bytes - Treat bytes as ASCII when decoding path parts (see `parse_args`). - Prefer f-strings for formatting; flynt can help. ### Testing - Tests use pytest; keep tests explicit and readable. - Favor direct assertions, not helper wrappers. - Use deterministic inputs; avoid reliance on filesystem unless necessary. ## Repository conventions - Code lives under `pathable/`. - Tests live under `tests/`. - CI runs: `pytest` and `mypy` (see `.github/workflows/python-test.yml`). - Formatting is enforced by pre-commit hooks in `.pre-commit-config.yaml`. ## Rules files - No Cursor rules found in `.cursor/rules/` or `.cursorrules`. - No Copilot rules found in `.github/copilot-instructions.md`. ## Notes for agents - Prefer editing minimal sections; do not reformat unrelated code. - Run targeted tests when possible (single file or single test). - Keep changes compatible with Python 3.10+. p1c2u-pathable-263aef1/LICENSE000066400000000000000000000261351520312417300156450ustar00rootroot00000000000000 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), even 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. p1c2u-pathable-263aef1/MANIFEST.in000066400000000000000000000000741520312417300163700ustar00rootroot00000000000000include LICENSE include README.md include pathable/py.typed p1c2u-pathable-263aef1/README.md000066400000000000000000000140751520312417300161170ustar00rootroot00000000000000# pathable [![Package version](https://img.shields.io/pypi/v/pathable.svg)](https://pypi.org/project/pathable/) [![Python versions](https://img.shields.io/pypi/pyversions/pathable.svg)](https://pypi.org/project/pathable/) [![License](https://img.shields.io/pypi/l/pathable.svg)](https://pypi.org/project/pathable/) ## About Pathable provides a small set of "path" objects for traversing hierarchical data (mappings, lists, and other subscriptable trees) using a familiar path-like syntax. It’s especially handy when you want to: * express deep lookups as a single object (and pass it around) * build paths incrementally (`p / "a" / 0 / "b"`) * safely probe (`exists()`, `get(...)`) or strictly require segments (`//`) ## Key features * Intuitive path-based navigation for nested data (e.g., dicts/lists) * Pluggable accessor layer for custom backends * Pythonic, chainable API for concise and readable code * Per-instance (bounded LRU) cached lookup accessor for repeated reads of the same tree ## Quickstart ```python from pathable import LookupPath data = { "parts": { "part1": {"name": "Part One"}, "part2": {"name": "Part Two"}, } } root = LookupPath.from_lookup(data) name = (root / "parts" / "part2" / "name").read_value() assert name == "Part Two" ``` ## Usage ```python from pathable import LookupPath data = { "parts": { "part1": {"name": "Part One"}, "part2": {"name": "Part Two"}, } } p = LookupPath.from_lookup(data) # Concatenate path segments with / parts = p / "parts" # Check membership (mapping keys or list indexes) assert "part2" in parts # Read a value assert (parts / "part2" / "name").read_value() == "Part Two" # Iterate children as paths for child in parts: print(child, child.read_value()) # Work with keys/items print(list(parts.keys())) print({k: v.read_value() for k, v in parts.items()}) # Safe access print(parts.get("missing", default=None)) # Strict access (raises KeyError if missing) must_exist = parts // "part2" # "Open" yields the current value as a context manager with parts.open() as parts_value: assert isinstance(parts_value, dict) # Optional metadata print(parts.stat()) ``` ## Filesystem example Pathable can also traverse the filesystem via an accessor. ```python from pathlib import Path from pathable import FilesystemPath root_dir = Path(".") p = FilesystemPath.from_path(root_dir) readme = p / "README.md" if readme.exists(): content = readme.read_value() # bytes print(content[:100]) ``` ## Core concepts * `BasePath` is a pure path (segments + separator) with `/` joining. * `AccessorPath` is a `BasePath` bound to a `NodeAccessor`, enabling `read_value()`, `exists()`, `keys()`, iteration, etc. * `FilesystemPath` is an `AccessorPath` specialized for filesystem objects. * `LookupPath` is an `AccessorPath` specialized for mapping/list lookups. Notes on parsing: * A segment like `"a/b"` is split into parts using the separator. * `None` segments are ignored. * `"."` segments are ignored (relative no-op). * Operations like `relative_to()` and `is_relative_to()` also respect the instance separator. Equality and ordering: * Two `BasePath` instances are equal if their `parts` are equal. The separator is presentation only β€” `BasePath("a", separator="/") == BasePath("a", separator=".")`. * Two `AccessorPath` instances are equal if they have equal `parts` *and* their accessors compare equal under the accessor's own `__eq__`. A plain `BasePath` is never equal to an `AccessorPath`. * Path parts are type-sensitive (`0` is not equal to `"0"`). * Ordering is address-based: separator is not part of the order, and it remains deterministic across mixed part types. For `AccessorPath`, different bindings with the same `parts` may compare ordering-equivalent while remaining unequal. Identity and lifecycle: * Build one accessor per resource and reuse it for every path you derive. `LookupPath.from_lookup(data)` constructs a fresh accessor on each call, which is convenient for one-off use but defeats the cache when called repeatedly over the same data: ```python from pathable import LookupPath from pathable.accessors import LookupAccessor # Construct the accessor once, reuse it. accessor = LookupAccessor(data) root = LookupPath(accessor) # Every path derived from `accessor` shares its cache. a = root / "parts" / "part1" b = root / "parts" / "part1" assert a == b ``` * `path.is_same_binding(other)` is a stricter version of `==` that additionally requires both paths to share the *same accessor instance* (object identity), not just an `==`-equal one. Use it when you need to verify cache attribution or detect accidental accessor swaps. Lookup caching: * `LookupPath` uses a per-instance LRU cache (default maxsize: 128) on its accessor. * You can control it via `path.accessor.clear_cache()`, `path.accessor.disable_cache()`, and `path.accessor.enable_cache(maxsize=...)`. * `path.accessor.node` is immutable; to point at a different tree, create a new `LookupPath`/accessor. ## Installation Recommended way (via pip): ``` console pip install pathable ``` Alternatively you can download the code and install from the repository: ``` console pip install -e git+https://github.com/p1c2u/pathable.git#egg=pathable ``` ## Benchmarks Benchmarks live in `tests/benchmarks/` and produce JSON reports. Local run (recommended as modules): ```console poetry run python -m tests.benchmarks.bench_parse --output reports/bench-parse.json poetry run python -m tests.benchmarks.bench_lookup --output reports/bench-lookup.json ``` Quick sanity run: ```console poetry run python -m tests.benchmarks.bench_parse --quick --output reports/bench-parse.quick.json poetry run python -m tests.benchmarks.bench_lookup --quick --output reports/bench-lookup.quick.json ``` Compare two results (fails if candidate is >20% slower in any scenario): ```console poetry run python -m tests.benchmarks.compare_results \ --baseline reports/bench-before.json \ --candidate reports/bench-after.json \ --tolerance 0.20 ``` CI (on-demand): - GitHub Actions workflow `Benchmarks` runs via `workflow_dispatch` and uploads the JSON artifacts. p1c2u-pathable-263aef1/pathable/000077500000000000000000000000001520312417300164115ustar00rootroot00000000000000p1c2u-pathable-263aef1/pathable/__init__.py000066400000000000000000000013041520312417300205200ustar00rootroot00000000000000"""Pathable module""" from pathable.accessors import NodeAccessor from pathable.accessors import PathAccessor from pathable.paths import AccessorPath from pathable.paths import BasePath from pathable.paths import FilesystemPath from pathable.paths import LookupPath from pathable.paths import LookupPath as DictPath from pathable.paths import LookupPath as ListPath __author__ = "Artur Maciag" __email__ = "maciag.artur@gmail.com" __version__ = "0.6.0" __url__ = "https://github.com/p1c2u/pathable" __license__ = "Apache License, Version 2.0" __all__ = [ "BasePath", "AccessorPath", "FilesystemPath", "LookupPath", "DictPath", "ListPath", "NodeAccessor", "PathAccessor", ] p1c2u-pathable-263aef1/pathable/accessors.py000066400000000000000000000350171520312417300207560ustar00rootroot00000000000000"""Pathable accessors module""" import stat from collections import OrderedDict from collections.abc import Hashable from collections.abc import Mapping from collections.abc import Sequence from pathlib import Path from typing import Any from typing import Generic from typing import TypeVar from pathable.protocols import Subscriptable from pathable.types import LookupKey from pathable.types import LookupNode from pathable.types import LookupValue K = TypeVar("K", bound=Hashable, contravariant=True) V = TypeVar("V", covariant=True) N = TypeVar("N") SK = TypeVar("SK", bound=Hashable, contravariant=True) SV = TypeVar("SV", covariant=True) CSK = TypeVar("CSK", bound=Hashable, contravariant=True) CSV = TypeVar("CSV", covariant=True) class NodeAccessor(Generic[N, K, V]): """Node accessor.""" def __init__(self, node: N): self._node = node @property def node(self) -> N: return self._node def __getitem__(self, parts: Sequence[K]) -> N: return self._get_node(self.node, parts) def __eq__(self, other: object) -> Any: if not isinstance(other, NodeAccessor): return NotImplemented # Object identity is the only universally-correct default. The # base accessor cannot know what makes a wrapped resource "the # same" β€” that's a per-resource-type question. Subclasses that # represent a resource with a canonical name (URL, filesystem # path, storage options, in-memory object reference) override # both __eq__ and __hash__ in lockstep. return self is other def __hash__(self) -> int: return object.__hash__(self) def stat(self, parts: Sequence[K]) -> dict[str, Any] | None: raise NotImplementedError def keys(self, parts: Sequence[K]) -> Sequence[K]: """Return the keys of the node at `parts` if it is traversable, or raise `KeyError` if not. This performs a segment-by-segment traversal and raises `KeyError` with the failing segment if any part is missing or non-traversable. """ raise NotImplementedError def is_traversable(self, parts: Sequence[K]) -> bool: """Return True if the node at `parts` can enumerate child keys. This is intended for control-flow ("can I call keys()/len()/iterate?") and must not raise for missing or non-traversable paths, but may raise OSError for permission or I/O errors. The default implementation attempts a cheap node inspection via `_is_traversable_node` after traversing to the node. If that is not implemented, it falls back to calling `keys()`. Note: the fallback may be expensive for accessors where `keys()` enumerates large containers. """ try: node = self[parts] except KeyError: return False except NotImplementedError: try: self.keys(parts) except (KeyError, IndexError, TypeError, NotImplementedError): return False return True try: return self._is_traversable_node(node) except NotImplementedError: try: self.keys(parts) except (KeyError, IndexError, TypeError, NotImplementedError): return False return True def contains(self, parts: Sequence[K], key: K) -> bool: """Return True if `key` is a valid child of the node at `parts`. The default implementation tries to validate membership by traversing a single step (fast for accessors that implement `_get_subnode`). If traversal isn't available, it falls back to `keys()` for compatibility with accessors that only define enumeration. This method is intended to be used for membership checks (e.g. `key in path`) where errors should not be raised. """ try: parent = self[parts] try: self._get_subnode(parent, key) except (KeyError, IndexError, TypeError): return False return True except KeyError: return False except NotImplementedError: try: return key in self.keys(parts) except (KeyError, IndexError, TypeError): return False def require_child(self, parts: Sequence[K], key: K) -> None: """Assert that `key` is a valid child of the node at `parts`. Raises `KeyError` with stable diagnostics. """ try: # Validate the parent first to preserve intermediate segment # diagnostics. parent = self[parts] try: self._get_subnode(parent, key) return except KeyError as exc: raise KeyError(key) from exc except NotImplementedError: keys = self.keys(parts) if key not in keys: raise KeyError(key) def len(self, parts: Sequence[K]) -> int: raise NotImplementedError def read(self, parts: Sequence[K]) -> V: node = self[parts] return self._read_node(node) def validate(self, parts: Sequence[K]) -> None: """Validate that the node at `parts` exists. This performs a traversal only and raises `KeyError` (with the failing part when available) if the path is missing or non-traversable. """ self[parts] @classmethod def _is_traversable_node(cls, node: N) -> bool: raise NotImplementedError @classmethod def _get_node(cls, node: N, parts: Sequence[K]) -> N: current = node get_subnode = cls._get_subnode for part in parts: current = get_subnode(current, part) return current @classmethod def _read_node(cls, node: N) -> V: raise NotImplementedError @classmethod def _get_subnode(cls, node: N, part: K) -> N: raise NotImplementedError class PathAccessor(NodeAccessor[Path, str, bytes]): def __eq__(self, other: object) -> Any: if not isinstance(other, PathAccessor): return NotImplemented # pathlib.Path is hashable and value-equal on its canonical # string form, so PathAccessor can use value-equality on the # wrapped Path. Same-class check keeps behavioral subclasses # in their own equivalence class. return type(self) is type(other) and self._node == other._node def __hash__(self) -> int: return hash((type(self), self._node)) def stat(self, parts: Sequence[str]) -> dict[str, Any] | None: subpath = self.node.joinpath(*parts) try: stat = subpath.stat(follow_symlinks=False) except OSError: return None return { key: getattr(stat, key) for key in dir(stat) if key.startswith("st_") } def keys(self, parts: Sequence[str]) -> Sequence[str]: # Traverse using `get()` so missing intermediate segments are # reported by `_get_subnode()` with the first failing part. subpath = self[parts] try: return [path.name for path in subpath.iterdir()] except (FileNotFoundError, NotADirectoryError) as exc: if parts: raise KeyError(parts[-1]) from exc raise KeyError from exc @classmethod def _is_traversable_node(cls, node: Path) -> bool: # Avoid following symlinks for consistency with stat() # Use lstat to check the symlink itself, not its target try: return stat.S_ISDIR(node.lstat().st_mode) except OSError: return False def contains(self, parts: Sequence[str], key: str) -> bool: try: subpath = self[parts] except KeyError: return False return (subpath / key).exists() def require_child(self, parts: Sequence[str], key: str) -> None: subpath = self[parts] if not subpath.is_dir(): if parts: raise KeyError(parts[-1]) raise KeyError child = subpath / key if not child.exists(): raise KeyError(key) def len(self, parts: Sequence[str]) -> int: # Traverse using `get()` so missing intermediate segments are # reported by `_get_subnode()` with the first failing part. subpath = self[parts] try: return sum(1 for _ in subpath.iterdir()) except (FileNotFoundError, NotADirectoryError) as exc: if parts: raise KeyError(parts[-1]) from exc raise KeyError from exc def read(self, parts: Sequence[str]) -> bytes: node = self[parts] return self._read_node(node) @classmethod def _read_node(cls, node: Path) -> bytes: return node.read_bytes() @classmethod def _get_subnode(cls, node: Path, part: str) -> Path: subnode = node / part if not subnode.exists(): raise KeyError(part) return subnode class SubscriptableAccessor( NodeAccessor[Subscriptable[SK, SV] | SV, SK, SV], Generic[SK, SV] ): """Accessor for subscriptable content.""" @classmethod def _get_subnode( cls, node: Subscriptable[SK, SV] | SV, part: SK ) -> Subscriptable[SK, SV] | SV: if not isinstance(node, Subscriptable): raise KeyError(part) try: return node[part] except (KeyError, IndexError, TypeError) as exc: raise KeyError(part) from exc class CachedSubscriptableAccessor( SubscriptableAccessor[CSK, CSV], Generic[CSK, CSV] ): def __init__(self, node: Subscriptable[CSK, CSV] | CSV): super().__init__(node) # Per-instance cache: avoids global strong references and id-reuse hazards. # Default maxsize matches functools.lru_cache default (128). self._cache_enabled = True self._cache_maxsize: int | None = 128 self._cache: OrderedDict[tuple[CSK, ...], CSV] = OrderedDict() def clear_cache(self) -> None: """Clear any cached reads for this accessor instance.""" self._cache.clear() def disable_cache(self) -> None: """Disable caching for this accessor instance.""" self._cache_enabled = False self._cache.clear() def enable_cache(self, *, maxsize: int | None = 128) -> None: """Enable caching for this accessor instance. Args: maxsize: Maximum number of distinct paths to cache. - 128 by default (matches functools.lru_cache) - None for unbounded - 0 to disable caching """ self._cache_enabled = True self._cache_maxsize = maxsize self._cache.clear() def read(self, parts: Sequence[CSK]) -> CSV: key = tuple(parts) if (not self._cache_enabled) or self._cache_maxsize == 0: node = self[parts] return self._read_node(node) try: value = self._cache[key] except KeyError: node = self[parts] value = self._read_node(node) self._cache[key] = value else: # Mark as recently used. self._cache.move_to_end(key) return value # Enforce max size (LRU eviction). if self._cache_maxsize is not None: while len(self._cache) > self._cache_maxsize: self._cache.popitem(last=False) return value class LookupAccessor(CachedSubscriptableAccessor[LookupKey, LookupValue]): def __eq__(self, other: object) -> Any: if not isinstance(other, LookupAccessor): return NotImplemented # The wrapped node is typically an anonymous, mutable, unhashable # container (dict, list). Its only canonical identity is its # object reference: two LookupAccessors over the same Python # object refer to the same logical resource; two over distinct # value-equal objects do not. id() is safe in __hash__ because # the accessor holds a strong reference to _node for its lifetime. return type(self) is type(other) and self._node is other._node def __hash__(self) -> int: return hash((type(self), id(self._node))) @classmethod def _is_traversable_node(cls, node: LookupNode) -> bool: return isinstance(node, Mapping | list) def stat(self, parts: Sequence[LookupKey]) -> dict[str, Any] | None: try: node = self[parts] except KeyError: return None length: int | None match node: case Mapping() | list(): length = len(node) case _: try: length = len(node) except TypeError: length = None return { "type": type(node).__name__, "length": length, } def contains(self, parts: Sequence[LookupKey], key: LookupKey) -> bool: try: node = self[parts] except KeyError: return False match node: case Mapping(): return key in node case list() as items: return isinstance(key, int) and 0 <= key < len(items) case _: return False def require_child( self, parts: Sequence[LookupKey], key: LookupKey ) -> None: # Validate parent path for intermediate diagnostics. node = self[parts] match node: case Mapping(): if key not in node: raise KeyError(key) return case list() as items: if not (isinstance(key, int) and 0 <= key < len(items)): raise KeyError(key) return case _: raise KeyError(key) def keys(self, parts: Sequence[LookupKey]) -> Sequence[LookupKey]: node = self[parts] match node: case Mapping(): return list(node.keys()) case list() as items: return list(range(len(items))) # Non-traversable leaf. if parts: raise KeyError(parts[-1]) raise KeyError def len(self, parts: Sequence[LookupKey]) -> int: node = self[parts] # Define length as the number of child paths (consistent with keys()). if self._is_traversable_node(node): return len(node) # Non-traversable leaf. if parts: raise KeyError(parts[-1]) raise KeyError @classmethod def _read_node(cls, node: LookupNode) -> LookupValue: return node p1c2u-pathable-263aef1/pathable/parsers.py000066400000000000000000000031301520312417300204370ustar00rootroot00000000000000"""Pathable parsers module""" from collections.abc import Hashable from typing import Sequence SEPARATOR = "/" def parse_parts( parts: Sequence[Hashable | None], sep: str = SEPARATOR ) -> list[Hashable]: """Parse (filter and split) path parts.""" parsed: list[Hashable] = [] append = parsed.append for part in parts: if part is None: continue # Fast-path: int is common and never needs splitting/decoding. if isinstance(part, int): append(part) continue # Fast-path: str is most common. if isinstance(part, str): if not part or part == ".": continue if sep in part: for split_part in part.split(sep): if split_part and split_part != ".": append(split_part) continue append(part) continue # Fast-path: bytes, decode then treat as str. if isinstance(part, bytes): text = part.decode("ascii") if not text or text == ".": continue if sep in text: for split_part in text.split(sep): if split_part and split_part != ".": append(split_part) continue append(text) continue # Fallback: Hashable (covers e.g. tuple, custom keys). if isinstance(part, Hashable): append(part) continue raise TypeError(f"part must be Hashable or None; got {type(part)!r}") return parsed p1c2u-pathable-263aef1/pathable/paths.py000066400000000000000000000530141520312417300201050ustar00rootroot00000000000000"""Pathable paths module""" import os from collections.abc import Hashable from collections.abc import Iterator from contextlib import contextmanager from dataclasses import dataclass from functools import cached_property from pathlib import Path from typing import Any from typing import Generic from typing import Sequence from typing import TypeVar from typing import cast from typing import overload from pathable.accessors import K from pathable.accessors import LookupAccessor from pathable.accessors import N from pathable.accessors import NodeAccessor from pathable.accessors import PathAccessor from pathable.accessors import V from pathable.parsers import SEPARATOR from pathable.parsers import parse_parts from pathable.types import LookupKey from pathable.types import LookupNode from pathable.types import LookupValue # Python 3.11+ shortcut: typing.Self TBasePath = TypeVar("TBasePath", bound="BasePath") TAccessorPath = TypeVar("TAccessorPath", bound="AccessorPath[Any, Any, Any]") TDefault = TypeVar("TDefault") @dataclass(frozen=True, init=False, eq=False) class BasePath: """Base path. Identity is the *address*: two paths are equal if their ``parts`` are equal. The separator is presentation only β€” two paths that name the same address but render differently are still equal. Subclasses that introduce a resource binding (``AccessorPath``) extend the identity to include the binding and override ``__eq__`` accordingly; the BasePath/AccessorPath boundary is the only place class participates in equality. """ parts: tuple[Hashable, ...] separator: str = SEPARATOR def __init__(self, *args: Any, separator: str | None = None): object.__setattr__(self, "separator", separator or self.separator) parts = self._parse_args(args, sep=self.separator) object.__setattr__(self, "parts", parts) @classmethod def _parse_args( cls, args: Sequence[Any], sep: str = SEPARATOR, ) -> tuple[Hashable, ...]: """Parse constructor arguments into canonical parts. Subclasses may override this class method to customize parsing rules (e.g. accepted part types) while preserving the public constructor behavior. """ parts: list[Hashable] = [] append = parts.append extend = parts.extend for arg in args: part: Any = arg if isinstance(part, cls): extend(part.parts) continue if isinstance(part, bytes): append(part.decode("ascii")) continue if isinstance(part, os.PathLike): part = os.fspath(part) if isinstance(part, bytes): append(part.decode("ascii")) continue if isinstance(part, (str, int)): append(part) continue if isinstance(part, Hashable): append(part) continue raise TypeError( "argument must be Hashable, bytes, os.PathLike, or BasePath; got %r" % (type(part),) ) return tuple(parse_parts(parts, sep)) @classmethod def _from_parts( cls: type[TBasePath], args: Sequence[Any], separator: str | None = None, ) -> TBasePath: return cls(*args, separator=separator) @classmethod def _from_parsed_parts( cls: type[TBasePath], parts: tuple[Hashable, ...], separator: str | None = None, ) -> "TBasePath": instance = cls.__new__(cls) object.__setattr__(instance, "parts", parts) object.__setattr__( instance, "separator", separator or instance.separator ) return instance @cached_property def _cparts(self) -> tuple[str, ...]: # Cached stringified parts for display. return tuple(str(p) for p in self.parts) @cached_property def _cmp_parts(self) -> tuple[tuple[str, str], ...]: """Stable, type-aware comparison key for ordering. We include a fully-qualified type identifier so that e.g. `0` and "0" compare deterministically without being considered equal, and so that similarly-named types from different modules do not collide. """ return tuple( (f"{type(p).__module__}.{type(p).__qualname__}", c) for p, c in zip(self.parts, self._cparts, strict=True) ) def _make_child(self: TBasePath, args: list[Any]) -> TBasePath: parts = self._parse_args(args, sep=self.separator) parts_joined = self.parts + parts return self._clone_with_parts(parts_joined) def _make_child_relpath(self: TBasePath, part: Hashable) -> TBasePath: # This is an optimization used for dir walking. `part` must be # a single part relative to this path. parts = self.parts + (part,) return self._clone_with_parts(parts) def _clone_with_parts( self: TBasePath, parts: tuple[Hashable, ...] ) -> TBasePath: """Create a new instance of the same class with the given parts. Subclasses like `AccessorPath` require extra constructor state (e.g. accessor). This helper attempts to preserve that state. """ return self._from_parsed_parts(parts, separator=self.separator) def __fspath__(self) -> str: return str(self) def as_posix(self) -> str: """Return the path as a POSIX path (always uses '/').""" return "/".join(str(p) for p in self.parts) @cached_property def name(self) -> str: """Final path component.""" if not self.parts: return "" return str(self.parts[-1]) @staticmethod def _split_stem_suffix(name: str) -> tuple[str, str]: # Mirrors pathlib semantics for suffix handling, including dotfiles. if name in ("", ".", ".."): return name, "" dot = name.rfind(".") if dot <= 0: # no dot, or dotfile with no other suffix if dot == 0 and "." not in name[1:]: return name, "" return name, "" return name[:dot], name[dot:] @cached_property def suffix(self) -> str: """Final component's last suffix, including the leading dot.""" stem, suffix = self._split_stem_suffix(self.name) return suffix @cached_property def suffixes(self) -> list[str]: """Final component's suffixes, each including the leading dot.""" name = self.name if name in ("", ".", ".."): return [] if name.startswith("."): rest = name[1:] if "." not in rest: return [] name = rest parts = name.split(".") if len(parts) <= 1: return [] return ["." + p for p in parts[1:]] @cached_property def stem(self) -> str: """Final component without its last suffix.""" stem, _ = self._split_stem_suffix(self.name) return stem @cached_property def parent(self: TBasePath) -> TBasePath: """Logical parent path.""" if not self.parts: return self return self._clone_with_parts(self.parts[:-1]) @cached_property def parents(self: TBasePath) -> tuple[TBasePath, ...]: """Logical ancestors (like pathlib's `.parents`).""" if not self.parts: return () return tuple( self._clone_with_parts(self.parts[:-i]) for i in range(1, len(self.parts) + 1) ) def joinpath(self: TBasePath, *other: Any) -> TBasePath: """Combine this path with one or more segments.""" return self._make_child(list(other)) def with_name(self: TBasePath, name: str) -> TBasePath: """Return a new path with the final component replaced.""" if not self.parts: raise ValueError("with_name() requires a non-empty path") if not isinstance(name, str): raise TypeError("name must be a str") if not name: raise ValueError("name must be non-empty") if self.separator in name: raise ValueError("name must not contain path separator") new_parts = self.parts[:-1] + (name,) return self._clone_with_parts(new_parts) def with_suffix(self: TBasePath, suffix: str) -> TBasePath: """Return a new path with the final component's suffix changed.""" if not self.parts: raise ValueError("with_suffix() requires a non-empty path") if not isinstance(suffix, str): raise TypeError("suffix must be a str") if suffix and not suffix.startswith("."): raise ValueError("Invalid suffix; must start with '.'") name = self.name if name in ("", ".", ".."): raise ValueError("Invalid name for with_suffix()") new_name = self.stem + suffix return self.with_name(new_name) def is_relative_to(self, *other: Any) -> bool: """Return True if the path is relative to `other`.""" other_parts = self._parse_args(other, sep=self.separator) if len(other_parts) > len(self.parts): return False return self.parts[: len(other_parts)] == other_parts def relative_to(self: TBasePath, *other: Any) -> TBasePath: """Return the relative path from `other` to self. Raises ValueError if self is not under other. """ other_parts = self._parse_args(other, sep=self.separator) if not self.is_relative_to(*other_parts): raise ValueError( f"{self!r} is not in the subpath of {BasePath._from_parsed_parts(other_parts, separator=self.separator)!r}" ) return self._clone_with_parts(self.parts[len(other_parts) :]) def __str__(self) -> str: return self.separator.join(self._cparts) def __repr__(self) -> str: return f"{self.__class__.__name__}({str(self)!r})" def _identity_key(self) -> tuple[Any, ...]: # Address-only identity for BasePath. Separator is presentation, # not identity. AccessorPath overrides this to include the # accessor as binding. return (self.parts,) @cached_property def _hash(self) -> int: return hash(self._identity_key()) def __hash__(self) -> int: return self._hash def __truediv__(self: TBasePath, key: Any) -> TBasePath: try: return self._make_child( [ key, ] ) except TypeError: return NotImplemented def __rtruediv__(self: TBasePath, key: Hashable) -> TBasePath: try: return self._from_parts( (key,) + self.parts, separator=self.separator ) except TypeError: return NotImplemented def __eq__(self, other: object) -> bool: if not isinstance(other, BasePath): return NotImplemented # AccessorPath overrides __eq__ to enforce cross-class # discrimination (an AccessorPath carries a binding that a plain # BasePath does not, so they are never equal). Here we are on the # BasePath-side dispatch; if `other` is an AccessorPath, Python's # reflected-dispatch rules have already given AccessorPath.__eq__ # the first chance to answer. Reaching this branch means both # sides are plain BasePaths (or AccessorPath's __eq__ returned # NotImplemented), so address-only comparison is correct. return self.parts == other.parts def __lt__(self, other: Any) -> bool: if not isinstance(other, BasePath): return NotImplemented # Ordering is address-based: separator is presentation, and # AccessorPath bindings are intentionally outside the sort key. return self._cmp_parts < other._cmp_parts def __le__(self, other: Any) -> bool: if not isinstance(other, BasePath): return NotImplemented return self._cmp_parts <= other._cmp_parts def __gt__(self, other: Any) -> bool: if not isinstance(other, BasePath): return NotImplemented return self._cmp_parts > other._cmp_parts def __ge__(self, other: Any) -> bool: if not isinstance(other, BasePath): return NotImplemented return self._cmp_parts >= other._cmp_parts class AccessorPath(BasePath, Generic[N, K, V]): """Path for object that can be read by accessor.""" parts: tuple[K, ...] accessor: NodeAccessor[N, K, V] def __init__( self, accessor: NodeAccessor[N, K, V], *args: Any, separator: str | None = None, ): object.__setattr__(self, "accessor", accessor) super().__init__(*args, separator=separator) @classmethod def _from_parts( cls: type[TAccessorPath], args: Sequence[Any], separator: str | None = None, accessor: NodeAccessor[N, K, V] | None = None, ) -> TAccessorPath: if accessor is None: raise ValueError("accessor must be provided") return cls(accessor, *args, separator=separator) @classmethod def _from_parsed_parts( cls: type[TAccessorPath], parts: tuple[Hashable, ...], separator: str | None = None, accessor: NodeAccessor[N, K, V] | None = None, ) -> TAccessorPath: if accessor is None: raise ValueError("accessor must be provided") instance = cls.__new__(cls) object.__setattr__(instance, "parts", parts) object.__setattr__( instance, "separator", separator or instance.separator ) object.__setattr__(instance, "accessor", accessor) return instance def _clone_with_parts( self: TAccessorPath, parts: tuple[Hashable, ...] ) -> TAccessorPath: """Create a new instance of the same class with the given parts.""" return self._from_parsed_parts( parts, separator=self.separator, accessor=self.accessor, ) def _identity_key(self) -> tuple[Any, ...]: # Identity = (address, binding). The accessor's own __eq__ and # __hash__ decide what makes two accessors the same resource; # the path layer simply delegates to it via tuple comparison. return (self.parts, self.accessor) def __eq__(self, other: object) -> bool: if not isinstance(other, BasePath): return NotImplemented # Cross-class discrimination: a plain BasePath has no binding, # so it can never equal an AccessorPath. This preserves # transitivity β€” otherwise BasePath("x") could simultaneously # equal two AccessorPaths over distinct resources. if not isinstance(other, AccessorPath): return False return self.parts == other.parts and self.accessor == other.accessor # Re-bind __hash__: defining __eq__ on a class otherwise sets # __hash__ to None. The BasePath implementation dispatches through # _identity_key, which we override above, so this is the correct # hash for AccessorPath identity (parts, accessor). __hash__ = BasePath.__hash__ def is_same_binding(self, other: object) -> bool: """Return True if ``other`` is an equal address bound to the same accessor *instance* (object identity on the accessor). Stricter than ``==``, which only requires that the accessors compare equal under their own ``__eq__`` semantics. Use this when you need to assert that two paths are not just naming the same resource but are literally backed by the same accessor object β€” for example, to verify cache attribution. """ if not isinstance(other, AccessorPath): return False return self.parts == other.parts and self.accessor is other.accessor def __rtruediv__(self: TAccessorPath, key: Hashable) -> TAccessorPath: try: return self._from_parts( (key,) + self.parts, separator=self.separator, accessor=self.accessor, ) except TypeError: return NotImplemented def __floordiv__(self: TAccessorPath, key: K) -> TAccessorPath: """Return a new existing path with the key appended.""" self.accessor.require_child(self.parts, key) return self._make_child_relpath(key) def __rfloordiv__(self: TAccessorPath, key: K) -> TAccessorPath: """Return a new existing path with the key prepended.""" new = key / self # Validate existence in a way that preserves meaningful KeyError # diagnostics for missing/non-traversable intermediate nodes. # # We intentionally avoid `exists()` here because `exists()` uses # `accessor.stat()`, and `stat()` returns `None` for missing paths. # That behavior is useful for boolean checks, but it discards which # segment was missing. new.accessor.validate(new.parts) return new def __iter__(self: TAccessorPath) -> Iterator[TAccessorPath]: """Iterate over all child paths. Raises KeyError if the path is missing or non-traversable. """ for key in self.accessor.keys(self.parts): yield self._make_child_relpath(key) def __getitem__(self: TAccessorPath, key: K) -> V | TAccessorPath: """Access a child path's value.""" path: TAccessorPath | None = None # Fast path: if accessor supports direct traversal helpers, resolve the # child once and classify it without repeating full-path lookups. try: parent = self.accessor[self.parts] child = self.accessor._get_subnode(parent, key) except NotImplementedError: # Compatibility path for accessors that only implement keys/read. path = self // key if path.is_traversable(): return path return cast(V, path.read_value()) try: if self.accessor._is_traversable_node(child): path = self._make_child_relpath(key) return path except NotImplementedError: if path is None: path = self // key if path.is_traversable(): return path try: return cast(V, self.accessor._read_node(child)) except NotImplementedError: if path is None: path = self // key return cast(V, path.read_value()) def __contains__(self, key: K) -> bool: """Check if a key exists in the path. This mirrors typical container semantics: membership checks return a boolean and do not raise for missing/non-traversable intermediate nodes. """ return self.accessor.contains(self.parts, key) def __len__(self) -> int: """Return the number of child paths. Raises KeyError if the path is missing or non-traversable. """ return self.accessor.len(self.parts) def exists(self) -> bool: """Check if the path exists.""" return self.accessor.stat(self.parts) is not None def is_traversable(self) -> bool: """Return True if the path can enumerate child keys. This is a convenience wrapper around `accessor.is_traversable(...)`. """ return self.accessor.is_traversable(self.parts) def keys(self) -> Sequence[K]: """Return all keys at the current path. Raises KeyError if the path is missing or non-traversable. """ return self.accessor.keys(self.parts) def items(self: TAccessorPath) -> Iterator[tuple[K, TAccessorPath]]: """Return path's items.""" for key in self.accessor.keys(self.parts): yield key, self._make_child_relpath(key) @overload def get(self, key: K) -> V | None: ... @overload def get(self, key: K, default: TDefault) -> V | TDefault: ... def get(self, key: K, default: object = None) -> object: """Return the value for key if key is in the path, else default.""" try: return self[key] except KeyError: return default def read_value(self) -> V: """Return the path's value.""" return self.accessor.read(self.parts) def stat(self) -> dict[str, Any] | None: """Return metadata for the path, or None if it doesn't exist.""" return self.accessor.stat(self.parts) @contextmanager def open(self) -> Iterator[V]: """Context manager that yields the current path's value. This mirrors a file-like "open" API but works for any accessor. """ yield self.read_value() class FilesystemPath(AccessorPath[Path, str, bytes]): """Path for filesystem objects.""" @classmethod def from_path( cls: type["FilesystemPath"], path: Path, ) -> "FilesystemPath": """Public constructor for a Path-backed path.""" accessor = PathAccessor(path) return cls(accessor) class LookupPath(AccessorPath[LookupNode, LookupKey, LookupValue]): """Path for object that supports __getitem__ lookups.""" @classmethod def from_lookup( cls: type["LookupPath"], lookup: LookupNode, *args: Any, **kwargs: Any, ) -> "LookupPath": """Public constructor for a lookup-backed path.""" return cls._from_lookup(lookup, *args, **kwargs) @classmethod def _from_lookup( cls: type["LookupPath"], lookup: LookupNode, *args: Any, **kwargs: Any, ) -> "LookupPath": accessor = LookupAccessor(lookup) return cls(accessor, *args, **kwargs) p1c2u-pathable-263aef1/pathable/protocols.py000066400000000000000000000006771520312417300210210ustar00rootroot00000000000000from collections.abc import Hashable from typing import Protocol from typing import TypeVar from typing import runtime_checkable TKey = TypeVar("TKey", bound=Hashable, contravariant=True) TValue_co = TypeVar("TValue_co", covariant=True) @runtime_checkable class Subscriptable(Protocol[TKey, TValue_co]): def __contains__(self, key: TKey) -> bool: ... def __getitem__(self, key: TKey) -> TValue_co: ... def __len__(self) -> int: ... p1c2u-pathable-263aef1/pathable/py.typed000066400000000000000000000000001520312417300200760ustar00rootroot00000000000000p1c2u-pathable-263aef1/pathable/types.py000066400000000000000000000003141520312417300201250ustar00rootroot00000000000000"""Pathable types module""" from typing import Any from pathable.protocols import Subscriptable LookupKey = str | int LookupValue = Any LookupNode = Subscriptable[LookupKey, LookupValue] | LookupValue p1c2u-pathable-263aef1/poetry.lock000066400000000000000000002242151520312417300170330ustar00rootroot00000000000000# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "black" version = "25.11.0" description = "The uncompromising code formatter." optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "black-25.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ec311e22458eec32a807f029b2646f661e6859c3f61bc6d9ffb67958779f392e"}, {file = "black-25.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1032639c90208c15711334d681de2e24821af0575573db2810b0763bcd62e0f0"}, {file = "black-25.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0f7c461df55cf32929b002335883946a4893d759f2df343389c4396f3b6b37"}, {file = "black-25.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:f9786c24d8e9bd5f20dc7a7f0cdd742644656987f6ea6947629306f937726c03"}, {file = "black-25.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:895571922a35434a9d8ca67ef926da6bc9ad464522a5fe0db99b394ef1c0675a"}, {file = "black-25.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb4f4b65d717062191bdec8e4a442539a8ea065e6af1c4f4d36f0cdb5f71e170"}, {file = "black-25.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d81a44cbc7e4f73a9d6ae449ec2317ad81512d1e7dce7d57f6333fd6259737bc"}, {file = "black-25.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:7eebd4744dfe92ef1ee349dc532defbf012a88b087bb7ddd688ff59a447b080e"}, {file = "black-25.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:80e7486ad3535636657aa180ad32a7d67d7c273a80e12f1b4bfa0823d54e8fac"}, {file = "black-25.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6cced12b747c4c76bc09b4db057c319d8545307266f41aaee665540bc0e04e96"}, {file = "black-25.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb2d54a39e0ef021d6c5eef442e10fd71fcb491be6413d083a320ee768329dd"}, {file = "black-25.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae263af2f496940438e5be1a0c1020e13b09154f3af4df0835ea7f9fe7bfa409"}, {file = "black-25.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a1d40348b6621cc20d3d7530a5b8d67e9714906dfd7346338249ad9c6cedf2b"}, {file = "black-25.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:51c65d7d60bb25429ea2bf0731c32b2a2442eb4bd3b2afcb47830f0b13e58bfd"}, {file = "black-25.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:936c4dd07669269f40b497440159a221ee435e3fddcf668e0c05244a9be71993"}, {file = "black-25.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:f42c0ea7f59994490f4dccd64e6b2dd49ac57c7c84f38b8faab50f8759db245c"}, {file = "black-25.11.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:35690a383f22dd3e468c85dc4b915217f87667ad9cce781d7b42678ce63c4170"}, {file = "black-25.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dae49ef7369c6caa1a1833fd5efb7c3024bb7e4499bf64833f65ad27791b1545"}, {file = "black-25.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bd4a22a0b37401c8e492e994bce79e614f91b14d9ea911f44f36e262195fdda"}, {file = "black-25.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:aa211411e94fdf86519996b7f5f05e71ba34835d8f0c0f03c00a26271da02664"}, {file = "black-25.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a3bb5ce32daa9ff0605d73b6f19da0b0e6c1f8f2d75594db539fdfed722f2b06"}, {file = "black-25.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9815ccee1e55717fe9a4b924cae1646ef7f54e0f990da39a34fc7b264fcf80a2"}, {file = "black-25.11.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92285c37b93a1698dcbc34581867b480f1ba3a7b92acf1fe0467b04d7a4da0dc"}, {file = "black-25.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:43945853a31099c7c0ff8dface53b4de56c41294fa6783c0441a8b1d9bf668bc"}, {file = "black-25.11.0-py3-none-any.whl", hash = "sha256:e3f562da087791e96cefcd9dda058380a442ab322a02e222add53736451f604b"}, {file = "black-25.11.0.tar.gz", hash = "sha256:9a323ac32f5dc75ce7470501b887250be5005a01602e931a15e45593f70f6e08"}, ] [package.dependencies] click = ">=8.0.0" mypy-extensions = ">=0.4.3" packaging = ">=22.0" pathspec = ">=0.9.0" platformdirs = ">=2" pytokens = ">=0.3.0" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} [package.extras] colorama = ["colorama (>=0.4.3)"] d = ["aiohttp (>=3.10)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "cfgv" version = "3.3.1" description = "Validate configuration and produce human readable error messages." optional = false python-versions = ">=3.6.1" groups = ["dev"] files = [ {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, ] [[package]] name = "cli-ui" version = "0.19.0" description = "Build Nice User Interfaces In The Terminal" optional = false python-versions = "<4.0,>=3.9" groups = ["dev"] files = [ {file = "cli_ui-0.19.0-py3-none-any.whl", hash = "sha256:1cf1b93328f7377730db29507e10bcb29ccc1427ceef45714b522d1f2055e7cd"}, {file = "cli_ui-0.19.0.tar.gz", hash = "sha256:59cdab0c6a2a6703c61b31cb75a1943076888907f015fffe15c5a8eb41a933aa"}, ] [package.dependencies] colorama = ">=0.4.1,<0.5.0" tabulate = ">=0.9.0,<0.10.0" unidecode = ">=1.3.6,<2.0.0" [[package]] name = "click" version = "8.1.7" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" groups = ["dev"] files = [ {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, ] [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} [[package]] name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["dev"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] [[package]] name = "coverage" version = "7.10.7" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a"}, {file = "coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5"}, {file = "coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17"}, {file = "coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b"}, {file = "coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87"}, {file = "coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e"}, {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e"}, {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df"}, {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0"}, {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13"}, {file = "coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b"}, {file = "coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807"}, {file = "coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59"}, {file = "coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a"}, {file = "coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699"}, {file = "coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d"}, {file = "coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e"}, {file = "coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23"}, {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab"}, {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82"}, {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2"}, {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61"}, {file = "coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14"}, {file = "coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2"}, {file = "coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a"}, {file = "coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417"}, {file = "coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973"}, {file = "coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c"}, {file = "coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7"}, {file = "coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6"}, {file = "coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59"}, {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b"}, {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a"}, {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb"}, {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1"}, {file = "coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256"}, {file = "coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba"}, {file = "coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf"}, {file = "coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d"}, {file = "coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b"}, {file = "coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e"}, {file = "coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b"}, {file = "coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49"}, {file = "coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911"}, {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0"}, {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f"}, {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c"}, {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f"}, {file = "coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698"}, {file = "coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843"}, {file = "coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546"}, {file = "coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c"}, {file = "coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15"}, {file = "coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4"}, {file = "coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0"}, {file = "coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0"}, {file = "coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65"}, {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541"}, {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6"}, {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999"}, {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2"}, {file = "coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a"}, {file = "coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb"}, {file = "coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb"}, {file = "coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520"}, {file = "coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32"}, {file = "coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f"}, {file = "coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a"}, {file = "coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360"}, {file = "coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69"}, {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14"}, {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe"}, {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e"}, {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd"}, {file = "coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2"}, {file = "coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681"}, {file = "coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880"}, {file = "coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63"}, {file = "coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2"}, {file = "coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d"}, {file = "coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0"}, {file = "coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699"}, {file = "coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9"}, {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f"}, {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1"}, {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0"}, {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399"}, {file = "coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235"}, {file = "coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d"}, {file = "coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a"}, {file = "coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3"}, {file = "coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c"}, {file = "coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396"}, {file = "coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40"}, {file = "coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594"}, {file = "coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a"}, {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b"}, {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3"}, {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0"}, {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f"}, {file = "coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431"}, {file = "coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07"}, {file = "coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260"}, {file = "coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239"}, ] [package.dependencies] tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} [package.extras] toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "distlib" version = "0.3.7" description = "Distribution utilities" optional = false python-versions = "*" groups = ["dev"] files = [ {file = "distlib-0.3.7-py2.py3-none-any.whl", hash = "sha256:2e24928bc811348f0feb63014e97aaae3037f2cf48712d51ae61df7fd6075057"}, {file = "distlib-0.3.7.tar.gz", hash = "sha256:9dafe54b34a028eafd95039d5e5d4851a13734540f1331060d31c9916e7147a8"}, ] [[package]] name = "docopt" version = "0.6.2" description = "Pythonic argument parser, that will make you smile" optional = false python-versions = "*" groups = ["dev"] files = [ {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, ] [[package]] name = "exceptiongroup" version = "1.3.0" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" groups = ["dev"] markers = "python_version == \"3.10\"" files = [ {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, ] [package.dependencies] typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} [package.extras] test = ["pytest (>=6)"] [[package]] name = "filelock" version = "3.12.2" description = "A platform independent file lock." optional = false python-versions = ">=3.7" groups = ["dev"] files = [ {file = "filelock-3.12.2-py3-none-any.whl", hash = "sha256:cbb791cdea2a72f23da6ac5b5269ab0a0d161e9ef0100e653b69049a7706d1ec"}, {file = "filelock-3.12.2.tar.gz", hash = "sha256:002740518d8aa59a26b0c76e10fb8c6e15eae825d34b6fdf670333fd7b938d81"}, ] [package.extras] docs = ["furo (>=2023.5.20)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] testing = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "diff-cover (>=7.5)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)"] [[package]] name = "flynt" version = "1.0.6" description = "CLI tool to convert a python project's %-formatted strings to f-strings." optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "flynt-1.0.6-py3-none-any.whl", hash = "sha256:4e837c9597036b634a347855a89acf1483c4f8b73daa82c49372b10b6e1d1778"}, {file = "flynt-1.0.6.tar.gz", hash = "sha256:471b7ff00756678e2912d4261dcbcd8fc1395129b66bf6977f88a3b3ad220c90"}, ] [package.dependencies] tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} [package.extras] dev = ["build", "pre-commit", "pytest", "pytest-cov", "ruff", "twine"] [[package]] name = "identify" version = "2.5.24" description = "File identification library for Python" optional = false python-versions = ">=3.7" groups = ["dev"] files = [ {file = "identify-2.5.24-py2.py3-none-any.whl", hash = "sha256:986dbfb38b1140e763e413e6feb44cd731faf72d1909543178aa79b0e258265d"}, {file = "identify-2.5.24.tar.gz", hash = "sha256:0aac67d5b4812498056d28a9a512a483f5085cc28640b02b258a59dac34301d4"}, ] [package.extras] license = ["ukkonen"] [[package]] name = "iniconfig" version = "1.1.1" description = "iniconfig: brain-dead simple config-ini parsing" optional = false python-versions = "*" groups = ["dev"] files = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] [[package]] name = "isort" version = "6.1.0" description = "A Python utility / library to sort Python imports." optional = false python-versions = ">=3.9.0" groups = ["dev"] files = [ {file = "isort-6.1.0-py3-none-any.whl", hash = "sha256:58d8927ecce74e5087aef019f778d4081a3b6c98f15a80ba35782ca8a2097784"}, {file = "isort-6.1.0.tar.gz", hash = "sha256:9b8f96a14cfee0677e78e941ff62f03769a06d412aabb9e2a90487b3b7e8d481"}, ] [package.extras] colors = ["colorama"] plugins = ["setuptools"] [[package]] name = "librt" version = "0.8.0" description = "Mypyc runtime library" optional = false python-versions = ">=3.9" groups = ["dev"] markers = "platform_python_implementation != \"PyPy\"" files = [ {file = "librt-0.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:db63cf3586a24241e89ca1ce0b56baaec9d371a328bd186c529b27c914c9a1ef"}, {file = "librt-0.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ba9d9e60651615bc614be5e21a82cdb7b1769a029369cf4b4d861e4f19686fb6"}, {file = "librt-0.8.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb4b3ad543084ed79f186741470b251b9d269cd8b03556f15a8d1a99a64b7de5"}, {file = "librt-0.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d2720335020219197380ccfa5c895f079ac364b4c429e96952cd6509934d8eb"}, {file = "librt-0.8.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726305d3e53419d27fc8cdfcd3f9571f0ceae22fa6b5ea1b3662c2e538f833e"}, {file = "librt-0.8.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3d107f603b5ee7a79b6aa6f166551b99b32fb4a5303c4dfcb4222fc6a0335e"}, {file = "librt-0.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:41064a0c07b4cc7a81355ccc305cb097d6027002209ffca51306e65ee8293630"}, {file = "librt-0.8.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c6e4c10761ddbc0d67d2f6e2753daf99908db85d8b901729bf2bf5eaa60e0567"}, {file = "librt-0.8.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ba581acad5ac8f33e2ff1746e8a57e001b47c6721873121bf8bbcf7ba8bd3aa4"}, {file = "librt-0.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bdab762e2c0b48bab76f1a08acb3f4c77afd2123bedac59446aeaaeed3d086cf"}, {file = "librt-0.8.0-cp310-cp310-win32.whl", hash = "sha256:6a3146c63220d814c4a2c7d6a1eacc8d5c14aed0ff85115c1dfea868080cd18f"}, {file = "librt-0.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:bbebd2bba5c6ae02907df49150e55870fdd7440d727b6192c46b6f754723dde9"}, {file = "librt-0.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0ce33a9778e294507f3a0e3468eccb6a698b5166df7db85661543eca1cfc5369"}, {file = "librt-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8070aa3368559de81061ef752770d03ca1f5fc9467d4d512d405bd0483bfffe6"}, {file = "librt-0.8.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:20f73d4fecba969efc15cdefd030e382502d56bb6f1fc66b580cce582836c9fa"}, {file = "librt-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a512c88900bdb1d448882f5623a0b1ad27ba81a9bd75dacfe17080b72272ca1f"}, {file = "librt-0.8.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:015e2dde6e096d27c10238bf9f6492ba6c65822dfb69d2bf74c41a8e88b7ddef"}, {file = "librt-0.8.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c25a131013eadd3c600686a0c0333eb2896483cbc7f65baa6a7ee761017aef9"}, {file = "librt-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:21b14464bee0b604d80a638cf1ee3148d84ca4cc163dcdcecb46060c1b3605e4"}, {file = "librt-0.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:05a3dd3f116747f7e1a2b475ccdc6fb637fd4987126d109e03013a79d40bf9e6"}, {file = "librt-0.8.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:fa37f99bff354ff191c6bcdffbc9d7cdd4fc37faccfc9be0ef3a4fd5613977da"}, {file = "librt-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1566dbb9d1eb0987264c9b9460d212e809ba908d2f4a3999383a84d765f2f3f1"}, {file = "librt-0.8.0-cp311-cp311-win32.whl", hash = "sha256:70defb797c4d5402166787a6b3c66dfb3fa7f93d118c0509ffafa35a392f4258"}, {file = "librt-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:db953b675079884ffda33d1dca7189fb961b6d372153750beb81880384300817"}, {file = "librt-0.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:75d1a8cab20b2043f03f7aab730551e9e440adc034d776f15f6f8d582b0a5ad4"}, {file = "librt-0.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:17269dd2745dbe8e42475acb28e419ad92dfa38214224b1b01020b8cac70b645"}, {file = "librt-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f4617cef654fca552f00ce5ffdf4f4b68770f18950e4246ce94629b789b92467"}, {file = "librt-0.8.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5cb11061a736a9db45e3c1293cfcb1e3caf205912dfa085734ba750f2197ff9a"}, {file = "librt-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4bb00bd71b448f16749909b08a0ff16f58b079e2261c2e1000f2bbb2a4f0a45"}, {file = "librt-0.8.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95a719a049f0eefaf1952673223cf00d442952273cbd20cf2ed7ec423a0ef58d"}, {file = "librt-0.8.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bd32add59b58fba3439d48d6f36ac695830388e3da3e92e4fc26d2d02670d19c"}, {file = "librt-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f764b2424cb04524ff7a486b9c391e93f93dc1bd8305b2136d25e582e99aa2f"}, {file = "librt-0.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f04ca50e847abc486fa8f4107250566441e693779a5374ba211e96e238f298b9"}, {file = "librt-0.8.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9ab3a3475a55b89b87ffd7e6665838e8458e0b596c22e0177e0f961434ec474a"}, {file = "librt-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3e36a8da17134ffc29373775d88c04832f9ecfab1880470661813e6c7991ef79"}, {file = "librt-0.8.0-cp312-cp312-win32.whl", hash = "sha256:4eb5e06ebcc668677ed6389164f52f13f71737fc8be471101fa8b4ce77baeb0c"}, {file = "librt-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:0a33335eb59921e77c9acc05d0e654e4e32e45b014a4d61517897c11591094f8"}, {file = "librt-0.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:24a01c13a2a9bdad20997a4443ebe6e329df063d1978bbe2ebbf637878a46d1e"}, {file = "librt-0.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7f820210e21e3a8bf8fde2ae3c3d10106d4de9ead28cbfdf6d0f0f41f5b12fa1"}, {file = "librt-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4831c44b8919e75ca0dfb52052897c1ef59fdae19d3589893fbd068f1e41afbf"}, {file = "librt-0.8.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:88c6e75540f1f10f5e0fc5e87b4b6c290f0e90d1db8c6734f670840494764af8"}, {file = "librt-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9646178cd794704d722306c2c920c221abbf080fede3ba539d5afdec16c46dad"}, {file = "librt-0.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e1af31a710e17891d9adf0dbd9a5fcd94901a3922a96499abdbf7ce658f4e01"}, {file = "librt-0.8.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:507e94f4bec00b2f590fbe55f48cd518a208e2474a3b90a60aa8f29136ddbada"}, {file = "librt-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f1178e0de0c271231a660fbef9be6acdfa1d596803464706862bef6644cc1cae"}, {file = "librt-0.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:71fc517efc14f75c2f74b1f0a5d5eb4a8e06aa135c34d18eaf3522f4a53cd62d"}, {file = "librt-0.8.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0583aef7e9a720dd40f26a2ad5a1bf2ccbb90059dac2b32ac516df232c701db3"}, {file = "librt-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5d0f76fc73480d42285c609c0ea74d79856c160fa828ff9aceab574ea4ecfd7b"}, {file = "librt-0.8.0-cp313-cp313-win32.whl", hash = "sha256:e79dbc8f57de360f0ed987dc7de7be814b4803ef0e8fc6d3ff86e16798c99935"}, {file = "librt-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:25b3e667cbfc9000c4740b282df599ebd91dbdcc1aa6785050e4c1d6be5329ab"}, {file = "librt-0.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:e9a3a38eb4134ad33122a6d575e6324831f930a771d951a15ce232e0237412c2"}, {file = "librt-0.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:421765e8c6b18e64d21c8ead315708a56fc24f44075059702e421d164575fdda"}, {file = "librt-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:48f84830a8f8ad7918afd743fd7c4eb558728bceab7b0e38fd5a5cf78206a556"}, {file = "librt-0.8.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9f09d4884f882baa39a7e36bbf3eae124c4ca2a223efb91e567381d1c55c6b06"}, {file = "librt-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:693697133c3b32aa9b27f040e3691be210e9ac4d905061859a9ed519b1d5a376"}, {file = "librt-0.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5512aae4648152abaf4d48b59890503fcbe86e85abc12fb9b096fe948bdd816"}, {file = "librt-0.8.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:995d24caa6bbb34bcdd4a41df98ac6d1af637cfa8975cb0790e47d6623e70e3e"}, {file = "librt-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b9aef96d7593584e31ef6ac1eb9775355b0099fee7651fae3a15bc8657b67b52"}, {file = "librt-0.8.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4f6e975377fbc4c9567cb33ea9ab826031b6c7ec0515bfae66a4fb110d40d6da"}, {file = "librt-0.8.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:daae5e955764be8fd70a93e9e5133c75297f8bce1e802e1d3683b98f77e1c5ab"}, {file = "librt-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7bd68cebf3131bb920d5984f75fe302d758db33264e44b45ad139385662d7bc3"}, {file = "librt-0.8.0-cp314-cp314-win32.whl", hash = "sha256:1e6811cac1dcb27ca4c74e0ca4a5917a8e06db0d8408d30daee3a41724bfde7a"}, {file = "librt-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:178707cda89d910c3b28bf5aa5f69d3d4734e0f6ae102f753ad79edef83a83c7"}, {file = "librt-0.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3e8b77b5f54d0937b26512774916041756c9eb3e66f1031971e626eea49d0bf4"}, {file = "librt-0.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:789911e8fa40a2e82f41120c936b1965f3213c67f5a483fc5a41f5839a05dcbb"}, {file = "librt-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2b37437e7e4ef5e15a297b36ba9e577f73e29564131d86dd75875705e97402b5"}, {file = "librt-0.8.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:671a6152edf3b924d98a5ed5e6982ec9cb30894085482acadce0975f031d4c5c"}, {file = "librt-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8992ca186a1678107b0af3d0c9303d8c7305981b9914989b9788319ed4d89546"}, {file = "librt-0.8.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:001e5330093d887b8b9165823eca6c5c4db183fe4edea4fdc0680bbac5f46944"}, {file = "librt-0.8.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d920789eca7ef71df7f31fd547ec0d3002e04d77f30ba6881e08a630e7b2c30e"}, {file = "librt-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:82fb4602d1b3e303a58bfe6165992b5a78d823ec646445356c332cd5f5bbaa61"}, {file = "librt-0.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4d3e38797eb482485b486898f89415a6ab163bc291476bd95712e42cf4383c05"}, {file = "librt-0.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a905091a13e0884701226860836d0386b88c72ce5c2fdfba6618e14c72be9f25"}, {file = "librt-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:375eda7acfce1f15f5ed56cfc960669eefa1ec8732e3e9087c3c4c3f2066759c"}, {file = "librt-0.8.0-cp314-cp314t-win32.whl", hash = "sha256:2ccdd20d9a72c562ffb73098ac411de351b53a6fbb3390903b2d33078ef90447"}, {file = "librt-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:25e82d920d4d62ad741592fcf8d0f3bda0e3fc388a184cb7d2f566c681c5f7b9"}, {file = "librt-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:92249938ab744a5890580d3cb2b22042f0dce71cdaa7c1369823df62bedf7cbc"}, {file = "librt-0.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4b705f85311ee76acec5ee70806990a51f0deb519ea0c29c1d1652d79127604d"}, {file = "librt-0.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7ce0a8cb67e702dcb06342b2aaaa3da9fb0ddc670417879adfa088b44cf7b3b6"}, {file = "librt-0.8.0-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:aaadec87f45a3612b6818d1db5fbfe93630669b7ee5d6bdb6427ae08a1aa2141"}, {file = "librt-0.8.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56901f1eec031396f230db71c59a01d450715cbbef9856bf636726994331195d"}, {file = "librt-0.8.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b055bb3abaf69abed25743d8fc1ab691e4f51a912ee0a6f9a6c84f4bbddb283d"}, {file = "librt-0.8.0-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ef3bd856373cf8e7382402731f43bfe978a8613b4039e49e166e1e0dc590216"}, {file = "librt-0.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2e0ffe88ebb5962f8fb0ddcbaaff30f1ea06a79501069310e1e030eafb1ad787"}, {file = "librt-0.8.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:82e61cd1c563745ad495387c3b65806bfd453badb4adbc019df3389dddee1bf6"}, {file = "librt-0.8.0-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:667e2513cf69bfd1e1ed9a00d6c736d5108714ec071192afb737987955888a25"}, {file = "librt-0.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6b6caff69e25d80c269b1952be8493b4d94ef745f438fa619d7931066bdd26de"}, {file = "librt-0.8.0-cp39-cp39-win32.whl", hash = "sha256:02a9fe85410cc9bef045e7cb7fd26fdde6669e6d173f99df659aa7f6335961e9"}, {file = "librt-0.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:de076eaba208d16efb5962f99539867f8e2c73480988cb513fcf1b5dbb0c9dcf"}, {file = "librt-0.8.0.tar.gz", hash = "sha256:cb74cdcbc0103fc988e04e5c58b0b31e8e5dd2babb9182b6f9490488eb36324b"}, ] [[package]] name = "mypy" version = "1.19.1" description = "Optional static typing for Python" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec"}, {file = "mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b"}, {file = "mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6"}, {file = "mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74"}, {file = "mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1"}, {file = "mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac"}, {file = "mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288"}, {file = "mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab"}, {file = "mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6"}, {file = "mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331"}, {file = "mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925"}, {file = "mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042"}, {file = "mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1"}, {file = "mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e"}, {file = "mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2"}, {file = "mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8"}, {file = "mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a"}, {file = "mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13"}, {file = "mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250"}, {file = "mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b"}, {file = "mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e"}, {file = "mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef"}, {file = "mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75"}, {file = "mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd"}, {file = "mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1"}, {file = "mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718"}, {file = "mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b"}, {file = "mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045"}, {file = "mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957"}, {file = "mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f"}, {file = "mypy-1.19.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7bcfc336a03a1aaa26dfce9fff3e287a3ba99872a157561cbfcebe67c13308e3"}, {file = "mypy-1.19.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b7951a701c07ea584c4fe327834b92a30825514c868b1f69c30445093fdd9d5a"}, {file = "mypy-1.19.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b13cfdd6c87fc3efb69ea4ec18ef79c74c3f98b4e5498ca9b85ab3b2c2329a67"}, {file = "mypy-1.19.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f28f99c824ecebcdaa2e55d82953e38ff60ee5ec938476796636b86afa3956e"}, {file = "mypy-1.19.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c608937067d2fc5a4dd1a5ce92fd9e1398691b8c5d012d66e1ddd430e9244376"}, {file = "mypy-1.19.1-cp39-cp39-win_amd64.whl", hash = "sha256:409088884802d511ee52ca067707b90c883426bd95514e8cfda8281dc2effe24"}, {file = "mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247"}, {file = "mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba"}, ] [package.dependencies] librt = {version = ">=0.6.2", markers = "platform_python_implementation != \"PyPy\""} mypy_extensions = ">=1.0.0" pathspec = ">=0.9.0" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} typing_extensions = ">=4.6.0" [package.extras] dmypy = ["psutil (>=4.0)"] faster-cache = ["orjson"] install-types = ["pip"] mypyc = ["setuptools (>=50)"] reports = ["lxml"] [[package]] name = "mypy-extensions" version = "1.1.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, ] [[package]] name = "nodeenv" version = "1.8.0" description = "Node.js virtual environment builder" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" groups = ["dev"] files = [ {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, ] [package.dependencies] setuptools = "*" [[package]] name = "packaging" version = "25.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, ] [[package]] name = "pathspec" version = "0.11.2" description = "Utility library for gitignore style pattern matching of file paths." optional = false python-versions = ">=3.7" groups = ["dev"] files = [ {file = "pathspec-0.11.2-py3-none-any.whl", hash = "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20"}, {file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"}, ] [[package]] name = "platformdirs" version = "2.4.1" description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." optional = false python-versions = ">=3.7" groups = ["dev"] files = [ {file = "platformdirs-2.4.1-py3-none-any.whl", hash = "sha256:1d7385c7db91728b83efd0ca99a5afb296cab9d0ed8313a45ed8ba17967ecfca"}, {file = "platformdirs-2.4.1.tar.gz", hash = "sha256:440633ddfebcc36264232365d7840a970e75e1018d15b4327d11f91909045fda"}, ] [package.extras] docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"] test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] [[package]] name = "pluggy" version = "1.6.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, ] [package.extras] dev = ["pre-commit", "tox"] testing = ["coverage", "pytest", "pytest-benchmark"] [[package]] name = "pre-commit" version = "4.3.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8"}, {file = "pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16"}, ] [package.dependencies] cfgv = ">=2.0.0" identify = ">=1.0.0" nodeenv = ">=0.11.1" pyyaml = ">=5.1" virtualenv = ">=20.10.0" [[package]] name = "pygments" version = "2.19.2" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, ] [package.extras] windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pytest" version = "8.4.2" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, ] [package.dependencies] colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} iniconfig = ">=1" packaging = ">=20" pluggy = ">=1.5,<2" pygments = ">=2.7.2" tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-cov" version = "7.0.0" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"}, {file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"}, ] [package.dependencies] coverage = {version = ">=7.10.6", extras = ["toml"]} pluggy = ">=1.2" pytest = ">=7" [package.extras] testing = ["process-tests", "pytest-xdist", "virtualenv"] [[package]] name = "pytokens" version = "0.4.1" description = "A Fast, spec compliant Python 3.14+ tokenizer that runs on older Pythons." optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5"}, {file = "pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe"}, {file = "pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c"}, {file = "pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7"}, {file = "pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2"}, {file = "pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440"}, {file = "pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc"}, {file = "pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d"}, {file = "pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16"}, {file = "pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6"}, {file = "pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083"}, {file = "pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1"}, {file = "pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1"}, {file = "pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9"}, {file = "pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68"}, {file = "pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b"}, {file = "pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f"}, {file = "pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1"}, {file = "pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4"}, {file = "pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78"}, {file = "pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321"}, {file = "pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa"}, {file = "pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d"}, {file = "pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324"}, {file = "pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9"}, {file = "pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb"}, {file = "pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3"}, {file = "pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975"}, {file = "pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a"}, {file = "pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918"}, {file = "pytokens-0.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:da5baeaf7116dced9c6bb76dc31ba04a2dc3695f3d9f74741d7910122b456edc"}, {file = "pytokens-0.4.1-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11edda0942da80ff58c4408407616a310adecae1ddd22eef8c692fe266fa5009"}, {file = "pytokens-0.4.1-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0fc71786e629cef478cbf29d7ea1923299181d0699dbe7c3c0f4a583811d9fc1"}, {file = "pytokens-0.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dcafc12c30dbaf1e2af0490978352e0c4041a7cde31f4f81435c2a5e8b9cabb6"}, {file = "pytokens-0.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:42f144f3aafa5d92bad964d471a581651e28b24434d184871bd02e3a0d956037"}, {file = "pytokens-0.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:34bcc734bd2f2d5fe3b34e7b3c0116bfb2397f2d9666139988e7a3eb5f7400e3"}, {file = "pytokens-0.4.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941d4343bf27b605e9213b26bfa1c4bf197c9c599a9627eb7305b0defcfe40c1"}, {file = "pytokens-0.4.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3ad72b851e781478366288743198101e5eb34a414f1d5627cdd585ca3b25f1db"}, {file = "pytokens-0.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:682fa37ff4d8e95f7df6fe6fe6a431e8ed8e788023c6bcc0f0880a12eab80ad1"}, {file = "pytokens-0.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:30f51edd9bb7f85c748979384165601d028b84f7bd13fe14d3e065304093916a"}, {file = "pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de"}, {file = "pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a"}, ] [package.extras] dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "twine", "wheel"] [[package]] name = "pyyaml" version = "6.0.1" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.6" groups = ["dev"] files = [ {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, ] [[package]] name = "schema" version = "0.7.7" description = "Simple data validation library" optional = false python-versions = "*" groups = ["dev"] files = [ {file = "schema-0.7.7-py2.py3-none-any.whl", hash = "sha256:5d976a5b50f36e74e2157b47097b60002bd4d42e65425fcc9c9befadb4255dde"}, {file = "schema-0.7.7.tar.gz", hash = "sha256:7da553abd2958a19dc2547c388cde53398b39196175a9be59ea1caf5ab0a1807"}, ] [[package]] name = "setuptools" version = "80.8.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "setuptools-80.8.0-py3-none-any.whl", hash = "sha256:95a60484590d24103af13b686121328cc2736bee85de8936383111e421b9edc0"}, {file = "setuptools-80.8.0.tar.gz", hash = "sha256:49f7af965996f26d43c8ae34539c8d99c5042fbff34302ea151eaa9c207cd257"}, ] [package.extras] check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] enabler = ["pytest-enabler (>=2.2)"] test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] [[package]] name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" groups = ["dev"] files = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] [[package]] name = "tabulate" version = "0.9.0" description = "Pretty-print tabular data" optional = false python-versions = ">=3.7" groups = ["dev"] files = [ {file = "tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f"}, {file = "tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c"}, ] [package.extras] widechars = ["wcwidth"] [[package]] name = "tbump" version = "6.11.0" description = "Bump software releases" optional = false python-versions = ">=3.7,<4.0" groups = ["dev"] files = [ {file = "tbump-6.11.0-py3-none-any.whl", hash = "sha256:6b181fe6f3ae84ce0b9af8cc2009a8bca41ded34e73f623a7413b9684f1b4526"}, {file = "tbump-6.11.0.tar.gz", hash = "sha256:385e710eedf0a8a6ff959cf1e9f3cfd17c873617132fc0ec5f629af0c355c870"}, ] [package.dependencies] cli-ui = ">=0.10.3" docopt = ">=0.6.2,<0.7.0" schema = ">=0.7.1,<0.8.0" tomlkit = ">=0.11,<0.12" [[package]] name = "tomli" version = "2.0.0" description = "A lil' TOML parser" optional = false python-versions = ">=3.7" groups = ["dev"] markers = "python_full_version <= \"3.11.0a6\"" files = [ {file = "tomli-2.0.0-py3-none-any.whl", hash = "sha256:b5bde28da1fed24b9bd1d4d2b8cba62300bfb4ec9a6187a957e8ddb9434c5224"}, {file = "tomli-2.0.0.tar.gz", hash = "sha256:c292c34f58502a1eb2bbb9f5bbc9a5ebc37bee10ffb8c2d6bbdfa8eb13cc14e1"}, ] [[package]] name = "tomlkit" version = "0.11.8" description = "Style preserving TOML library" optional = false python-versions = ">=3.7" groups = ["dev"] files = [ {file = "tomlkit-0.11.8-py3-none-any.whl", hash = "sha256:8c726c4c202bdb148667835f68d68780b9a003a9ec34167b6c673b38eff2a171"}, {file = "tomlkit-0.11.8.tar.gz", hash = "sha256:9330fc7faa1db67b541b28e62018c17d20be733177d290a13b24c62d1614e0c3"}, ] [[package]] name = "typing-extensions" version = "4.13.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, ] [[package]] name = "unidecode" version = "1.4.0" description = "ASCII transliterations of Unicode text" optional = false python-versions = ">=3.7" groups = ["dev"] files = [ {file = "Unidecode-1.4.0-py3-none-any.whl", hash = "sha256:c3c7606c27503ad8d501270406e345ddb480a7b5f38827eafe4fa82a137f0021"}, {file = "Unidecode-1.4.0.tar.gz", hash = "sha256:ce35985008338b676573023acc382d62c264f307c8f7963733405add37ea2b23"}, ] [[package]] name = "virtualenv" version = "20.13.0" description = "Virtual Python Environment builder" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" groups = ["dev"] files = [ {file = "virtualenv-20.13.0-py2.py3-none-any.whl", hash = "sha256:339f16c4a86b44240ba7223d0f93a7887c3ca04b5f9c8129da7958447d079b09"}, {file = "virtualenv-20.13.0.tar.gz", hash = "sha256:d8458cf8d59d0ea495ad9b34c2599487f8a7772d796f9910858376d1600dd2dd"}, ] [package.dependencies] distlib = ">=0.3.1,<1" filelock = ">=3.2,<4" platformdirs = ">=2,<3" six = ">=1.9.0,<2" [package.extras] docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=21.3)"] testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "packaging (>=20.0) ; python_version > \"3.4\"", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0" content-hash = "fef5732c8b443ab4a7d7f997abb08cc050886e42b3e5c882df3f5b3ecf3e7314" p1c2u-pathable-263aef1/pyproject.toml000066400000000000000000000047351520312417300175560ustar00rootroot00000000000000[build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" [tool.coverage.run] branch = true source =["pathable"] [tool.coverage.xml] output = "reports/coverage.xml" [tool.mypy] files = "pathable" strict = true # files = "pathable" # check_untyped_defs = true # disallow_subclassing_any = true # disallow_untyped_calls = true # disallow_untyped_defs = false # ignore_missing_imports = false # show_column_numbers = true # show_none_errors = true # strict_optional = true # warn_incomplete_stub = true # warn_no_return = true # warn_redundant_casts = true # warn_return_any = true # warn_unused_configs = true # warn_unused_ignores = true # allow_redefinition = true # no_implicit_optional = true # local_partial_types = true # strict_equality = true [tool.poetry] name = "pathable" version = "0.6.0" description = "Object-oriented paths" authors = ["Artur Maciag "] license = "Apache-2.0" readme = "README.md" repository = "https://github.com/p1c2u/pathable" keywords = ["dict", "dictionary", "list", "lookup", "path", "pathable"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Topic :: Software Development :: Libraries :: Python Modules", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Topic :: Software Development :: Libraries" ] [tool.poetry.dependencies] python = ">=3.10,<4.0" [tool.poetry.group.dev.dependencies] tbump = "^6.11.0" pre-commit = "*" pytest = ">=6.2.5,<9.0.0" pytest-cov = ">=6.1.1,<8.0.0" isort = "^6.0.1" black = "^25.1.0" flynt = "1.0.6" mypy = "^1.15" [tool.pytest.ini_options] addopts = """ --capture=no --verbose --showlocals --junitxml=reports/junit.xml --cov=pathable --cov-report=term-missing --cov-report=xml """ [tool.black] line-length = 79 [tool.isort] profile = "black" line_length = 79 force_single_line = true [tool.tbump] [tool.tbump.git] message_template = "Version {new_version}" tag_template = "{new_version}" [tool.tbump.version] current = "0.6.0" regex = ''' (?P\d+) \. (?P\d+) \. (?P\d+) (?P[a-z]+\d+)? ''' [[tool.tbump.file]] src = "pathable/__init__.py" [[tool.tbump.file]] src = "pyproject.toml" search = 'version = "{current_version}"' p1c2u-pathable-263aef1/tests/000077500000000000000000000000001520312417300157735ustar00rootroot00000000000000p1c2u-pathable-263aef1/tests/__init__.py000066400000000000000000000000001520312417300200720ustar00rootroot00000000000000p1c2u-pathable-263aef1/tests/benchmarks/000077500000000000000000000000001520312417300201105ustar00rootroot00000000000000p1c2u-pathable-263aef1/tests/benchmarks/__init__.py000066400000000000000000000003531520312417300222220ustar00rootroot00000000000000"""Benchmark scripts for pathable. Run as modules for the most reliable import behavior: - `python -m tests.benchmarks.bench_lookup --output bench-lookup.json` - `python -m tests.benchmarks.bench_parse --output bench-parse.json` """ p1c2u-pathable-263aef1/tests/benchmarks/bench_lookup.py000066400000000000000000000205771520312417300231450ustar00rootroot00000000000000"""Benchmarks for LookupPath / LookupAccessor hot paths. These benchmarks avoid filesystem I/O (too noisy for CI) and focus on: - traversal cost (cache disabled) - cache hit speed - LRU eviction patterns - keys/contains/iter overhead on large mappings """ import argparse from typing import Any from typing import Iterable from pathable.accessors import LookupAccessor from pathable.paths import LookupPath try: # Prefer module execution: `python -m tests.benchmarks.bench_lookup ...` from .bench_utils import BenchmarkResult from .bench_utils import add_common_args from .bench_utils import results_to_json from .bench_utils import run_benchmark from .bench_utils import write_json except ImportError: # pragma: no cover # Allow direct execution: `python tests/benchmarks/bench_lookup.py ...` from bench_utils import BenchmarkResult # type: ignore[no-redef] from bench_utils import add_common_args # type: ignore[no-redef] from bench_utils import results_to_json # type: ignore[no-redef] from bench_utils import run_benchmark # type: ignore[no-redef] from bench_utils import write_json # type: ignore[no-redef] def _build_deep_tree(depth: int) -> dict[str, Any]: node: dict[str, Any] = {"value": 1} for i in range(depth - 1, -1, -1): node = {f"k{i}": node} return node def _deep_keys(depth: int) -> tuple[str, ...]: return tuple(f"k{i}" for i in range(depth)) def _make_deep_path(root: LookupPath, depth: int) -> LookupPath: p = root for k in _deep_keys(depth): p = p / k return p def _build_mapping(size: int) -> dict[str, int]: return {f"k{i}": i for i in range(size)} def main(argv: Iterable[str] | None = None) -> int: parser = argparse.ArgumentParser() add_common_args(parser) args = parser.parse_args(list(argv) if argv is not None else None) repeats: int = args.repeats warmup_loops: int = args.warmup_loops results: list[BenchmarkResult] = [] # --- Lookup read benchmarks --- depth = 25 if not args.quick else 10 loops_hit = 200_000 if not args.quick else 20_000 loops_miss = 80_000 if not args.quick else 10_000 data = _build_deep_tree(depth) root = LookupPath.from_lookup(data) deep = _make_deep_path(root, depth) # Cache hit: repeated reads of the same path. results.append( run_benchmark( f"lookup.read_value.cache_hit.depth{depth}", deep.read_value, loops=loops_hit, repeats=repeats, warmup_loops=warmup_loops, ) ) # __getitem__ leaf read: should return value for non-traversable child. leaf_parent = LookupPath.from_lookup( {"root": {"branch": {"leaf": "value"}}}, "root", "branch" ) def getitem_leaf(_p: LookupPath = leaf_parent) -> None: _ = _p["leaf"] loops_getitem_leaf = 200_000 if not args.quick else 20_000 results.append( run_benchmark( "lookup.getitem.leaf", getitem_leaf, loops=loops_getitem_leaf, repeats=repeats, warmup_loops=warmup_loops, ) ) # __getitem__ branch read: should return child path for traversable child. branch_parent = LookupPath.from_lookup( {"root": {"branch": {"child": {"x": 1}}}}, "root", "branch" ) def getitem_branch(_p: LookupPath = branch_parent) -> None: _ = _p["child"] loops_getitem_branch = 200_000 if not args.quick else 20_000 results.append( run_benchmark( "lookup.getitem.branch", getitem_branch, loops=loops_getitem_branch, repeats=repeats, warmup_loops=warmup_loops, ) ) # Cache miss cost: disable cache and repeatedly read. deep_accessor = deep.accessor if not isinstance(deep_accessor, LookupAccessor): raise TypeError("Expected LookupPath.accessor to be LookupAccessor") deep_accessor.disable_cache() results.append( run_benchmark( f"lookup.read_value.cache_disabled.depth{depth}", deep.read_value, loops=loops_miss, repeats=repeats, warmup_loops=warmup_loops, ) ) # LRU eviction: alternate two distinct deep paths with maxsize=1. data2 = { "a": _build_deep_tree(depth), "x": _build_deep_tree(depth), } root2 = LookupPath.from_lookup(data2) a_path = _make_deep_path(root2 / "a", depth) x_path = _make_deep_path(root2 / "x", depth) root2_accessor = root2.accessor if not isinstance(root2_accessor, LookupAccessor): raise TypeError("Expected LookupPath.accessor to be LookupAccessor") root2_accessor.enable_cache(maxsize=1) toggle = {"i": 0} def read_alternating() -> None: if toggle["i"] & 1: x_path.read_value() else: a_path.read_value() toggle["i"] += 1 loops_eviction = 120_000 if not args.quick else 15_000 results.append( run_benchmark( f"lookup.read_value.eviction_alternate.maxsize1.depth{depth}", read_alternating, loops=loops_eviction, repeats=repeats, warmup_loops=warmup_loops, ) ) # --- Large mapping operations --- sizes = [10, 1_000, 50_000] if not args.quick else [10, 1_000] for size in sizes: mapping = _build_mapping(size) p = LookupPath.from_lookup({"root": mapping}) / "root" # keys() materializes a list in LookupAccessor.keys for mappings. loops_keys = 5_000 if size <= 1_000 else 200 if args.quick: loops_keys = min(loops_keys, 500) results.append( run_benchmark( f"lookup.keys.mapping.size{size}", p.keys, loops=loops_keys, repeats=repeats, warmup_loops=warmup_loops, ) ) # contains: AccessorPath.__contains__ calls keys() then `in`. probe_key = f"k{size - 1}" if size else "k0" loops_contains = 20_000 if size <= 1_000 else 500 if args.quick: loops_contains = min(loops_contains, 2_000) def contains_probe(_p: LookupPath = p, _key: str = probe_key) -> None: _ = _key in _p results.append( run_benchmark( f"lookup.contains.mapping.size{size}", contains_probe, loops=loops_contains, repeats=repeats, warmup_loops=warmup_loops, ) ) # floordiv (`//`): strict child assertion. # Previously this was implemented via keys()+membership, which # materializes all keys for mappings. loops_floordiv = 20_000 if size <= 1_000 else 500 if args.quick: loops_floordiv = min(loops_floordiv, 2_000) def floordiv_probe(_p: LookupPath = p, _key: str = probe_key) -> None: _ = _p // _key results.append( run_benchmark( f"lookup.floordiv.mapping.size{size}", floordiv_probe, loops=loops_floordiv, repeats=repeats, warmup_loops=warmup_loops, ) ) missing_key = "missing" def floordiv_missing_probe( _p: LookupPath = p, _key: str = missing_key ) -> None: try: _ = _p // _key except KeyError: return results.append( run_benchmark( f"lookup.floordiv_missing.mapping.size{size}", floordiv_missing_probe, loops=loops_floordiv, repeats=repeats, warmup_loops=warmup_loops, ) ) # iterating children: should call keys() once and yield child paths. loops_iter = 500 if size <= 1_000 else 3 if args.quick: loops_iter = min(loops_iter, 50) def iter_children(_p: LookupPath = p) -> None: for _ in _p: pass results.append( run_benchmark( f"lookup.iter_children.mapping.size{size}", iter_children, loops=loops_iter, repeats=repeats, warmup_loops=warmup_loops, ) ) payload = results_to_json(results=results) write_json(args.output, payload) return 0 if __name__ == "__main__": raise SystemExit(main()) p1c2u-pathable-263aef1/tests/benchmarks/bench_parse.py000066400000000000000000000056621520312417300227440ustar00rootroot00000000000000"""Benchmarks for parsing and BasePath construction.""" import argparse from typing import Iterable from pathable.paths import BasePath try: # Prefer module execution: `python -m tests.benchmarks.bench_parse ...` from .bench_utils import BenchmarkResult from .bench_utils import add_common_args from .bench_utils import results_to_json from .bench_utils import run_benchmark from .bench_utils import write_json except ImportError: # pragma: no cover # Allow direct execution: `python tests/benchmarks/bench_parse.py ...` from bench_utils import BenchmarkResult # type: ignore[no-redef] from bench_utils import add_common_args # type: ignore[no-redef] from bench_utils import results_to_json # type: ignore[no-redef] from bench_utils import run_benchmark # type: ignore[no-redef] from bench_utils import write_json # type: ignore[no-redef] def _build_args(n: int) -> list[object]: # Mix in segments that exercise splitting, filtering, bytes decode, and ints. out: list[object] = [] for i in range(n): if i % 11 == 0: out.append(".") elif i % 11 == 1: out.append(b"bytes") elif i % 11 == 2: out.append(i) elif i % 11 == 3: out.append(f"a/{i}/b") else: out.append(f"seg{i}") return out def main(argv: Iterable[str] | None = None) -> int: parser = argparse.ArgumentParser() add_common_args(parser) args = parser.parse_args(list(argv) if argv is not None else None) repeats: int = args.repeats warmup_loops: int = args.warmup_loops results: list[BenchmarkResult] = [] sizes = [10, 100, 1_000] if not args.quick else [10, 100] for n in sizes: inputs = _build_args(n) inputs_t = tuple(inputs) loops_parse = 80_000 if n <= 100 else 10_000 if args.quick: loops_parse = min(loops_parse, 10_000) def do_parse(_inputs: tuple[object, ...] = inputs_t) -> None: BasePath._parse_args(_inputs) results.append( run_benchmark( f"parse.BasePath._parse_args.size{n}", do_parse, loops=loops_parse, repeats=repeats, warmup_loops=warmup_loops, ) ) loops_basepath = 60_000 if n <= 100 else 3_000 if args.quick: loops_basepath = min(loops_basepath, 5_000) def do_basepath(_inputs: tuple[object, ...] = inputs_t) -> None: BasePath(*_inputs) results.append( run_benchmark( f"paths.BasePath.constructor.size{n}", do_basepath, loops=loops_basepath, repeats=repeats, warmup_loops=warmup_loops, ) ) payload = results_to_json(results=results) write_json(args.output, payload) return 0 if __name__ == "__main__": raise SystemExit(main()) p1c2u-pathable-263aef1/tests/benchmarks/bench_utils.py000066400000000000000000000101631520312417300227620ustar00rootroot00000000000000"""Minimal benchmark utilities (dependency-free). This module is intentionally simple to keep benchmarks stable and easy to run locally and in CI. """ import argparse import json import os import platform import statistics import sys import time from dataclasses import dataclass from typing import Any from typing import Callable from typing import Iterable from typing import Mapping from typing import MutableMapping @dataclass(frozen=True) class BenchmarkResult: name: str loops: int repeats: int warmup_loops: int times_s: tuple[float, ...] @property def total_s_median(self) -> float: return statistics.median(self.times_s) @property def per_loop_s_median(self) -> float: if self.loops <= 0: return float("inf") return self.total_s_median / self.loops @property def ops_per_sec_median(self) -> float: per = self.per_loop_s_median if per <= 0: return float("inf") return 1.0 / per def _safe_int_env(name: str) -> int | None: value = os.environ.get(name) if value is None: return None try: return int(value) except ValueError: return None def default_meta() -> dict[str, Any]: return { "python": sys.version, "python_implementation": platform.python_implementation(), "platform": platform.platform(), "machine": platform.machine(), "processor": platform.processor(), "pythondotorg": platform.python_build(), "py_hash_seed": os.environ.get("PYTHONHASHSEED"), "github_sha": os.environ.get("GITHUB_SHA"), "github_ref": os.environ.get("GITHUB_REF"), "ci": os.environ.get("CI"), } def run_benchmark( name: str, func: Callable[[], Any], *, loops: int, repeats: int = 5, warmup_loops: int = 1, ) -> BenchmarkResult: if loops <= 0: raise ValueError("loops must be > 0") if repeats <= 0: raise ValueError("repeats must be > 0") if warmup_loops < 0: raise ValueError("warmup_loops must be >= 0") for _ in range(warmup_loops): for __ in range(loops): func() times: list[float] = [] for _ in range(repeats): start = time.perf_counter() for __ in range(loops): func() end = time.perf_counter() times.append(end - start) return BenchmarkResult( name=name, loops=loops, repeats=repeats, warmup_loops=warmup_loops, times_s=tuple(times), ) def results_to_json( *, results: Iterable[BenchmarkResult], meta: Mapping[str, Any] | None = None, ) -> dict[str, Any]: out: dict[str, Any] = { "meta": dict(meta or default_meta()), "benchmarks": {}, } bench: MutableMapping[str, Any] = out["benchmarks"] for r in results: bench[r.name] = { "loops": r.loops, "repeats": r.repeats, "warmup_loops": r.warmup_loops, "times_s": list(r.times_s), "median_total_s": r.total_s_median, "median_per_loop_s": r.per_loop_s_median, "median_ops_per_sec": r.ops_per_sec_median, } return out def add_common_args(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--output", required=True, help="Write JSON results to this file.", ) parser.add_argument( "--quick", action="store_true", help="Run fewer iterations for a fast sanity check.", ) parser.add_argument( "--repeats", type=int, default=_safe_int_env("PATHABLE_BENCH_REPEATS") or 5, help="Number of repeats per scenario (median is reported).", ) parser.add_argument( "--warmup-loops", type=int, default=_safe_int_env("PATHABLE_BENCH_WARMUP") or 1, help="Warmup passes before timing.", ) def write_json(path: str, payload: Mapping[str, Any]) -> None: os.makedirs(os.path.dirname(path) or ".", exist_ok=True) with open(path, "w", encoding="utf-8") as f: json.dump(payload, f, indent=2, sort_keys=True) f.write("\n") p1c2u-pathable-263aef1/tests/benchmarks/compare_results.py000066400000000000000000000112231520312417300236700ustar00rootroot00000000000000"""Compare two pathable benchmark JSON results. Exits non-zero if candidate regresses beyond the configured tolerance. This is meant for local regression checking and optional CI gating. """ import argparse import json from dataclasses import dataclass from typing import Any from typing import Iterable from typing import Mapping from typing import cast @dataclass(frozen=True) class ScenarioComparison: name: str baseline_ops: float candidate_ops: float ratio: float baseline_scenario: str candidate_scenario: str def _canonicalize_scenario_name(name: str) -> str: """Return a stable scenario identifier across benchmark renames. This keeps `compare_results.py` compatible with older JSON reports. """ aliases: tuple[tuple[str, str], ...] = ( # Historical rename in bench_parse: # parse.parse_args.sizeN -> parse.BasePath._parse_args.sizeN # Canonicalize both to parse.args.sizeN. ("parse.parse_args.", "parse.args."), ("parse.BasePath._parse_args.", "parse.args."), ) for prefix, replacement in aliases: if name.startswith(prefix): return replacement + name[len(prefix) :] return name def _load(path: str) -> Mapping[str, Any]: with open(path, "r", encoding="utf-8") as f: data_any = json.load(f) if not isinstance(data_any, dict): raise ValueError("Invalid report: expected top-level JSON object") return cast(dict[str, Any], data_any) def _extract_ops(report: Mapping[str, Any]) -> dict[str, float]: benchmarks = report.get("benchmarks") if not isinstance(benchmarks, dict): raise ValueError("Invalid report: missing 'benchmarks' dict") benchmarks_d = cast(dict[str, Any], benchmarks) out: dict[str, float] = {} for name, payload in benchmarks_d.items(): if not isinstance(payload, dict): continue payload_d = cast(dict[str, Any], payload) ops_any = payload_d.get("median_ops_per_sec") ops = ops_any if isinstance(ops_any, (int, float)) else None if ops is not None: out[name] = float(ops) return out def compare( *, baseline: Mapping[str, Any], candidate: Mapping[str, Any], tolerance: float, ) -> tuple[list[ScenarioComparison], list[ScenarioComparison]]: if tolerance < 0: raise ValueError("tolerance must be >= 0") b_raw = _extract_ops(baseline) c_raw = _extract_ops(candidate) b: dict[str, tuple[str, float]] = {} c: dict[str, tuple[str, float]] = {} for name, ops in b_raw.items(): canon = _canonicalize_scenario_name(name) b.setdefault(canon, (name, ops)) for name, ops in c_raw.items(): canon = _canonicalize_scenario_name(name) c.setdefault(canon, (name, ops)) comparisons: list[ScenarioComparison] = [] for name in sorted(set(b) & set(c)): b_name, bops = b[name] c_name, cops = c[name] ratio = cops / bops if bops > 0 else float("inf") comparisons.append( ScenarioComparison( name=name, baseline_ops=bops, candidate_ops=cops, ratio=ratio, baseline_scenario=b_name, candidate_scenario=c_name, ) ) # Regression if candidate is slower by more than tolerance: # candidate_ops < baseline_ops * (1 - tolerance) floor_ratio = 1.0 - tolerance regressions = [x for x in comparisons if x.ratio < floor_ratio] return comparisons, regressions def main(argv: Iterable[str] | None = None) -> int: parser = argparse.ArgumentParser() parser.add_argument("--baseline", required=True) parser.add_argument("--candidate", required=True) parser.add_argument( "--tolerance", type=float, default=0.20, help="Allowed slowdown (e.g. 0.20 means 20% slower allowed).", ) args = parser.parse_args(list(argv) if argv is not None else None) baseline = _load(args.baseline) candidate = _load(args.candidate) comparisons, regressions = compare( baseline=baseline, candidate=candidate, tolerance=args.tolerance, ) print("scenario\tbaseline_ops/s\tcandidate_ops/s\tratio") for c in comparisons: print( f"{c.name}\t{c.baseline_ops:.2f}\t{c.candidate_ops:.2f}\t{c.ratio:.3f}" ) if regressions: print("\nREGRESSIONS:") for r in regressions: print( f"- {r.name}: {r.ratio:.3f}x (baseline {r.baseline_ops:.2f} ops/s, candidate {r.candidate_ops:.2f} ops/s)" ) return 1 return 0 if __name__ == "__main__": raise SystemExit(main()) p1c2u-pathable-263aef1/tests/unit/000077500000000000000000000000001520312417300167525ustar00rootroot00000000000000p1c2u-pathable-263aef1/tests/unit/__init__.py000066400000000000000000000000001520312417300210510ustar00rootroot00000000000000p1c2u-pathable-263aef1/tests/unit/test_filesystem.py000066400000000000000000000312141520312417300225500ustar00rootroot00000000000000"""Tests for PathAccessor and FilesystemPath.""" import os from pathlib import Path from unittest.mock import Mock from unittest.mock import patch import pytest from pathable.accessors import PathAccessor from pathable.paths import FilesystemPath class TestPathAccessorStat: """Tests for PathAccessor.stat() method.""" def test_stat_existing_file(self, tmp_path): """Test stat returns dict of stat attributes for existing file.""" test_file = tmp_path / "test.txt" test_file.write_text("content") accessor = PathAccessor(tmp_path) result = accessor.stat(["test.txt"]) assert result is not None assert isinstance(result, dict) assert "st_size" in result assert "st_mode" in result assert "st_mtime" in result assert result["st_size"] == 7 # "content" is 7 bytes def test_stat_existing_directory(self, tmp_path): """Test stat returns dict for existing directory.""" test_dir = tmp_path / "testdir" test_dir.mkdir() accessor = PathAccessor(tmp_path) result = accessor.stat(["testdir"]) assert result is not None assert isinstance(result, dict) assert "st_mode" in result def test_stat_nonexistent_path_returns_none(self, tmp_path): """Test stat returns None when path does not exist.""" accessor = PathAccessor(tmp_path) result = accessor.stat(["nonexistent.txt"]) assert result is None def test_stat_oserror_returns_none(self, tmp_path): """Test stat returns None when OSError is raised.""" accessor = PathAccessor(tmp_path) # Mock the joinpath to raise OSError with patch.object(Path, "joinpath") as mock_joinpath: mock_path = Mock() mock_path.stat.side_effect = OSError("Permission denied") mock_path.lstat.side_effect = OSError("Permission denied") mock_joinpath.return_value = mock_path result = accessor.stat(["test.txt"]) assert result is None def test_stat_nested_path(self, tmp_path): """Test stat works with nested paths.""" nested_dir = tmp_path / "dir1" / "dir2" nested_dir.mkdir(parents=True) test_file = nested_dir / "test.txt" test_file.write_text("nested") accessor = PathAccessor(tmp_path) result = accessor.stat(["dir1", "dir2", "test.txt"]) assert result is not None assert result["st_size"] == 6 # "nested" is 6 bytes def test_stat_symlink_not_followed(self, tmp_path): """Test stat does not follow symlinks.""" target_file = tmp_path / "target.txt" target_file.write_text("target content") link_file = tmp_path / "link.txt" link_file.symlink_to(target_file) accessor = PathAccessor(tmp_path) result = accessor.stat(["link.txt"]) assert result is not None # Should use stat(follow_symlinks=False) # which means we get the symlink's own stat, not the target's # Verify it's the symlink by checking the size doesn't match target target_stat = target_file.stat() link_stat = link_file.lstat() assert result["st_size"] == link_stat.st_size # For symlinks, size should be small (path length), not target size assert result["st_size"] != target_stat.st_size class TestPathAccessorKeys: """Tests for PathAccessor.keys() method.""" def test_keys_empty_directory(self, tmp_path): """Test keys returns empty list for empty directory.""" accessor = PathAccessor(tmp_path) result = accessor.keys([]) assert result == [] def test_keys_with_files(self, tmp_path): """Test keys returns file names.""" (tmp_path / "file1.txt").write_text("content1") (tmp_path / "file2.txt").write_text("content2") (tmp_path / "file3.txt").write_text("content3") accessor = PathAccessor(tmp_path) result = accessor.keys([]) assert sorted(result) == ["file1.txt", "file2.txt", "file3.txt"] def test_keys_with_directories(self, tmp_path): """Test keys returns directory names.""" (tmp_path / "dir1").mkdir() (tmp_path / "dir2").mkdir() accessor = PathAccessor(tmp_path) result = accessor.keys([]) assert sorted(result) == ["dir1", "dir2"] def test_keys_mixed_files_and_directories(self, tmp_path): """Test keys returns both file and directory names.""" (tmp_path / "file.txt").write_text("content") (tmp_path / "dir").mkdir() accessor = PathAccessor(tmp_path) result = accessor.keys([]) assert sorted(result) == ["dir", "file.txt"] def test_keys_returns_names_not_paths(self, tmp_path): """Test keys returns entry names, not full paths.""" (tmp_path / "test.txt").write_text("content") accessor = PathAccessor(tmp_path) result = accessor.keys([]) # Should return just the name, not the full path assert result == ["test.txt"] assert all(not str(item).startswith("/") for item in result) def test_keys_nested_directory(self, tmp_path): """Test keys works with nested paths.""" nested_dir = tmp_path / "dir1" / "dir2" nested_dir.mkdir(parents=True) (nested_dir / "file1.txt").write_text("content1") (nested_dir / "file2.txt").write_text("content2") accessor = PathAccessor(tmp_path) result = accessor.keys(["dir1", "dir2"]) assert sorted(result) == ["file1.txt", "file2.txt"] def test_keys_hidden_files(self, tmp_path): """Test keys includes hidden files (Unix convention).""" (tmp_path / ".hidden").write_text("hidden") (tmp_path / "visible.txt").write_text("visible") accessor = PathAccessor(tmp_path) result = accessor.keys([]) assert sorted(result) == [".hidden", "visible.txt"] def test_keys_non_existing_directory(self, tmp_path): """Test keys raises KeyError for non-existing directory.""" accessor = PathAccessor(tmp_path / "invalid_key") with pytest.raises(KeyError): accessor.keys([]) def test_keys_raises_keyerror_for_not_a_directory(self, tmp_path): """Test keys raises KeyError when trying to iterate a file.""" test_file = tmp_path / "test.txt" test_file.write_text("content") accessor = PathAccessor(tmp_path) with pytest.raises(KeyError): accessor.keys(["test.txt"]) def test_keys_propagates_permission_error(self, tmp_path): """Test keys propagates PermissionError instead of converting to KeyError.""" accessor = PathAccessor(tmp_path) # Mock iterdir to raise PermissionError with patch.object(Path, "iterdir") as mock_iterdir: mock_iterdir.side_effect = PermissionError("Permission denied") with pytest.raises(PermissionError): accessor.keys([]) class TestPathAccessorLen: """Tests for PathAccessor.len() method.""" def test_len_non_existing_directory(self, tmp_path): """Test len raises KeyError for non-existing directory.""" accessor = PathAccessor(tmp_path / "invalid_key") with pytest.raises(KeyError): accessor.len([]) def test_len_raises_keyerror_for_not_a_directory(self, tmp_path): """Test len raises KeyError when trying to iterate a file.""" test_file = tmp_path / "test.txt" test_file.write_text("content") accessor = PathAccessor(tmp_path) with pytest.raises(KeyError): accessor.len(["test.txt"]) def test_len_propagates_permission_error(self, tmp_path): """Test len propagates PermissionError instead of converting to KeyError.""" accessor = PathAccessor(tmp_path) # Mock iterdir to raise PermissionError with patch.object(Path, "iterdir") as mock_iterdir: mock_iterdir.side_effect = PermissionError("Permission denied") with pytest.raises(PermissionError): accessor.len([]) class TestPathAccessorIsTraversable: def test_is_traversable_true_for_directory(self, tmp_path): (tmp_path / "d").mkdir() accessor = PathAccessor(tmp_path) assert accessor.is_traversable([]) is True assert accessor.is_traversable(["d"]) is True def test_is_traversable_false_for_file(self, tmp_path): (tmp_path / "f").write_text("x") accessor = PathAccessor(tmp_path) assert accessor.is_traversable(["f"]) is False def test_is_traversable_false_for_missing(self, tmp_path): accessor = PathAccessor(tmp_path) assert accessor.is_traversable(["missing"]) is False class TestFilesystemPathExists: """Tests for FilesystemPath.exists() method.""" def test_exists_true_for_file(self, tmp_path): """Test exists returns True for existing file.""" test_file = tmp_path / "test.txt" test_file.write_text("content") path = FilesystemPath.from_path(tmp_path / "test.txt") result = path.exists() assert result is True def test_exists_true_for_directory(self, tmp_path): """Test exists returns True for existing directory.""" test_dir = tmp_path / "testdir" test_dir.mkdir() path = FilesystemPath.from_path(tmp_path / "testdir") result = path.exists() assert result is True def test_exists_false_for_nonexistent(self, tmp_path): """Test exists returns False for nonexistent path.""" path = FilesystemPath.from_path(tmp_path / "nonexistent.txt") result = path.exists() assert result is False def test_exists_false_when_stat_raises_oserror(self, tmp_path): """Test exists returns False when stat raises OSError.""" test_file = tmp_path / "test.txt" test_file.write_text("content") path = FilesystemPath.from_path(test_file) # Mock the accessor's stat to raise OSError by returning None with patch.object(path.accessor, "stat", return_value=None): result = path.exists() assert result is False def test_exists_with_nested_path(self, tmp_path): """Test exists works with nested paths.""" nested_dir = tmp_path / "dir1" / "dir2" nested_dir.mkdir(parents=True) test_file = nested_dir / "test.txt" test_file.write_text("content") path = FilesystemPath.from_path(test_file) result = path.exists() assert result is True class TestFilesystemPathIsTraversable: def test_is_traversable(self, tmp_path): (tmp_path / "d").mkdir() (tmp_path / "f").write_text("x") root = FilesystemPath.from_path(tmp_path) assert root.is_traversable() is True assert (root / "d").is_traversable() is True assert (root / "f").is_traversable() is False assert (root / "missing").is_traversable() is False class TestFilesystemPathKeys: """Tests for FilesystemPath.keys() method.""" def test_keys_empty_directory(self, tmp_path): """Test keys returns empty sequence for empty directory.""" path = FilesystemPath.from_path(tmp_path) result = path.keys() assert list(result) == [] def test_keys_with_files(self, tmp_path): """Test keys returns file names.""" (tmp_path / "file1.txt").write_text("content1") (tmp_path / "file2.txt").write_text("content2") path = FilesystemPath.from_path(tmp_path) result = path.keys() assert sorted(result) == ["file1.txt", "file2.txt"] def test_keys_returns_names_not_paths(self, tmp_path): """Test keys returns entry names, not full paths.""" (tmp_path / "test.txt").write_text("content") path = FilesystemPath.from_path(tmp_path) result = path.keys() # Should return just the name, not the full path assert list(result) == ["test.txt"] def test_keys_nested_with_child_path(self, tmp_path): """Test keys works when using child paths.""" nested_dir = tmp_path / "dir1" nested_dir.mkdir() (nested_dir / "file1.txt").write_text("content1") (nested_dir / "file2.txt").write_text("content2") path = FilesystemPath.from_path(tmp_path) child_path = path / "dir1" result = child_path.keys() assert sorted(result) == ["file1.txt", "file2.txt"] class TestFilesystemPathLen: """Tests for FilesystemPath.len() method.""" def test_len_non_existing_directory(self, tmp_path): """Test len raises KeyError for non-existing directory.""" path = FilesystemPath.from_path(tmp_path / "invalid_key") with pytest.raises(KeyError): len(path) p1c2u-pathable-263aef1/tests/unit/test_lookup_cache.py000066400000000000000000000056461520312417300230320ustar00rootroot00000000000000from typing import Any from pathable.paths import LookupPath class CounterDict(dict): def __init__(self, *args: Any, **kwargs: Any): super().__init__(*args, **kwargs) self.getitem_counter = 0 def __getitem__(self, key: Any) -> Any: self.getitem_counter += 1 return super().__getitem__(key) def test_lookuppath_caches_within_instance() -> None: value = {"test3": "test4"} resource = CounterDict(test1=1, test2=value) p = LookupPath.from_lookup(resource, "test2") assert p.read_value() == value assert p.read_value() == value assert resource.getitem_counter == 1 def test_lookuppath_cache_is_not_shared_between_instances() -> None: value = {"test3": "test4"} resource = CounterDict(test1=1, test2=value) p1 = LookupPath.from_lookup(resource, "test2") p2 = LookupPath.from_lookup(resource, "test2") assert p1.read_value() == value assert p1.read_value() == value assert resource.getitem_counter == 1 assert p2.read_value() == value assert p2.read_value() == value assert resource.getitem_counter == 2 def test_lookup_accessor_disable_cache_reads_each_time() -> None: resource = CounterDict({"a": {"b": "value"}}) path = LookupPath.from_lookup(resource, "a/b") # Disable caching on this accessor instance. path.accessor.disable_cache() path.read_value() path.read_value() assert resource.getitem_counter == 2 def test_lookup_accessor_clear_cache_forces_reread() -> None: resource = CounterDict({"a": {"b": "value"}}) path = LookupPath.from_lookup(resource, "a/b") path.read_value() assert resource.getitem_counter == 1 path.accessor.clear_cache() path.read_value() assert resource.getitem_counter == 2 def test_lookup_accessor_lru_eviction_respects_maxsize() -> None: resource = CounterDict({"a": {"b": "value"}, "x": {"y": "value"}}) root = LookupPath.from_lookup(resource) a = root / "a" / "b" x = root / "x" / "y" root.accessor.enable_cache(maxsize=1) # Populate cache with a/b a.read_value() assert resource.getitem_counter == 1 # Add second key -> should evict first due to maxsize=1 x.read_value() assert resource.getitem_counter == 2 # a/b should be a cache miss now a.read_value() assert resource.getitem_counter == 3 def test_lookup_accessor_node_is_immutable() -> None: value1 = {"v": 1} value2 = {"v": 2} resource1 = CounterDict(test2=value1) resource2 = CounterDict(test2=value2) p = LookupPath.from_lookup(resource1, "test2") assert p.read_value() == value1 assert resource1.getitem_counter == 1 assert resource2.getitem_counter == 0 try: p.accessor.node = resource2 # type: ignore[misc] except AttributeError: pass else: raise AssertionError("Expected node to be immutable") # Still reads from the original node. assert p.read_value() == value1 p1c2u-pathable-263aef1/tests/unit/test_parsers.py000066400000000000000000000043771520312417300220550ustar00rootroot00000000000000from uuid import uuid4 import pytest from pathable.parsers import parse_parts class TestParseParts: separator = "/" def test_empty(self): parts = [] result = parse_parts(parts, self.separator) assert result == [] def test_one(self): parts = ["test"] result = parse_parts(parts, self.separator) assert result == [ "test", ] def test_simple(self): parts = ["test", "test1", "test2"] result = parse_parts(parts, self.separator) assert result == ["test", "test1", "test2"] def test_none(self): parts = ["test", None, "test1", None, "test2"] result = parse_parts(parts, self.separator) assert result == ["test", "test1", "test2"] def test_relative(self): parts = ["test", ".", "test1", ".", "test2"] result = parse_parts(parts, self.separator) assert result == ["test", "test1", "test2"] def test_separator(self): sep_part = "test1{sep}test2{sep}test3".format(sep=self.separator) parts = ["test", sep_part] result = parse_parts(parts, self.separator) assert result == ["test", "test1", "test2", "test3"] def test_separator_with_relative(self): sep_part = "test1{sep}.{sep}test2{sep}.{sep}test3".format( sep=self.separator ) parts = ["test", sep_part] result = parse_parts(parts, self.separator) assert result == ["test", "test1", "test2", "test3"] def test_int(self): parts = ["test", 1, "test2"] result = parse_parts(parts, self.separator) assert result == ["test", 1, "test2"] def test_hashable_passthrough(self): token = uuid4() parts = ["test", token, "test2"] result = parse_parts(parts, self.separator) assert result == ["test", token, "test2"] def test_bytes(self): parts = [b"test", b"test2"] result = parse_parts(parts, self.separator) assert result == ["test", "test2"] def test_invalid_part_message(self): parts = [[]] with pytest.raises( TypeError, match=r"part must be Hashable or None; got ", ): parse_parts(parts, self.separator) p1c2u-pathable-263aef1/tests/unit/test_paths.py000066400000000000000000001456561520312417300215230ustar00rootroot00000000000000from collections.abc import Hashable from collections.abc import Mapping from collections.abc import Sequence from pathlib import Path from types import GeneratorType from typing import Any from uuid import uuid4 import pytest from pathable.accessors import LookupAccessor from pathable.accessors import NodeAccessor from pathable.accessors import PathAccessor from pathable.parsers import SEPARATOR from pathable.paths import AccessorPath from pathable.paths import BasePath from pathable.paths import FilesystemPath from pathable.paths import LookupPath class MockAccessor(NodeAccessor[Mapping[Hashable, Any] | Any, Hashable, Any]): """Mock accessor.""" def __init__( self, *children_keys: str, content: Any = None, exists: bool = False ): super().__init__(None) self._children_keys = children_keys self._content = content self._exists = exists def keys(self, parts: Sequence[Hashable]) -> Any: return self._children_keys def len(self, parts: Sequence[Hashable]) -> int: return len(self._children_keys) def read(self, parts: Sequence[Hashable]) -> Mapping[Hashable, Any] | Any: return self._content class MockTraversableAccessor(NodeAccessor[Mapping[Any, Any], Hashable, Any]): """Mock accessor that implements _get_subnode for testing fast paths.""" def __init__(self, node: Mapping[Any, Any]): super().__init__(node) def keys(self, parts: Sequence[Hashable]) -> Any: node = self._get_node(self.node, parts) if isinstance(node, Mapping): return list(node.keys()) raise KeyError def len(self, parts: Sequence[Hashable]) -> int: keys = self.keys(parts) return len(keys) def read(self, parts: Sequence[Hashable]) -> Any: return self._read_node(self._get_node(self.node, parts)) @classmethod def _read_node(cls, node: Any) -> Any: return node @classmethod def _get_subnode(cls, node: Any, part: Hashable) -> Any: if not isinstance(node, Mapping): raise KeyError(part) try: return node[part] except KeyError as exc: raise KeyError(part) from exc class CountingTraversableAccessor( NodeAccessor[Mapping[Any, Any], Hashable, Any] ): """Accessor that counts traversal operations.""" def __init__(self, node: Mapping[Any, Any]): super().__init__(node) self.get_node_calls = 0 def keys(self, parts: Sequence[Hashable]) -> Any: node = self._get_node(self.node, parts) if isinstance(node, Mapping): return list(node.keys()) raise KeyError def len(self, parts: Sequence[Hashable]) -> int: keys = self.keys(parts) return len(keys) def read(self, parts: Sequence[Hashable]) -> Any: return self._read_node(self._get_node(self.node, parts)) @classmethod def _is_traversable_node(cls, node: Mapping[Any, Any] | Any) -> bool: return isinstance(node, Mapping) @classmethod def _read_node(cls, node: Any) -> Any: return node @classmethod def _get_subnode(cls, node: Any, part: Hashable) -> Any: if not isinstance(node, Mapping): raise KeyError(part) try: return node[part] except KeyError as exc: raise KeyError(part) from exc @classmethod def _get_node(cls, node: Any, parts: Sequence[Hashable]) -> Any: current = node for part in parts: current = cls._get_subnode(current, part) return current def __getitem__(self, parts: Sequence[Hashable]) -> Any: self.get_node_calls += 1 return super().__getitem__(parts) class MockPart(str): """Mock resource for testing purposes.""" def __init__(self, s: str): self.s = s self.str_counter = 0 def __str__(self) -> str: self.str_counter += 1 return self.s class MockResource(dict): """Mock resource for testing purposes.""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.getitem_counter = 0 def __getitem__(self, key: Hashable) -> Any: self.getitem_counter += 1 return super().__getitem__(key) class TestBasePathParseArgs: separator = "/" def test_empty(self): args = [] result = BasePath._parse_args(args, self.separator) assert result == () def test_string(self): args = ["test"] result = BasePath._parse_args(args, self.separator) assert result == ("test",) def test_string_many(self): args = ["test", "test2"] result = BasePath._parse_args(args, self.separator) assert result == ("test", "test2") def test_int(self): args = ["test", 1, "test2"] result = BasePath._parse_args(args, self.separator) assert result == ("test", 1, "test2") def test_bytes(self): args = [b"test", b"test2"] result = BasePath._parse_args(args, self.separator) assert result == ("test", "test2") def test_hashable_passthrough(self): token = uuid4() args = ["test", token, "test2"] result = BasePath._parse_args(args, self.separator) assert result == ("test", token, "test2") def test_path(self): args = [BasePath("test")] result = BasePath._parse_args(args, self.separator) assert result == ("test",) def test_pathlike(self): args = [Path("test"), Path("test2")] result = BasePath._parse_args(args, self.separator) assert result == ("test", "test2") def test_invalid_part(self): args = [[], []] with pytest.raises( TypeError, match=r"argument must be Hashable, bytes, os.PathLike, or BasePath; got ", ): BasePath._parse_args(args, self.separator) class TestBasePathInit: def test_default(self): p = BasePath() assert p.parts == () assert p.separator == SEPARATOR def test_part_text(self): part = "part" p = BasePath(part) assert p.parts == (part,) assert p.separator == SEPARATOR def test_part_binary(self): part = b"part" p = BasePath(part) assert p.parts == ("part",) assert p.separator == SEPARATOR def test_part_binary_many(self): part1 = b"part1" part2 = b"part2" p = BasePath(part1, part2) assert p.parts == ("part1", "part2") assert p.separator == SEPARATOR def test_part_text_many(self): part1 = "part1" part2 = "part2" p = BasePath(part1, part2) assert p.parts == (part1, part2) assert p.separator == SEPARATOR def test_part_path(self): part = "part" p1 = BasePath(part) p = BasePath(p1) assert p.parts == (part,) assert p.separator == SEPARATOR def test_part_path_many(self): part1 = "part1" part2 = "part2" p1 = BasePath(part1) p2 = BasePath(part2) p = BasePath(p1, p2) assert p.parts == (part1, part2) assert p.separator == SEPARATOR def test_separator(self): separator = "." p = BasePath(separator=separator) assert p.parts == () assert p.separator == separator class TestBasePathStr: def test_empty(self): p = BasePath() assert str(p) == "" def test_single(self): p = BasePath("part1") assert str(p) == "part1" def test_double(self): args = ["part1", "part2"] p = BasePath(*args) assert str(p) == SEPARATOR.join(args) def test_separator(self): args = ["part1", "part2"] separator = "," p = BasePath(*args, separator=separator) assert str(p) == separator.join(args) def test_cparts_cached(self): part = MockPart("part1") args = [ part, ] separator = "," p = BasePath(*args, separator=separator) assert str(p) == separator.join(args) assert part.str_counter == 1 assert str(p) == separator.join(args) assert part.str_counter == 1 class TestBasePathRepr: def test_empty(self): p = BasePath() assert repr(p) == "BasePath('')" def test_single(self): arg = "part1" p = BasePath(arg) assert repr(p) == f"BasePath('{arg}')" def test_double(self): args = ["part1", "part2"] p = BasePath(*args) p_str = SEPARATOR.join(args) assert repr(p) == f"BasePath('{p_str}')" def test_separator(self): args = ["part1", "part2"] separator = "," p = BasePath(*args, separator=separator) p_str = separator.join(args) assert repr(p) == f"BasePath('{p_str}')" class TestBasePathHash: def test_empty(self): p = BasePath() assert hash(p) == hash((p.parts,)) def test_single(self): p = BasePath("part1") assert hash(p) == hash((p.parts,)) def test_double(self): args = ["part1", "part2"] p = BasePath(*args) assert hash(p) == hash((p.parts,)) def test_separator(self): args = ["part1", "part2"] separator = "," p = BasePath(*args, separator=separator) # Separator is presentation, not identity; hash is parts-only. assert hash(p) == hash((p.parts,)) def test_cparts_cached(self): part = MockPart("part1") args = [ part, ] separator = "," p = BasePath(*args, separator=separator) # Hashing does not stringify parts. assert part.str_counter == 0 assert hash(p) == hash((p.parts,)) assert part.str_counter == 0 assert hash(p) == hash((p.parts,)) assert part.str_counter == 0 def test_separator_not_part_of_identity(self): # Separator is presentation only: two paths with the same parts # but different separators name the same address and are equal # under both __eq__ and __hash__. p1 = BasePath("a", separator="/") p2 = BasePath("a", separator=".") assert p1 == p2 assert hash(p1) == hash(p2) assert len({p1, p2}) == 1 def test_hash_is_cached(self): # Inspect __dict__ directly: cached_property stores the computed # value under its attribute name on the instance dict, so its # presence is the load-bearing signal that caching happened. p = BasePath("a", "b", "c") assert "_hash" not in p.__dict__ h = hash(p) assert p.__dict__["_hash"] == h # Subsequent calls return the same cached int without recomputing. assert hash(p) == h class TestBasePathMakeChild: @pytest.fixture def base_path(self): return BasePath(separator=SEPARATOR) def test_none(self, base_path): args = [] p = base_path._make_child(args) assert p.parts == tuple(args) assert p.separator == SEPARATOR def test_arg(self, base_path): args = ["part1"] p = base_path._make_child(args) assert p.parts == tuple(args) assert p.separator == SEPARATOR def test_arg_unparsed(self, base_path): parts = ["part1", "part2"] arg = SEPARATOR.join(parts) args = [arg] p = base_path._make_child(args) assert p.parts == tuple(parts) assert p.separator == SEPARATOR def test_args_many(self, base_path): args = ["part1", "part2"] p = base_path._make_child(args) assert p.parts == tuple(args) assert p.separator == SEPARATOR class TestBasePathMakeChildRelPath: @pytest.fixture def base_path(self): return BasePath(separator=SEPARATOR) def test_part(self, base_path): part = "part1" p = base_path._make_child_relpath(part) assert p.parts == (part,) assert p.separator == SEPARATOR class TestBasePathTruediv: def test_default_empty(self): p = BasePath() / "" assert p.parts == () assert p.separator == SEPARATOR @pytest.mark.parametrize( "part1,part2,parts,separator", ( [ "", "", (), SEPARATOR, ], [ "", "part1", ("part1",), SEPARATOR, ], [ "part1", "", ("part1",), SEPARATOR, ], [ "part1", "part2", ("part1", "part2"), SEPARATOR, ], [ b"", "", (), SEPARATOR, ], [ b"", "part1", ("part1",), SEPARATOR, ], [ b"part1", "", ("part1",), SEPARATOR, ], [ b"part1", "part2", ("part1", "part2"), SEPARATOR, ], [ "part1", BasePath("part2"), ("part1", "part2"), SEPARATOR, ], [ BasePath("part1"), "part2", ("part1", "part2"), SEPARATOR, ], [ BasePath("part1"), BasePath("part2"), ("part1", "part2"), SEPARATOR, ], ), ) def test_parts(self, part1, part2, parts, separator): p = BasePath(part1) / part2 assert p.parts == parts assert p.separator == separator def test_combined(self): part11 = "part11" part12 = "part12" part21 = "part21" part22 = "part22" part1 = SEPARATOR.join([part11, part12]) part2 = SEPARATOR.join([part21, part22]) p = BasePath(part1) / part2 assert p.parts == (part11, part12, part21, part22) assert p.separator == SEPARATOR def test_combined_different_separators(self): part11 = "part11" part12 = "part12" part21 = "part21" part22 = "part22" separator1 = "." part1 = separator1.join([part11, part12]) part2 = SEPARATOR.join([part21, part22]) p1 = BasePath(part2) p = BasePath(part1, separator=separator1) / p1 assert p.parts == (part11, part12, part21, part22) assert p.separator == separator1 def test_type_not_implemented(self): with pytest.raises(TypeError): BasePath() / [] class TestBasePathRtruediv: def test_default_empty(self): p = "" / BasePath() assert p.parts == () assert p.separator == SEPARATOR @pytest.mark.parametrize( "part1,part2,parts,separator", ( [ "", "", (), SEPARATOR, ], [ "", "part1", ("part1",), SEPARATOR, ], [ "part1", "", ("part1",), SEPARATOR, ], [ "part1", "part2", ("part1", "part2"), SEPARATOR, ], [ b"", "", (), SEPARATOR, ], [ b"", "part1", ("part1",), SEPARATOR, ], [ b"part1", "", ("part1",), SEPARATOR, ], [ b"part1", "part2", ("part1", "part2"), SEPARATOR, ], [ "part1", BasePath("part2"), ("part1", "part2"), SEPARATOR, ], [ BasePath("part1"), "part2", ("part1", "part2"), SEPARATOR, ], [ BasePath("part1"), BasePath("part2"), ("part1", "part2"), SEPARATOR, ], ), ) def test_parts(self, part1, part2, parts, separator): p = part1 / BasePath(part2) assert p.parts == parts assert p.separator == separator def test_combined(self): part11 = "part11" part12 = "part12" part21 = "part21" part22 = "part22" part1 = SEPARATOR.join([part11, part12]) part2 = SEPARATOR.join([part21, part22]) p = part1 / BasePath(part2) assert p.parts == (part11, part12, part21, part22) assert p.separator == SEPARATOR def test_type_not_implemented(self): with pytest.raises(TypeError): [] / BasePath() def test_preserves_separator(self): p = BasePath("b.c", separator=".") result = "a" / p assert result.parts == ("a", "b", "c") assert result.separator == "." class TestBasePathEq: @pytest.mark.parametrize( "part1,part2,expected", ( ["", "", True], ["", "part", False], ["part", "", False], ["part", "part", True], ["part", BasePath("part"), True], [BasePath("part"), "part", True], [BasePath("part"), BasePath("part"), True], ), ) def test_parts(self, part1, part2, expected): result = BasePath(part1) == BasePath(part2) assert result is expected def test_type_not_implemented(self): result = BasePath() == [] assert result is False def test_type_sensitive_parts(self): assert BasePath(0) != BasePath("0") def test_separator_not_part_of_equality(self): # Address-only identity: separator is presentation, not identity. assert BasePath("a", separator="/") == BasePath("a", separator=".") class TestBasePathLt: @pytest.mark.parametrize( "part1,part2,expected", ( ["", "", False], ["", "part", True], ["part", "", False], ["part", "part", False], ["part", "part2", True], ["part", BasePath("part"), False], ["part", BasePath("part2"), True], [BasePath("part"), "part", False], [BasePath("part"), BasePath("part"), False], [BasePath("part"), BasePath("part2"), True], ), ) def test_parts(self, part1, part2, expected): result = BasePath(part1) < BasePath(part2) assert result is expected def test_type_not_implemented(self): with pytest.raises(TypeError): BasePath() < [] def test_mixed_type_ordering_is_deterministic(self): # Ordering is based on (separator, type-aware cmp key). # This locks in the current rule that `int` parts sort before `str` # parts when their string forms are the same. assert BasePath(0) < BasePath("0") assert not (BasePath("0") < BasePath(0)) def test_separator_does_not_affect_ordering(self): # Ordering tracks identity: separator is not part of equality, # so it is not part of ordering either. Two paths with the same # parts compare as ordering-equivalent regardless of separator. p1 = BasePath("a", separator=".") p2 = BasePath("a", separator="/") assert not (p1 < p2) assert not (p2 < p1) def test_type_identifier_includes_module(self): # Two distinct types may share the same __qualname__. # Ordering must remain deterministic and distinguish them. class Same: __module__ = "module_a" def __str__(self) -> str: return "x" SameA = Same class Same: __module__ = "module_b" def __str__(self) -> str: return "x" SameB = Same root = AccessorPath(MockAccessor()) p1 = root._make_child_relpath(SameA()) p2 = root._make_child_relpath(SameB()) assert p1 != p2 assert (p1 < p2) ^ (p2 < p1) class TestBasePathLe: @pytest.mark.parametrize( "part1,part2,expected", ( ["", "", True], ["", "part", True], ["part", "", False], ["part", "part", True], ["part", "part2", True], ["part", BasePath("part"), True], ["part", BasePath("part2"), True], [BasePath("part"), "part", True], [BasePath("part"), BasePath("part"), True], [BasePath("part"), BasePath("part2"), True], ), ) def test_parts(self, part1, part2, expected): result = BasePath(part1) <= BasePath(part2) assert result is expected def test_type_not_implemented(self): with pytest.raises(TypeError): BasePath() <= [] class TestBasePathGt: @pytest.mark.parametrize( "part1,part2,expected", ( ["", "", False], ["", "part", False], ["part", "", True], ["part", "part", False], ["part", "part2", False], ["part", BasePath("part"), False], ["part", BasePath("part2"), False], [BasePath("part"), "part", False], [BasePath("part"), BasePath("part"), False], [BasePath("part"), BasePath("part2"), False], ), ) def test_parts(self, part1, part2, expected): result = BasePath(part1) > BasePath(part2) assert result is expected def test_type_not_implemented(self): with pytest.raises(TypeError): BasePath() > [] class TestBasePathGe: @pytest.mark.parametrize( "part1,part2,expected", ( ["", "", True], ["", "part", False], ["part", "", True], ["part", "part", True], ["part", "part2", False], ["part", BasePath("part"), True], ["part", BasePath("part2"), False], [BasePath("part"), "part", True], [BasePath("part"), BasePath("part"), True], [BasePath("part"), BasePath("part2"), False], ), ) def test_parts(self, part1, part2, expected): result = BasePath(part1) >= BasePath(part2) assert result is expected def test_type_not_implemented(self): with pytest.raises(TypeError): BasePath() >= [] class TestAccessorPathLen: def test_empty(self): accessor = MockAccessor() p = AccessorPath(accessor) result = len(p) assert result == 0 def test_value(self): accessor = MockAccessor("test1", "test2") p = AccessorPath(accessor) result = len(p) assert result == 2 class TestAccessorPathKeys: def test_empty(self): accessor = MockAccessor() p = AccessorPath(accessor) result = p.keys() assert list(result) == [] def test_value(self): accessor = MockAccessor("test1", "test2") p = AccessorPath(accessor) result = p.keys() assert list(result) == ["test1", "test2"] class TestAccessorPathContains: class KeysKeyErrorAccessor(MockAccessor): def keys(self, parts: Sequence[Hashable]) -> Any: raise KeyError def test_valid(self): accessor = MockAccessor("test1", "test2") p = AccessorPath(accessor) result = "test1" in p assert result is True def test_invalid(self): accessor = MockAccessor("test1", "test2") p = AccessorPath(accessor) result = "test3" in p assert result is False def test_missing_path_does_not_raise(self): p = AccessorPath(self.KeysKeyErrorAccessor()) assert ("anything" in p) is False def test_fast_path_valid(self): """Test fast path (using _get_subnode) for valid key.""" node = {"test1": {"nested": "value"}, "test2": "value2"} accessor = MockTraversableAccessor(node) p = AccessorPath(accessor) result = "test1" in p assert result is True def test_fast_path_invalid(self): """Test fast path (using _get_subnode) for invalid key.""" node = {"test1": {"nested": "value"}, "test2": "value2"} accessor = MockTraversableAccessor(node) p = AccessorPath(accessor) result = "test3" in p assert result is False def test_fast_path_nested(self): """Test fast path (using _get_subnode) for nested paths.""" node = {"test1": {"nested": "value"}, "test2": "value2"} accessor = MockTraversableAccessor(node) p = AccessorPath(accessor) / "test1" result = "nested" in p assert result is True def test_fast_path_missing_parent(self): """Test fast path when parent path is missing.""" node = {"test1": {"nested": "value"}} accessor = MockTraversableAccessor(node) p = AccessorPath(accessor) / "missing" result = "anything" in p assert result is False class TestAccessorPathRequireChild: """Tests for require_child fast path using floordiv operator.""" def test_fast_path_valid_child(self): """Test fast path (using _get_subnode) for valid child.""" node = {"test1": {"nested": "value"}, "test2": "value2"} accessor = MockTraversableAccessor(node) p = AccessorPath(accessor) # Should not raise result = p // "test1" assert result == p / "test1" def test_fast_path_invalid_child(self): """Test fast path (using _get_subnode) for invalid child.""" node = {"test1": {"nested": "value"}, "test2": "value2"} accessor = MockTraversableAccessor(node) p = AccessorPath(accessor) with pytest.raises(KeyError) as excinfo: p // "test3" assert excinfo.value.args == ("test3",) def test_fast_path_nested_valid(self): """Test fast path (using _get_subnode) for nested paths.""" node = {"test1": {"nested": "value"}, "test2": "value2"} accessor = MockTraversableAccessor(node) p = AccessorPath(accessor) / "test1" # Should not raise result = p // "nested" assert result == p / "nested" def test_fast_path_missing_parent(self): """Test fast path when parent path is missing.""" node = {"test1": {"nested": "value"}} accessor = MockTraversableAccessor(node) p = AccessorPath(accessor) / "missing" with pytest.raises(KeyError) as excinfo: p // "anything" # Should raise KeyError for the missing parent segment assert excinfo.value.args == ("missing",) class TestAccessorPathItems: def test_empty(self): accessor = MockAccessor() p = AccessorPath(accessor) result = p.items() assert type(result) is GeneratorType assert dict(result) == {} def test_keys(self): accessor = MockAccessor("test1", "test2") p = AccessorPath(accessor) result = p.items() assert type(result) is GeneratorType assert dict(result) == { "test1": p / "test1", "test2": p / "test2", } class TestAccessorPathIter: def test_empty(self): accessor = MockAccessor() p = AccessorPath(accessor) result = iter(p) assert type(result) is GeneratorType assert list(result) == [] def test_value(self): accessor = MockAccessor("test1", "test2") p = AccessorPath(accessor) result = iter(p) assert type(result) is GeneratorType result_list = list(result) assert result_list == [ p / "test1", p / "test2", ] class TestLookupPathIter: def test_object(self): resource = {"test1": {"test2": {"test3": "test"}}} p = LookupPath._from_lookup(resource, "test1/test2") result = iter(p) assert type(result) == GeneratorType result_list = list(result) assert result_list == [ LookupPath._from_lookup(resource, "test1/test2/test3"), ] def test_list(self): resource = {"test1": {"test2": ["test3", "test4"]}} p = LookupPath._from_lookup(resource, "test1/test2") result = iter(p) assert type(result) == GeneratorType result_list = list(result) assert result_list == [ LookupPath._from_lookup(resource, "test1/test2", 0), LookupPath._from_lookup(resource, "test1/test2", 1), ] def test_leaf_raises_keyerror(self): resource = {"v": "str"} p = LookupPath._from_lookup(resource, "v") with pytest.raises(KeyError) as excinfo: list(iter(p)) assert excinfo.value.args == ("v",) class TestLookupPathGetItem: def test_valid(self): value = "testvalue" resource = {"test1": {"test2": {"test3": value}}} p = LookupPath._from_lookup(resource, "test1/test2") result = p["test3"] assert result == value def test_invalid(self): value = "testvalue" resource = {"test1": {"test2": {"test3": value}}} p = LookupPath._from_lookup(resource, "test1/test2") with pytest.raises(KeyError): p["test4"] class TestAccessorPathGetItemPerformance: def test_single_traversal_for_leaf_value(self): accessor = CountingTraversableAccessor({"a": {"b": "value"}}) p = AccessorPath(accessor, "a") result = p["b"] assert result == "value" assert accessor.get_node_calls == 1 class TestLookupPathReadValue: @pytest.mark.parametrize( "resource,args,expected", [ ( {"test1": {"test2": {"test3": "testvalue"}}}, ("test1/test2/test3",), "testvalue", ), ( {"test1": [{}, {"test3": "testvalue"}]}, ("test1", 1, "test3"), "testvalue", ), ], ) def test_valid(self, resource, args, expected): p = LookupPath._from_lookup(resource, *args) result = p.read_value() assert result == expected @pytest.mark.parametrize( "resource,args", [ ( {"test1": {"test2": {"test3": "testvalue"}}}, ("test1/test2/test4",), ), ({"test1": [{}, {"test3": "testvalue"}]}, ("test1", 0, "test3")), ], ) def test_invalid(self, resource, args): p = LookupPath._from_lookup(resource, *args) with pytest.raises(KeyError): p.read_value() class TestLookupPathGet: def test_non_existing_key_default_none(self): value = "testvalue" resource = {"test1": {"test2": {"test3": value}}} p = LookupPath._from_lookup(resource, "test1/test2") result = p.get("") assert result == None def test_non_existing_key_default_defined(self): value = "testvalue" resource = {"test1": {"test2": {"test3": value}}} p = LookupPath._from_lookup(resource, "test1/test2") result = p.get("", default=value) assert result == value def test_key_exists_returns_leaf_value(self): resource = {"test1": "test2"} p = LookupPath._from_lookup(resource) result = p.get("test1") assert result == "test2" def test_key_exists_returns_subpath(self): # `expected` must share the same `resource` object: under the # new accessor identity model, two LookupAccessors over # value-equal but distinct dicts represent distinct resources. resource = {"test1": {"test2": "test3"}} p = LookupPath._from_lookup(resource) expected = LookupPath._from_lookup(resource, "test1") result = p.get("test1") assert result == expected class TestLookupPathExists: def test_non_existing_key(self): value = "testvalue" resource = {"test1": {"test2": {"test3": value}}} p = LookupPath._from_lookup(resource, "test1/test2/non_existing_key") result = p.exists() assert result is False @pytest.mark.parametrize( "resource,key", ( [ {"test1": "test2"}, "test1", ], [ {"test1": 123}, "test1", ], [ {"test1": True}, "test1", ], [ {"test1": {"test2": "test3"}}, "test1", ], ), ) def test_key_exists(self, resource, key): p = LookupPath._from_lookup(resource, key) result = p.exists() assert result is True class TestLookupPathFloorDiv: def test_non_existing_key(self): value = "testvalue" resource = {"test1": {"test2": {"test3": value}}} p = LookupPath._from_lookup(resource, "test1/test2") with pytest.raises(KeyError) as excinfo: p // "non_existing_key" assert excinfo.value.args == ("non_existing_key",) def test_missing_intermediate_raises_missing_segment(self): p = LookupPath._from_lookup({"a": "b"}, "a", "missing") with pytest.raises(KeyError) as excinfo: p // "x" assert excinfo.value.args == ("missing",) @pytest.mark.parametrize( "resource", [ {"test1": "test2"}, {"test1": {"test2": "test3"}}, ], ) def test_key_exists(self, resource): # Both `p` and `expected` must wrap the *same* `resource` # object: under the new accessor identity model, two # LookupAccessors over distinct dicts are distinct resources. p = LookupPath._from_lookup(resource) expected = LookupPath._from_lookup(resource, "test1") result = p // "test1" assert result == expected class TestLookupPathRfloorDiv: def test_non_existing_key(self): value = "testvalue" resource = {"test1": {"test2": {"test3": value}}} p = LookupPath._from_lookup(resource, "test1/test2") with pytest.raises(KeyError) as excinfo: "non_existing_key" // p assert excinfo.value.args == ("non_existing_key",) def test_missing_intermediate_raises_missing_segment(self): # The prepended key exists, but a later segment is missing. p = LookupPath._from_lookup({"x": {"a": "b"}}, "a", "missing") with pytest.raises(KeyError) as excinfo: "x" // p assert excinfo.value.args == ("missing",) @pytest.mark.parametrize( "resource", [ {"test1": "test2"}, {"test1": {"test2": "test3"}}, ], ) def test_key_exists(self, resource): # Both `p` and `expected` must wrap the *same* `resource` # object: under the new accessor identity model, two # LookupAccessors over distinct dicts are distinct resources. p = LookupPath._from_lookup(resource) expected = LookupPath._from_lookup(resource, "test1") result = "test1" // p assert result == expected class TestFilesystemPathValidate: def test_validate_missing_first_segment(self, tmp_path: Path): p = FilesystemPath.from_path(tmp_path) with pytest.raises(KeyError) as excinfo: p.accessor.validate(("missing",)) assert excinfo.value.args == ("missing",) def test_validate_missing_intermediate_segment(self, tmp_path: Path): p = FilesystemPath.from_path(tmp_path) with pytest.raises(KeyError) as excinfo: p.accessor.validate(("a", "missing")) assert excinfo.value.args == ("a",) class TestFilesystemPathKeysAndLenDiagnostics: def test_keys_missing_intermediate_reports_first_missing( self, tmp_path: Path ): p = FilesystemPath.from_path(tmp_path) with pytest.raises(KeyError) as excinfo: p.accessor.keys(("a", "b")) assert excinfo.value.args == ("a",) def test_len_missing_intermediate_reports_first_missing( self, tmp_path: Path ): p = FilesystemPath.from_path(tmp_path) with pytest.raises(KeyError) as excinfo: p.accessor.len(("a", "b")) assert excinfo.value.args == ("a",) class TestFilesystemPathFloorDivDiagnostics: def test_floordiv_missing_intermediate_reports_first_missing( self, tmp_path: Path ): p = FilesystemPath.from_path(tmp_path) / "a" / "b" with pytest.raises(KeyError) as excinfo: p // "x" assert excinfo.value.args == ("a",) class TestLookupPathLen: def test_empty(self): resource = {} p = LookupPath._from_lookup(resource) result = len(p) assert result == 0 def test_value(self): resource = {"test1": "test2"} p = LookupPath._from_lookup(resource, "test1") with pytest.raises(KeyError) as excinfo: len(p) assert excinfo.value.args == ("test1",) def test_single(self): resource = {"test1": "test2"} p = LookupPath._from_lookup(resource) result = len(p) assert result == 1 def test_list(self): resource = {"test1": [{"test2": "test3"}, {"test4": "test5"}]} p = LookupPath._from_lookup(resource, "test1") result = len(p) assert result == 2 def test_non_existing(self): resource = {"test1": "test2"} p = LookupPath._from_lookup(resource, "invalid_key") with pytest.raises(KeyError): len(p) class TestLookupPathKeys: def test_empty(self): resource = {} p = LookupPath._from_lookup(resource) result = p.keys() assert list(result) == [] def test_value(self): resource = {"test1": "test2"} p = LookupPath._from_lookup(resource, "test1") with pytest.raises(KeyError) as excinfo: p.keys() assert excinfo.value.args == ("test1",) def test_string(self): resource = "test1" p = LookupPath._from_lookup(resource) with pytest.raises(KeyError) as excinfo: p.keys() assert excinfo.value.args == () def test_dict(self): resource = {"test1": "test2"} p = LookupPath._from_lookup(resource) result = p.keys() assert list(result) == ["test1"] def test_list(self): resource = {"test1": [{"test2": "test3"}, {"test4": "test5"}]} p = LookupPath._from_lookup(resource, "test1") result = p.keys() assert list(result) == [0, 1] def test_non_existing(self): resource = {"test1": "test2"} p = LookupPath._from_lookup(resource, "invalid_key") with pytest.raises(KeyError): p.keys() class TestLookupTraversable: def test_lookup_accessor_is_traversable(self): a = LookupAccessor({"m": {"x": 1}, "l": [1, 2], "v": "str"}) assert a.is_traversable(()) is True assert a.is_traversable(("m",)) is True assert a.is_traversable(("l",)) is True assert a.is_traversable(("v",)) is False assert a.is_traversable(("missing",)) is False assert a.is_traversable(("m", "missing")) is False def test_lookuppath_is_traversable(self): root = LookupPath.from_lookup({"m": {"x": 1}, "v": "str"}) assert root.is_traversable() is True assert (root / "m").is_traversable() is True assert (root / "v").is_traversable() is False assert (root / "missing").is_traversable() is False class TestLookupPathContains: def test_valid(self): value = "testvalue" resource = {"test1": {"test2": {"test3": value}}} p = LookupPath._from_lookup(resource, "test1/test2") result = "test3" in p assert result is True def test_invalid(self): value = "testvalue" resource = {"test1": {"test2": {"test3": value}}} p = LookupPath._from_lookup(resource, "test1/test2") result = "test4" in p assert result is False def test_missing_intermediate_does_not_raise(self): resource = {"test1": {"test2": {"test3": "value"}}} p = LookupPath._from_lookup(resource, "test1", "missing") assert ("any" in p) is False def test_out_of_bounds_list_index_does_not_raise(self): resource = {"test1": [1, 2]} p = LookupPath._from_lookup(resource, "test1", 5) assert ("any" in p) is False class TestLookupPathItems: def test_empty(self): resource = {} p = LookupPath._from_lookup(resource) result = p.items() assert type(result) is GeneratorType assert dict(result) == {} def test_keys(self): resource = { "test1": 1, "test2": 2, } p = LookupPath._from_lookup(resource) result = p.items() assert type(result) is GeneratorType assert dict(result) == { "test1": p / "test1", "test2": p / "test2", } class TestLookupPathOpen: def test_content_cached(self): value = { "test3": "test4", } resource = MockResource( test1=1, test2=value, ) p = LookupPath._from_lookup(resource, "test2") result = p.read_value() assert resource.getitem_counter == 1 assert result == p.read_value() assert resource.getitem_counter == 1 class TestLookupPathFromLookup: def test_from_lookup_matches_private_constructor(self): resource = {"test1": {"test2": "test3"}} p1 = LookupPath._from_lookup(resource, "test1") p2 = LookupPath.from_lookup(resource, "test1") assert p1 == p2 assert p1.read_value() == p2.read_value() class TestAccessorPathOpenAndStat: def test_open_yields_read_value(self): resource = {"test1": {"test2": "value"}} p = LookupPath.from_lookup(resource, "test1") with p.open() as value: assert value == {"test2": "value"} def test_stat_returns_none_for_missing(self): resource = {"test1": {"test2": "value"}} p = LookupPath.from_lookup(resource, "test1", "missing") assert p.stat() is None class TestPathlibLikeManipulation: def test_name_parent_parents(self): p = BasePath("a", "b", "c") assert p.name == "c" assert str(p.parent) == "a/b" assert [str(x) for x in p.parents] == ["a/b", "a", ""] def test_joinpath(self): p = BasePath("a") assert str(p.joinpath("b", "c")) == "a/b/c" def test_suffix_stem_suffixes(self): p = BasePath("archive.tar.gz") assert p.suffix == ".gz" assert p.suffixes == [".tar", ".gz"] assert p.stem == "archive.tar" dotfile = BasePath(".bashrc") assert dotfile.suffix == "" assert dotfile.suffixes == [] assert dotfile.stem == ".bashrc" def test_with_name_and_with_suffix(self): p = BasePath("a", "file.txt") assert str(p.with_name("other.md")) == "a/other.md" assert str(p.with_suffix(".csv")) == "a/file.csv" def test_with_name_respects_separator(self): p = BasePath("a", "b", separator=".") with pytest.raises(ValueError): p.with_name("c.d") def test_relative_to_and_is_relative_to(self): p = BasePath("a", "b", "c") assert p.is_relative_to("a") assert p.is_relative_to("a", "b") assert not p.is_relative_to("x") assert str(p.relative_to("a")) == "b/c" assert str(p.relative_to("a", "b")) == "c" with pytest.raises(ValueError): p.relative_to("x") def test_relative_to_and_is_relative_to_custom_separator(self): p = BasePath("a.b.c", separator=".") assert p.is_relative_to("a") assert p.is_relative_to("a.b") assert not p.is_relative_to("a/b") assert str(p.relative_to("a")) == "b.c" assert str(p.relative_to("a.b")) == "c" with pytest.raises(ValueError): p.relative_to("x") def test_as_posix_and_fspath(self): p = BasePath("a", "b") assert p.as_posix() == "a/b" assert p.__fspath__() == "a/b" class TestNodeAccessorIdentity: """Locks in the per-accessor identity policy.""" def test_node_accessor_is_hashable(self): # The base NodeAccessor must be hashable so it can participate # in path identity tuples. a = MockAccessor() assert hash(a) == hash(a) {a} # construct a set; would raise if unhashable def test_node_accessor_default_is_object_identity(self): # The base NodeAccessor cannot know what makes a wrapped resource # "the same"; default identity is the object itself. a1 = MockAccessor() a2 = MockAccessor() assert a1 == a1 assert a1 != a2 assert hash(a1) == object.__hash__(a1) def test_lookup_accessor_same_node_compares_equal(self): # LookupAccessor identity = is-on-node: two LookupAccessors over # the *same* dict object are interchangeable. d = {"x": 1} a1 = LookupAccessor(d) a2 = LookupAccessor(d) assert a1 == a2 assert hash(a1) == hash(a2) def test_lookup_accessor_distinct_nodes_compare_unequal(self): # Two LookupAccessors over value-equal but distinct dicts # represent distinct resources (the user constructed each one; # nothing ties them together). a1 = LookupAccessor({"x": 1}) a2 = LookupAccessor({"x": 1}) assert a1 != a2 def test_lookup_accessor_different_class_not_equal(self): class OtherLookup(LookupAccessor): pass d = {"x": 1} assert LookupAccessor(d) != OtherLookup(d) def test_path_accessor_value_equal_on_path(self): # PathAccessor identity = value-equality on the wrapped Path # (Path is hashable and value-equal on its canonical string), # so two PathAccessors built from separately-constructed Paths # pointing at the same location compare equal. a1 = PathAccessor(Path("/tmp/x")) a2 = PathAccessor(Path("/tmp/x")) assert a1 == a2 assert hash(a1) == hash(a2) def test_path_accessor_different_path_not_equal(self): assert PathAccessor(Path("/tmp/x")) != PathAccessor(Path("/tmp/y")) def test_path_accessor_different_class_not_equal(self): class OtherPathAccessor(PathAccessor): pass p = Path("/tmp/x") assert PathAccessor(p) != OtherPathAccessor(p) class TestPathIdentityCrossClass: """BasePath and AccessorPath live in distinct equivalence classes.""" def test_basepath_not_equal_to_accessorpath(self): accessor = LookupAccessor({"a": "b"}) assert BasePath("a") != LookupPath(accessor, "a") def test_basepath_not_equal_to_accessorpath_reflected(self): # Reflected dispatch must give the same answer. accessor = LookupAccessor({"a": "b"}) assert LookupPath(accessor, "a") != BasePath("a") def test_subclass_compares_equal_to_base_when_address_and_binding_match( self, ): # Subclasses of AccessorPath that don't change identity semantics # compare equal to the base (LSP). Class is not part of identity. class MyLookupPath(LookupPath): pass accessor = LookupAccessor({"a": "b"}) assert LookupPath(accessor, "a") == MyLookupPath(accessor, "a") assert hash(LookupPath(accessor, "a")) == hash( MyLookupPath(accessor, "a") ) def test_distinct_accessor_subclasses_not_equal(self): # Different accessor backings = different resources. # PathAccessor and LookupAccessor are never `==`. lp = LookupPath.from_lookup({}) fp = FilesystemPath(PathAccessor(Path("/tmp"))) assert lp != fp def test_accessorpath_ordering_is_address_based_across_bindings(self): # Ordering is useful for presentation and stable sorting by # address, but it is not a semantic identity check. Distinct # bindings with the same parts are ordering-equivalent while # remaining unequal. p1 = LookupPath.from_lookup({"a": 1}, "a") p2 = LookupPath.from_lookup({"a": 1}, "a") assert p1 != p2 assert not (p1 < p2) assert p1 <= p2 assert not (p1 > p2) assert p1 >= p2 class TestAccessorPathIsSameBinding: def test_same_accessor_instance_is_same_binding(self): accessor = LookupAccessor({"a": {"b": 1}}) p1 = LookupPath(accessor, "a") p2 = LookupPath(accessor, "a") assert p1.is_same_binding(p2) def test_equal_accessors_distinct_instances_not_same_binding(self): # Two LookupAccessors over the same dict are `==` (is-on-node), # so the paths compare `==`, but they're not the same accessor # *instance* β€” is_same_binding draws the stricter line. d = {"a": {"b": 1}} p1 = LookupPath(LookupAccessor(d), "a") p2 = LookupPath(LookupAccessor(d), "a") assert p1 == p2 assert not p1.is_same_binding(p2) def test_is_same_binding_requires_accessorpath(self): accessor = LookupAccessor({"a": "b"}) assert not LookupPath(accessor, "a").is_same_binding(BasePath("a")) class TestPathHashEqInvariant: @pytest.mark.parametrize( "a, b", [ (BasePath("a", "b"), BasePath("a", "b")), (BasePath("a", separator="/"), BasePath("a", separator=".")), ], ) def test_basepath_eq_implies_hash_eq(self, a, b): assert a == b assert hash(a) == hash(b) def test_accessorpath_eq_implies_hash_eq(self): accessor = LookupAccessor({"x": 1}) a = LookupPath(accessor, "x") b = LookupPath(accessor, "x") assert a == b assert hash(a) == hash(b) def test_accessorpath_eq_implies_hash_eq_across_accessor_instances( self, ): # Two LookupAccessor instances over the same dict are == ; the # paths over them must therefore be == and share a hash. d = {"x": 1} a = LookupPath(LookupAccessor(d), "x") b = LookupPath(LookupAccessor(d), "x") assert a == b assert hash(a) == hash(b) class TestAccessorPathPathlibCompat: def test_parent_preserves_accessor(self): resource = {"a": {"b": {"c": "value"}}} p = LookupPath.from_lookup(resource, "a", "b", "c") assert p.parent.read_value() == {"c": "value"} def test_relative_to_preserves_accessor(self): resource = {"a": {"b": {"c": "value"}}} p = LookupPath.from_lookup(resource, "a", "b", "c") rel = p.relative_to("a") assert str(rel) == "b/c" # Like pathlib, a relative path is not automatically anchored to the # stripped prefix; consumers must re-anchor explicitly if needed. def test_with_name_preserves_accessor(self): resource = {"a": {"x": "value"}} p = LookupPath.from_lookup(resource, "a", "x") renamed = p.with_name("x") assert renamed.read_value() == "value" p1c2u-pathable-263aef1/tests/unit/test_traversable.py000066400000000000000000000012151520312417300226740ustar00rootroot00000000000000from typing import Any from typing import Sequence from pathable.accessors import NodeAccessor class KeysOnlyAccessor(NodeAccessor[dict[str, Any], str, Any]): def stat(self, parts: Sequence[str]) -> dict[str, Any] | None: return None def keys(self, parts: Sequence[str]) -> Sequence[str]: if parts: raise KeyError(parts[-1]) return ["a", "b"] def len(self, parts: Sequence[str]) -> int: return len(self.keys(parts)) def test_is_traversable_falls_back_to_keys() -> None: a = KeysOnlyAccessor({}) assert a.is_traversable(()) is True assert a.is_traversable(("missing",)) is False