diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a046d8e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +# Apply unified ruleset for these file endings +[*.{json,yml,yaml,jsonc,jsonschema}] +indent_style = space +indent_size = 2 + +# Tab indentation +[Makefile] +indent_style = tab diff --git a/.github/workflows/quality.yaml b/.github/workflows/quality.yaml new file mode 100644 index 0000000..0a411e9 --- /dev/null +++ b/.github/workflows/quality.yaml @@ -0,0 +1,32 @@ +name: Lint and Test + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + ci: + strategy: + fail-fast: false + matrix: + python-version: [ "3.11", "3.12", "3.13"] + poetry-version: ["latest"] + os: [ubuntu-22.04, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Run image + uses: abatilo/actions-poetry@v3 + with: + poetry-version: ${{ matrix.poetry-version }} + - name: install dependencies + run: poetry install + - name: lint + run: make lint + - name: test + run: make test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..afe88c5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,55 @@ +name: Tag and Release + +on: + push: + branches: + - main + +jobs: + tag-and-release: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Required for full git history + + - name: Set up Git user + run: | + git config user.name "GitHub Actions" + git config user.email "actions@github.com" + + - name: Install git-cliff + run: | + curl -sSL https://github.com/orhun/git-cliff/releases/latest/download/git-cliff-linux-x86_64.tar.gz | tar -xz + sudo mv git-cliff /usr/local/bin/ + + - name: Get latest tag + id: get_tag + run: | + latest_tag=$(git describe --tags --abbrev=0) + echo "latest_tag=$latest_tag" >> $GITHUB_OUTPUT + + - name: Bump version and create new tag + id: new_tag + run: | + # Example: bump patch version (customize as needed) + IFS='.' read -r major minor patch <<< "${{ steps.get_tag.outputs.latest_tag }}" + new_tag="v$major.$minor.$((patch + 1))" + git tag "$new_tag" + git push origin "$new_tag" + echo "new_tag=$new_tag" >> $GITHUB_OUTPUT + + - name: Generate changelog + run: | + git-cliff -c cliff.toml -t ${{ steps.new_tag.outputs.new_tag }} -o CHANGELOG.md + + - name: Create GitHub Release + uses: softprops/action-gh-release@v1 + with: + tag_name: ${{ steps.new_tag.outputs.new_tag }} + name: Release ${{ steps.new_tag.outputs.new_tag }} + body_path: CHANGELOG.md + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..ac09a2b --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,63 @@ +repos: + - repo: local + # Pre-built hooks for all these "local" checks are available as well. We rather + # define our own to ensure their version matches the one in pyproject.toml/poetry.lock + hooks: + # python code linters + - id: ruff-format + name: ruff-format + language: system + entry: poetry run ruff format + types: [ python ] + - id: ruff-check + name: ruff-check + language: system + entry: poetry run ruff check + types: [ python ] + args: [ --exit-non-zero-on-fix ] + files: . + - id: mypy + name: mypy + language: system + entry: poetry run mypy + types: [ python ] + # poetry checks + - id: poetry-check + name: poetry-check + language: system + entry: poetry check + files: ^pyproject.toml$ + pass_filenames: false + - id: poetry-check-lock + name: poetry-check-lock + entry: poetry check + language: system + pass_filenames: false + args: [ "--lock" ] + files: | + (?x)^( + pyproject.toml| + poetry.lock + )$ + + # common checks that aren't supported by the linters we use in this service + # i.e. in a project that uses prettier, you don't need the json checks + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-added-large-files + - id: check-json + - id: check-toml + - id: check-xml + + - repo: https://github.com/antonbabenko/pre-commit-terraform + rev: v1.90.0 + hooks: + - id: terraform_fmt + - id: terraform_validate # this step might take a minute the first time it runs + + - repo: https://github.com/compilerla/conventional-pre-commit + rev: v3.2.0 + hooks: + - id: conventional-pre-commit + stages: [ commit-msg ] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3480efc --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ +# ------------------------------------------------------ +# HELP +# ------------------------------------------------------ +.PHONY: help +help: + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + +# ------------------------------------------------------ +# LOCAL COMMANDS +# ------------------------------------------------------ +.PHONY: test_unit +test: ## Run unit tests + @poetry run pytest ./tests + + +# this linter just lints src/services/notification/ and tests folders. +# it should gradually covers all other parts of the code as soon all errors have been fixed +.PHONY: lint +lint: ## Run linters with auto-fix. + @poetry run pre-commit run --all-files diff --git a/README.md b/README.md index b91ef9b..32ed2b9 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,51 @@ # diff -A library to calculate structured data + +Diff is a library to calculate deltas between structured data. + +## Features + +- Calculate detlas +- Rebuild via deltas + +## Supported Formats + +- [x] JSON +- [ ] YAML +- [ ] XML +- [ ] TOML + +## Usage + +### Diff + +```python +import diff + +old = {"name": "David"} +new = {"name": "Alex"} +deltas = diff.diff(new=new, old=old) + +for delta in deltas: + print(delta) + +``` + +Output + +```text +Operation(op='modified', path='$.name', new_value='Alex', old_value='David') +``` + +### Rebuild + +```python +import diff + +old = {"name": "David"} +new = {"name": "Alex"} +deltas = diff.diff(new=new, old=old) + +rebuild_new = diff.patch(base=old, deltas=deltas) + +assert rebuild_new == old +``` diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..9794f27 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,25 @@ +# docs: https://mypy.readthedocs.io/en/stable/config_file.html +[mypy] +python_version = 3.11 +allow_redefinition = False +check_untyped_defs = True +disallow_untyped_decorators = True +disallow_any_explicit = False +disallow_any_generics = False +disallow_untyped_calls = True +ignore_errors = False +ignore_missing_imports = True +implicit_reexport = False +strict_optional = True +strict_equality = True +show_traceback = True +no_implicit_optional = True +warn_unused_ignores = True +warn_redundant_casts = True +warn_unused_configs = True +warn_unreachable = True +warn_no_return = True +; plugins = +exclude = + build + .venv diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..83def34 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,630 @@ +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. + +[[package]] +name = "black" +version = "25.9.0" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "black-25.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ce41ed2614b706fd55fd0b4a6909d06b5bab344ffbfadc6ef34ae50adba3d4f7"}, + {file = "black-25.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2ab0ce111ef026790e9b13bd216fa7bc48edd934ffc4cbf78808b235793cbc92"}, + {file = "black-25.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f96b6726d690c96c60ba682955199f8c39abc1ae0c3a494a9c62c0184049a713"}, + {file = "black-25.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:d119957b37cc641596063cd7db2656c5be3752ac17877017b2ffcdb9dfc4d2b1"}, + {file = "black-25.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:456386fe87bad41b806d53c062e2974615825c7a52159cde7ccaeb0695fa28fa"}, + {file = "black-25.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a16b14a44c1af60a210d8da28e108e13e75a284bf21a9afa6b4571f96ab8bb9d"}, + {file = "black-25.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaf319612536d502fdd0e88ce52d8f1352b2c0a955cc2798f79eeca9d3af0608"}, + {file = "black-25.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:c0372a93e16b3954208417bfe448e09b0de5cc721d521866cd9e0acac3c04a1f"}, + {file = "black-25.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1b9dc70c21ef8b43248f1d86aedd2aaf75ae110b958a7909ad8463c4aa0880b0"}, + {file = "black-25.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8e46eecf65a095fa62e53245ae2795c90bdecabd53b50c448d0a8bcd0d2e74c4"}, + {file = "black-25.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9101ee58ddc2442199a25cb648d46ba22cd580b00ca4b44234a324e3ec7a0f7e"}, + {file = "black-25.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:77e7060a00c5ec4b3367c55f39cf9b06e68965a4f2e61cecacd6d0d9b7ec945a"}, + {file = "black-25.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0172a012f725b792c358d57fe7b6b6e8e67375dd157f64fa7a3097b3ed3e2175"}, + {file = "black-25.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3bec74ee60f8dfef564b573a96b8930f7b6a538e846123d5ad77ba14a8d7a64f"}, + {file = "black-25.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b756fc75871cb1bcac5499552d771822fd9db5a2bb8db2a7247936ca48f39831"}, + {file = "black-25.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:846d58e3ce7879ec1ffe816bb9df6d006cd9590515ed5d17db14e17666b2b357"}, + {file = "black-25.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ef69351df3c84485a8beb6f7b8f9721e2009e20ef80a8d619e2d1788b7816d47"}, + {file = "black-25.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e3c1f4cd5e93842774d9ee4ef6cd8d17790e65f44f7cdbaab5f2cf8ccf22a823"}, + {file = "black-25.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:154b06d618233fe468236ba1f0e40823d4eb08b26f5e9261526fde34916b9140"}, + {file = "black-25.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:e593466de7b998374ea2585a471ba90553283fb9beefcfa430d84a2651ed5933"}, + {file = "black-25.9.0-py3-none-any.whl", hash = "sha256:474b34c1342cdc157d307b56c4c65bce916480c4a8f6551fdc6bf9b486a7c4ae"}, + {file = "black-25.9.0.tar.gz", hash = "sha256:0474bca9a0dd1b51791fcc507a4e02078a1c63f6d4e4ae5544b9848c7adfb619"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" +pytokens = ">=0.1.10" + +[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.4.0" +description = "Validate configuration and produce human readable error messages." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, + {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, +] + +[[package]] +name = "click" +version = "8.3.0" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc"}, + {file = "click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4"}, +] + +[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"] +markers = "platform_system == \"Windows\" or sys_platform == \"win32\"" +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.extras] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] + +[[package]] +name = "distlib" +version = "0.4.0" +description = "Distribution utilities" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"}, + {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, +] + +[[package]] +name = "filelock" +version = "3.19.1" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d"}, + {file = "filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58"}, +] + +[[package]] +name = "identify" +version = "2.6.15" +description = "File identification library for Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757"}, + {file = "identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf"}, +] + +[package.extras] +license = ["ukkonen"] + +[[package]] +name = "iniconfig" +version = "2.1.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, + {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, +] + +[[package]] +name = "mypy" +version = "1.18.2" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "mypy-1.18.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c1eab0cf6294dafe397c261a75f96dc2c31bffe3b944faa24db5def4e2b0f77c"}, + {file = "mypy-1.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7a780ca61fc239e4865968ebc5240bb3bf610ef59ac398de9a7421b54e4a207e"}, + {file = "mypy-1.18.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448acd386266989ef11662ce3c8011fd2a7b632e0ec7d61a98edd8e27472225b"}, + {file = "mypy-1.18.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9e171c465ad3901dc652643ee4bffa8e9fef4d7d0eece23b428908c77a76a66"}, + {file = "mypy-1.18.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:592ec214750bc00741af1f80cbf96b5013d81486b7bb24cb052382c19e40b428"}, + {file = "mypy-1.18.2-cp310-cp310-win_amd64.whl", hash = "sha256:7fb95f97199ea11769ebe3638c29b550b5221e997c63b14ef93d2e971606ebed"}, + {file = "mypy-1.18.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:807d9315ab9d464125aa9fcf6d84fde6e1dc67da0b6f80e7405506b8ac72bc7f"}, + {file = "mypy-1.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:776bb00de1778caf4db739c6e83919c1d85a448f71979b6a0edd774ea8399341"}, + {file = "mypy-1.18.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1379451880512ffce14505493bd9fe469e0697543717298242574882cf8cdb8d"}, + {file = "mypy-1.18.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1331eb7fd110d60c24999893320967594ff84c38ac6d19e0a76c5fd809a84c86"}, + {file = "mypy-1.18.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ca30b50a51e7ba93b00422e486cbb124f1c56a535e20eff7b2d6ab72b3b2e37"}, + {file = "mypy-1.18.2-cp311-cp311-win_amd64.whl", hash = "sha256:664dc726e67fa54e14536f6e1224bcfce1d9e5ac02426d2326e2bb4e081d1ce8"}, + {file = "mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34"}, + {file = "mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764"}, + {file = "mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893"}, + {file = "mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914"}, + {file = "mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8"}, + {file = "mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074"}, + {file = "mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc"}, + {file = "mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e"}, + {file = "mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986"}, + {file = "mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d"}, + {file = "mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba"}, + {file = "mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544"}, + {file = "mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce"}, + {file = "mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d"}, + {file = "mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c"}, + {file = "mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb"}, + {file = "mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075"}, + {file = "mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf"}, + {file = "mypy-1.18.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:25a9c8fb67b00599f839cf472713f54249a62efd53a54b565eb61956a7e3296b"}, + {file = "mypy-1.18.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c2b9c7e284ee20e7598d6f42e13ca40b4928e6957ed6813d1ab6348aa3f47133"}, + {file = "mypy-1.18.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6985ed057513e344e43a26cc1cd815c7a94602fb6a3130a34798625bc2f07b6"}, + {file = "mypy-1.18.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f27105f1525ec024b5c630c0b9f36d5c1cc4d447d61fe51ff4bd60633f47ac"}, + {file = "mypy-1.18.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:030c52d0ea8144e721e49b1f68391e39553d7451f0c3f8a7565b59e19fcb608b"}, + {file = "mypy-1.18.2-cp39-cp39-win_amd64.whl", hash = "sha256:aa5e07ac1a60a253445797e42b8b2963c9675563a94f11291ab40718b016a7a0"}, + {file = "mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e"}, + {file = "mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b"}, +] + +[package.dependencies] +mypy_extensions = ">=1.0.0" +pathspec = ">=0.9.0" +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.9.1" +description = "Node.js virtual environment builder" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +files = [ + {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, + {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, +] + +[[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.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + +[[package]] +name = "platformdirs" +version = "4.4.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85"}, + {file = "platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] +type = ["mypy (>=1.14.1)"] + +[[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\""} +iniconfig = ">=1" +packaging = ">=20" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" + +[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.1.10" +description = "A Fast, spec compliant Python 3.12+ tokenizer that runs on older Pythons." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytokens-0.1.10-py3-none-any.whl", hash = "sha256:db7b72284e480e69fb085d9f251f66b3d2df8b7166059261258ff35f50fb711b"}, + {file = "pytokens-0.1.10.tar.gz", hash = "sha256:c9a4bfa0be1d26aebce03e6884ba454e842f186a59ea43a6d3b25af58223c044"}, +] + +[package.extras] +dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "twine", "wheel"] + +[[package]] +name = "pyyaml" +version = "6.0.3" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, + {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, + {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, + {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, + {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, + {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, + {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, + {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, + {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, + {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, +] + +[[package]] +name = "ruff" +version = "0.13.3" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ruff-0.13.3-py3-none-linux_armv6l.whl", hash = "sha256:311860a4c5e19189c89d035638f500c1e191d283d0cc2f1600c8c80d6dcd430c"}, + {file = "ruff-0.13.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2bdad6512fb666b40fcadb65e33add2b040fc18a24997d2e47fee7d66f7fcae2"}, + {file = "ruff-0.13.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fc6fa4637284708d6ed4e5e970d52fc3b76a557d7b4e85a53013d9d201d93286"}, + {file = "ruff-0.13.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c9e6469864f94a98f412f20ea143d547e4c652f45e44f369d7b74ee78185838"}, + {file = "ruff-0.13.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5bf62b705f319476c78891e0e97e965b21db468b3c999086de8ffb0d40fd2822"}, + {file = "ruff-0.13.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78cc1abed87ce40cb07ee0667ce99dbc766c9f519eabfd948ed87295d8737c60"}, + {file = "ruff-0.13.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4fb75e7c402d504f7a9a259e0442b96403fa4a7310ffe3588d11d7e170d2b1e3"}, + {file = "ruff-0.13.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:17b951f9d9afb39330b2bdd2dd144ce1c1335881c277837ac1b50bfd99985ed3"}, + {file = "ruff-0.13.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6052f8088728898e0a449f0dde8fafc7ed47e4d878168b211977e3e7e854f662"}, + {file = "ruff-0.13.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc742c50f4ba72ce2a3be362bd359aef7d0d302bf7637a6f942eaa763bd292af"}, + {file = "ruff-0.13.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8e5640349493b378431637019366bbd73c927e515c9c1babfea3e932f5e68e1d"}, + {file = "ruff-0.13.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6b139f638a80eae7073c691a5dd8d581e0ba319540be97c343d60fb12949c8d0"}, + {file = "ruff-0.13.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6b547def0a40054825de7cfa341039ebdfa51f3d4bfa6a0772940ed351d2746c"}, + {file = "ruff-0.13.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9cc48a3564423915c93573f1981d57d101e617839bef38504f85f3677b3a0a3e"}, + {file = "ruff-0.13.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1a993b17ec03719c502881cb2d5f91771e8742f2ca6de740034433a97c561989"}, + {file = "ruff-0.13.3-py3-none-win32.whl", hash = "sha256:f14e0d1fe6460f07814d03c6e32e815bff411505178a1f539a38f6097d3e8ee3"}, + {file = "ruff-0.13.3-py3-none-win_amd64.whl", hash = "sha256:621e2e5812b691d4f244638d693e640f188bacbb9bc793ddd46837cea0503dd2"}, + {file = "ruff-0.13.3-py3-none-win_arm64.whl", hash = "sha256:9e9e9d699841eaf4c2c798fa783df2fabc680b72059a02ca0ed81c460bc58330"}, + {file = "ruff-0.13.3.tar.gz", hash = "sha256:5b0ba0db740eefdfbcce4299f49e9eaefc643d4d007749d77d047c2bab19908e"}, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] + +[[package]] +name = "virtualenv" +version = "20.34.0" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026"}, + {file = "virtualenv-20.34.0.tar.gz", hash = "sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<5" + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] + +[metadata] +lock-version = "2.1" +python-versions = ">= 3.11" +content-hash = "e635485a7a40655f4ff0f875e0da02e961523257bf9ec1450fae29843f33db35" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f4801e8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[project] +name = "diff" +version = "0.1.0" +description = "A library to calculate diff between structured data" +authors = [ + {name = "Amin Jamal",email = "includeamin@gmail.com"} +] +license = {text = "MIT"} +readme = "README.md" +requires-python = ">= 3.11" + +[tool.poetry] + +[tool.poetry.group.dev.dependencies] +mypy = "^1.18.2" +ruff = "^0.13.3" +black = "^25.9.0" +pre-commit = "^4.3.0" +pytest = "^8.4.2" +pytest-cov = "^7.0.0" + +[build-system] +requires = ["poetry-core>=2.0.0,<3.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..47ba96d --- /dev/null +++ b/pytest.ini @@ -0,0 +1,8 @@ +[pytest] +junit_family = xunit1 +addopts = + --strict-markers + --tb=short + --no-cov-on-fail + --cov=src + --cov-report=html diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..9f2bd09 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,87 @@ +# https://docs.astral.sh/ruff/configuration/ +target-version = "py311" +output-format = "full" + +[lint] +# list of all rules: https://docs.astral.sh/ruff/rules/ +select = ["ALL"] +ignore = [ + "ANN", # flake8-annotations: we're fine with what mypy gives us + "ARG", # flake8-unused-arguments: this rule yielded too many false-negatives + "BLE001", # blind-except: catching `Exception` is fine as long as you don't `pass` + "COM812", # missing-trailing-comma: trailing commas are managed by the formatter + "D", # pydocstyle: we don't care too much about docstrings + "DJ", # flake8-django: we don't use Django + "E501", # line-too-long: we're fine with what black gives us + "EM", # flake8-errmsg: raw strings in exceptions are fine + "F722", # forward-annotation-syntax-error: https://github.com/PyCQA/pyflakes/issues/542 + "FBT001", # boolean-type-hint-positional-argument: FBT003 is sufficient + "FBT002", # boolean-default-value-positional-argument: FBT003 is sufficient + "FIX", # flake8-fixmes: we use todos & fixmes for follow-up and long-term issues + "ISC001", # single-line-implicit-string-concatenation: https://github.com/astral-sh/ruff/issues/8272 + "N805", # invalid-first-argument-name-for-method: incompatible with pydantic validations + "PTH123", # pathlib-open: It's ok to use open(...) instead of Pathlib(...).open() + "RET504", # unnecessary-assign: unnecessary assigns are sometimes helpful for debuggers + "RET505", # superfluous-else-return: sometimes the `else` inproves readability + "RET506", # superfluous-else-raise: sometimes the `else` inproves readability + "RET507", # superfluous-else-continue: sometimes the `else` inproves readability + "RET508", # superfluous-else-break: sometimes the `else` inproves readability + "SIM108", # if-else-block-instead-of-if-exp: ternary operator isn't always preferred + "TD", # flake8-todos: we don't want to formalise todos + "TRY002", # raise-vanilla-class: in services, we're fine with raising broad exceptions + "TRY003", # raise-vanilla-args: we're ok with long strings in exceptions + "TRY004", # type-check-without-type-error: too many false-negatives + "TRY300", # try-consider-else: else statements can be harder to read + "A002", # builtin-argument-shadowing: the existing graphql contract uses built-in names like `filter` and `input` extensively + "N802", # invalid-function-name: the existing graphql contract extensively uses camelcase + "N803", # invalid-argument-name: the existing graphql contract extensively uses camelcase + "N815", # mixed-case-variable-in-class-scope: the existing graphql contract extensively uses camelcase + "FAST002", # currently we have dependency to fastapi 0.78.0 and Annotated is introduced in version 0.95.0, + "TID252", # Prefer absolute imports over relative imports from parent modules: we have to follow that because of the current project strucutre +] + +[lint.per-file-ignores] +"tests/*" = [ + "S101", # assert: asserts are allowed in tests + "PLR2004", # magic-value-comparison: magic values are ok in tests + "PLR0913", # too-many-arguments: tests can use as many arguments (i.e. fixtures) as they like +# "SLF001", # private-member-access: private methods can be used in tests +# "UP031", # printf-string-formatting: Use format specifiers instead of percent format: it is cleaner to use % formatting for GQL queries +# "S113", # it is fine to use requests without timeout in tests +] + +"src/diff/patch.py" = [ + "PLR0915", + "PLR0912", + "PLR0911", + "C901", + "PLR0915", +] + +"src/diff/json_path.py" = [ + "PLR0912", + "C901", + "PLR0915" +] + +[lint.pylint] +max-args = 7 + +[lint.isort] +# https://beta.ruff.rs/docs/settings/#isort +known-first-party = [ + "shared", + "notification", +] +known-local-folder = [ + "src", + "tests", +] + +[lint.flake8-bugbear] +extend-immutable-calls = [ + "fastapi.Depends", + "fastapi.params.Depends", + "fastapi.Query", + "fastapi.params.Query", +] diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/diff/__init__.py b/src/diff/__init__.py new file mode 100644 index 0000000..7f8a774 --- /dev/null +++ b/src/diff/__init__.py @@ -0,0 +1,5 @@ +from diff.delta import Delta +from diff.diff import diff +from diff.patch import patch + +__all__ = ["Delta", "diff", "patch"] diff --git a/src/diff/delta.py b/src/diff/delta.py new file mode 100644 index 0000000..e2402bd --- /dev/null +++ b/src/diff/delta.py @@ -0,0 +1,10 @@ +import dataclasses +import typing + + +@dataclasses.dataclass +class Delta: + op: typing.Literal["deleted", "modified", "added"] + path: str + new_value: typing.Any | None + old_value: typing.Any | None diff --git a/src/diff/diff.py b/src/diff/diff.py new file mode 100644 index 0000000..7fda207 --- /dev/null +++ b/src/diff/diff.py @@ -0,0 +1,41 @@ +from diff import json_path +from diff.delta import Delta + + +def diff(new: dict, old: dict) -> list[Delta]: + new_path_map = json_path.path_value_map( + new, include_root=True, leaves_only=True, include_containers=False + ) + old_path_map = json_path.path_value_map( + old, include_root=True, leaves_only=False, include_containers=False + ) + ops: list[Delta] = [] + + # deleted + deleted = old_path_map.keys() - new_path_map.keys() + for item in deleted: + ops.append( # noqa: PERF401 + Delta(path=item, op="deleted", old_value=old_path_map[item], new_value=None) + ) + + # added + added = new_path_map.keys() - old_path_map.keys() + for item in added: + ops.append( # noqa: PERF401 + Delta(path=item, op="added", old_value=None, new_value=new_path_map[item]) + ) + + # modified + shared_keys = new_path_map.keys() & old_path_map.keys() + for item in shared_keys: + if old_path_map[item] != new_path_map[item]: + ops.append( # noqa: PERF401 + Delta( + path=item, + op="modified", + old_value=old_path_map[item], + new_value=new_path_map[item], + ) + ) + # exlude root diff + return ops diff --git a/src/diff/json_path.py b/src/diff/json_path.py new file mode 100644 index 0000000..f593b24 --- /dev/null +++ b/src/diff/json_path.py @@ -0,0 +1,228 @@ +from collections.abc import Iterator +from typing import Any + +Token = str | int + + +def _escape_key_for_brackets(key: str) -> str: + """Escape a key for bracket notation with double quotes.""" + return key.replace("\\", "\\\\").replace('"', '\\"') + + +def _join_path(base: str, token: Token) -> str: + """ + Join a base path string with a token (dict key or list index) using a JSONPath-like syntax: + - dict keys with no '.' or '[' use dot notation + - otherwise keys are quoted: ["..."] + - list indices use [i] + The base may be '' or '$' or a full path. + """ + if isinstance(token, int): + return f"{base}[{token}]" + + key = token + # Use dot-notation if it doesn't break the tokenizer used earlier. + use_dot = (key != "") and ("." not in key) and ("[" not in key) + if use_dot: + if not base or base == "$": + # "$" -> "$.key", "" -> "key" + return (base + "." if base == "$" else "") + key + return base + "." + key + else: + return f'{base}["{_escape_key_for_brackets(key)}"]' + + +def iter_json_paths( + obj: Any, + *, + include_root: bool = False, + leaves_only: bool = True, + include_containers: bool = False, + include_values: bool = False, + sort_keys: bool = False, + max_depth: int | None = None, +) -> Iterator[tuple[str, Any]]: + """ + Depth-first traversal that yields all JSONPath-like paths in `obj`. + By default, yields leaf paths only; see flags to tweak behavior. + + Args: + obj: Any Python object; dicts and lists are traversed. Tuples are treated as leaves + (to mirror the setter that only understands lists). + include_root: If True, include '$' (or '' if combine paths without root) as a node + when include_containers=True and/or when obj is a leaf. + leaves_only: If True, only yield paths to non-dict/non-list values. + include_containers: If True, also yield paths to dict/list containers (including empty ones). + include_values: If True, yield (path, value) tuples; otherwise just the path strings. + sort_keys: If True, iterate dict keys in sorted(str(key)) order (deterministic). + max_depth: Optional positive int to cap recursion depth (root has depth=0). None = unlimited. + + Yields: + Either the path string, or (path, value) if include_values=True. + """ + # Prepare root path + root_path = "$" if include_root else "" + + seen_ids = set() + + def yield_item(path: str, value: Any): + if include_values: + return path, value + return path + + def rec(current: Any, path: str, depth: int): + # Depth cap + if max_depth is not None and depth > max_depth: + return + + # Cycle protection for containers + if isinstance(current, (dict, list)): + oid = id(current) + if oid in seen_ids: + return + seen_ids.add(oid) + + # Dict + if isinstance(current, dict): + if include_containers and not leaves_only: + yield yield_item(path or ("$" if include_root else ""), current) + if not current and include_containers and leaves_only: + # Empty dict counts as a leaf-like container if user wants containers included. + yield yield_item(path or ("$" if include_root else ""), current) + return + + # Prepare iteration + items = current.items() + if sort_keys: + # sort by stringified key for deterministic ordering + items = sorted(items, key=lambda kv: str(kv[0])) # type: ignore[assignment] + + for k, v in items: + # JSON keys are strings; if not, coerce and quote with brackets + if not isinstance(k, str): + k_str = str(k) + else: + k_str = k + + child_path = _join_path(path or ("$" if include_root else ""), k_str) + # Recurse + if isinstance(v, (dict, list)): + # container + if include_containers and not leaves_only: + yield yield_item(child_path, v) + yield from rec(v, child_path, depth + 1) + else: + # leaf + if not leaves_only and include_containers: + # also include the parent container (already handled), leaf comes too + pass + yield yield_item(child_path, v) + + # List + elif isinstance(current, list): + if include_containers and not leaves_only: + yield yield_item(path or ("$" if include_root else ""), current) + if not current and include_containers and leaves_only: + # Empty list as container-leaf + yield yield_item(path or ("$" if include_root else ""), current) + return + + for idx, v in enumerate(current): + child_path = _join_path(path or ("$" if include_root else ""), idx) + if isinstance(v, (dict, list)): + if include_containers and not leaves_only: + yield yield_item(child_path, v) + yield from rec(v, child_path, depth + 1) + else: + yield yield_item(child_path, v) + + # Scalar leaf or unsupported container type (tuple/set/etc. treated as leaf) + elif path or include_root: + yield yield_item(path or "$", current) + else: + # Edge case: scalar root without '$' + yield yield_item("", current) + + # Optionally include the root itself + if include_root and include_containers and not leaves_only: + yield yield_item("$", obj) + + yield from rec(obj, root_path, depth=0) + + +def list_json_paths( + obj: Any, + *, + include_root: bool = False, + leaves_only: bool = True, + include_containers: bool = False, + sort_keys: bool = False, + max_depth: int | None = None, +) -> list[str | tuple[str, Any]]: + """ + Convenience wrapper that returns only path strings. + """ + return list( + iter_json_paths( + obj, + include_root=include_root, + leaves_only=leaves_only, + include_containers=include_containers, + include_values=False, + sort_keys=sort_keys, + max_depth=max_depth, + ) + ) + + +def paths_with_values( + obj: Any, + *, + include_root: bool = False, + leaves_only: bool = True, + include_containers: bool = False, + sort_keys: bool = False, + max_depth: int | None = None, + exclude_none: bool = False, +) -> list[tuple[str, Any]]: + """ + Return a list of (path, value) pairs for `obj` using the same JSONPath-like syntax. + Set `exclude_none=True` to drop entries where value is None. + """ + pairs = iter_json_paths( + obj, + include_root=include_root, + leaves_only=leaves_only, + include_containers=include_containers, + include_values=True, + sort_keys=sort_keys, + max_depth=max_depth, + ) + if exclude_none: + return [(p, v) for p, v in pairs if v is not None] + return list(pairs) + + +def path_value_map( + obj: Any, + *, + include_root: bool = False, + leaves_only: bool = True, + include_containers: bool = False, + sort_keys: bool = False, + max_depth: int | None = None, + exclude_none: bool = False, +) -> dict[str, Any]: + """ + Return a dict mapping path -> value. + """ + pairs = paths_with_values( + obj, + include_root=include_root, + leaves_only=leaves_only, + include_containers=include_containers, + sort_keys=sort_keys, + max_depth=max_depth, + exclude_none=exclude_none, + ) + return dict(pairs) diff --git a/src/diff/patch.py b/src/diff/patch.py new file mode 100644 index 0000000..bc31a00 --- /dev/null +++ b/src/diff/patch.py @@ -0,0 +1,367 @@ +import copy +import typing +from typing import Any + +from diff.diff import Delta + +Token = str | int # str for dict keys, int for list indices + + +class JsonPathError(ValueError): + pass + + +def _tokenize_json_path(path: str) -> list[Token]: + """ + Tokenize a JSONPath-like string into a list of tokens. + - Dict keys -> strings + - List indices -> integers + Supported syntax: + $.a.b[2]["key.with.dots"][0] + Notes: + - Leading '$' is optional. + - Dot-notation for simple keys. + - Brackets for indices and quoted keys. Quotes can be ' or ". + - Escaping inside quoted keys: backslash escapes the quote and backslash (\", \', \\). + """ + if not isinstance(path, str) or not path: + raise JsonPathError("Path must be a non-empty string.") + + i = 0 + n = len(path) + tokens: list[Token] = [] + + # Skip optional leading '$' and optional following '.' + if i < n and path[i] == "$": + i += 1 + if i < n and path[i] == ".": + i += 1 + + def read_simple_key(start: int) -> tuple[str, int]: + j = start + while j < n and path[j] not in ".[": + j += 1 + if j == start: + raise JsonPathError(f"Expected key at position {start} in '{path}'") + return path[start:j], j + + def read_bracket_key_or_index(start: int) -> tuple[Token, int]: + # start at '[', return (token, new_index_after_']) + j = start + 1 + if j >= n: + raise JsonPathError(f"Unclosed '[' at position {start} in '{path}'") + + if path[j] in ("'", '"'): + # Quoted key + quote = path[j] + j += 1 + buf = [] + while j < n: + ch = path[j] + if ch == "\\": # escape sequence + j += 1 + if j >= n: + raise JsonPathError("Trailing backslash in quoted key.") + esc = path[j] + if esc in [quote, "\\"]: + buf.append(esc) + else: + # Keep unknown escape as-is (e.g., \n), or handle specially if desired + buf.append(esc) + j += 1 + continue + if ch == quote: + j += 1 + break + buf.append(ch) + j += 1 + else: + raise JsonPathError( + f"Unclosed quoted key starting at position {start} in '{path}'" + ) + + # Expect closing ']' + if j >= n or path[j] != "]": + raise JsonPathError( + f"Expected ']' after quoted key at position {j} in '{path}'" + ) + return "".join(buf), j + 1 + + # Numeric index + k = j + while k < n and path[k].isdigit(): + k += 1 + if k == j: + raise JsonPathError( + f"Expected non-negative integer index after '[' at position {start} in '{path}'" + ) + if k >= n or path[k] != "]": + raise JsonPathError(f"Expected ']' after index at position {k} in '{path}'") + idx = int(path[j:k]) + return idx, k + 1 + + while i < n: + ch = path[i] + if ch == ".": + i += 1 # skip redundant dots (e.g., '$.a..b' would error on next step) + continue + elif ch == "[": + token, i = read_bracket_key_or_index(i) + tokens.append(token) + else: + key, i = read_simple_key(i) + tokens.append(key) + + return tokens + + +def set_by_json_path( + doc: Any, path: str, value: Any, *, create_missing: bool = True +) -> Any: + """ + Set `value` into `doc` following a JSONPath-like `path`, creating + intermediate dicts/lists if `create_missing` is True. + + Mutates `doc` in place and also returns it for convenience. + + Raises: + - JsonPathError on path syntax errors. + - TypeError when the path expects a dict/list but finds another type. + - IndexError for negative indices (not supported). + """ + tokens = _tokenize_json_path(path) + if not tokens: + raise JsonPathError("Path resolves to the root; set on '$' is not supported.") + + # We'll walk down, creating as needed. + current = doc + parents: list[ + tuple[Any, Token] + ] = [] # (container, token used to access) if you later want force-replace + for idx, tok in enumerate(tokens): + last = idx == len(tokens) - 1 + next_tok = None if last else tokens[idx + 1] + + if isinstance(tok, str): + # Expect dict + if not isinstance(current, dict): + raise TypeError( + f"Expected dict at step {idx} for key '{tok}', found {type(current).__name__}" + ) + if last: + current[tok] = value + return doc + # create if missing + if tok not in current or current[tok] is None: + if not create_missing: + raise KeyError( + f"Missing key '{tok}' at step {idx} and create_missing=False" + ) + current[tok] = [] if isinstance(next_tok, int) else {} + parents.append((current, tok)) + current = current[tok] + + else: + # list index + index = tok + if index < 0: + raise IndexError( + "Negative indices are not supported in this implementation." + ) + if not isinstance(current, list): + raise TypeError( + f"Expected list at step {idx} for index [{index}], found {type(current).__name__}" + ) + # extend if needed + if index >= len(current): + if not create_missing: + raise IndexError( + f"Index {index} out of range at step {idx} and create_missing=False" + ) + current.extend([None] * (index - len(current) + 1)) + if last: + current[index] = value + return doc + if current[index] is None: + if not create_missing: + raise KeyError( + f"Missing element at index {index} (None) and create_missing=False" + ) + current[index] = [] if isinstance(next_tok, int) else {} + parents.append((current, index)) + current = current[index] + + # Should not reach here + return doc + + +def get_by_json_path(doc: Any, path: str) -> Any: + """ + Retrieve a value from `doc` following the same JSONPath-like syntax. + """ + tokens = _tokenize_json_path(path) + current = doc + for idx, tok in enumerate(tokens): + if isinstance(tok, str): + if not isinstance(current, dict) or tok not in current: + raise KeyError(f"Path segment {tok} not found at step {idx}") + current = current[tok] + else: + if not isinstance(current, list): + raise TypeError( + f"Expected list at step {idx} for index [{tok}], found {type(current).__name__}" + ) + if tok < 0 or tok >= len(current): + raise IndexError(f"Index {tok} out of range at step {idx}") + current = current[tok] + return current + + +def _delete_in_parent(parent: Any, tok: Token, *, remove_from_list: bool) -> None: + """ + Remove child referenced by `tok` from `parent`. + - For dicts: del parent[tok] + - For lists: if remove_from_list=True -> del parent[tok]; else -> parent[tok] = None + """ + if isinstance(tok, str): + if not isinstance(parent, dict): + raise TypeError( + f"Expected dict parent to delete key '{tok}', found {type(parent).__name__}" + ) + if tok in parent: + del parent[tok] + else: + # list index + if not isinstance(parent, list): + raise TypeError( + f"Expected list parent to delete index [{tok}], found {type(parent).__name__}" + ) + if 0 <= tok < len(parent): + if remove_from_list: + del parent[tok] + else: + parent[tok] = None + + +def _is_empty_container(obj: Any) -> bool: + """Return True if obj is an empty dict or empty list.""" + return (isinstance(obj, dict) and len(obj) == 0) or ( + isinstance(obj, list) and len(obj) == 0 + ) + + +def pop_by_json_path( + doc: Any, + path: str, + *, + missing_ok: bool = False, + remove_from_list: bool = False, + prune_empty: bool = False, +) -> Any: + """ + Remove and return the value at `path`. Behaves like delete_by_json_path but returns the removed value. + If the path is missing and missing_ok=True, returns None and leaves `doc` unchanged. + """ + tokens = _tokenize_json_path(path) + if not tokens: + raise JsonPathError("Path resolves to the root; popping '$' is not supported.") + + # Traverse to parent + current = doc + parents: list[tuple[Any, Token]] = [] + try: + for idx, tok in enumerate(tokens[:-1]): + if isinstance(tok, str): + if ( + not isinstance(current, dict) + or tok not in current + or current[tok] is None + ): + if missing_ok: + return None + raise KeyError(f"Missing key '{tok}' at step {idx}") # noqa: TRY301 + parents.append((current, tok)) + current = current[tok] + else: + if not isinstance(current, list) or tok < 0 or tok >= len(current): + if missing_ok: + return None + raise IndexError(f"Index {tok} out of range at step {idx}") # noqa: TRY301 + if current[tok] is None: + if missing_ok: + return None + raise KeyError(f"None found at index {tok} at step {idx}") # noqa: TRY301 + parents.append((current, tok)) + current = current[tok] + except (TypeError, KeyError, IndexError): + if missing_ok: + return None + raise + + # Pop at leaf + leaf = tokens[-1] + removed = None + try: + if isinstance(leaf, str): + if not isinstance(current, dict): + if missing_ok: + return None + raise TypeError( # noqa: TRY301 + f"Expected dict at leaf for key '{leaf}', found {type(current).__name__}" + ) + if leaf not in current: + if missing_ok: + return None + raise KeyError(f"Key '{leaf}' not found at leaf") # noqa: TRY301 + removed = current[leaf] + del current[leaf] + else: + if not isinstance(current, list): + if missing_ok: + return None + raise TypeError( # noqa: TRY301 + f"Expected list at leaf for index [{leaf}], found {type(current).__name__}" + ) + if leaf < 0 or leaf >= len(current): + if missing_ok: + return None + raise IndexError(f"Index {leaf} out of range at leaf") # noqa: TRY301 + if remove_from_list: + removed = current[leaf] + del current[leaf] + else: + removed = current[leaf] + current[leaf] = None + except (TypeError, KeyError, IndexError): + if missing_ok: + return None + raise + + # Prune if requested + if prune_empty: + child = current + for depth in range(len(parents) - 1, -1, -1): + parent, tok_to_child = parents[depth] + if _is_empty_container(child): + _delete_in_parent( + parent, tok_to_child, remove_from_list=remove_from_list + ) + child = parent + else: + break + + return removed + + +def patch(base: dict[str, typing.Any], deltas: list[Delta]) -> dict[str, typing.Any]: + output = copy.deepcopy(base) + for op in deltas: + if op.op == "deleted": + pop_by_json_path(output, op.path, prune_empty=True, remove_from_list=True) + continue + set_by_json_path( + output, + op.path, + op.new_value, + ) + return output diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_diff.py b/tests/test_diff.py new file mode 100644 index 0000000..3020d6a --- /dev/null +++ b/tests/test_diff.py @@ -0,0 +1,216 @@ +import inspect +import typing + +import pytest + +from diff import Delta, diff, patch + + +def _op_has_field(op_obj, name: str) -> bool: + return hasattr(op_obj, name) + + +def _op_get(op_obj, *names) -> typing.Any: + """Get the first existing attribute among names; raise if none exist.""" + for n in names: + if hasattr(op_obj, n): + return getattr(op_obj, n) + raise AssertionError(f"Operation object has none of the attributes {names}") + + +def _mk_expected_operation(**kwargs): + """ + Construct an Operation but only pass fields that are actually supported by its signature. + Useful if Operation signature has slightly different names in different versions. + """ + params = inspect.signature(Delta).parameters + allowed = {k: v for k, v in kwargs.items() if k in params} + return Delta(**allowed) + + +# --- Your original tests (kept) ------------------------------------------- + + +@pytest.mark.parametrize( + ("old", "new", "expected_delta"), + [ + ( + {"name": "Amin"}, + {"name": "Amin2"}, + [Delta(op="modified", path="$.name", old_value="Amin", new_value="Amin2")], + ) + ], +) +def test_diff(old: dict, new: dict, expected_delta: list[Delta]): + result = diff(new=new, old=old) + assert expected_delta == result + + +def test_patch_complex(): + old = {"name": "amin"} + new = { + "full_name": "a", + "details": { + "matrix": [[[1, 2, 3]], [1, 2, 3]], + "list_object": [{"name": "amin", "matrix": [[1, 2], [1, 4]]}], + }, + } + deltas = diff(new=new, old=old) + patch_result = patch(base=old, deltas=deltas) + assert patch_result == new + + +# --- New tests ------------------------------------------------------------- + + +def test_diff_no_changes_is_empty(): + base = {"user": {"name": "Amin", "age": 30}, "tags": ["a", "b"]} + result = diff(new=base, old=base) + assert result == [] + + +def test_added_key_expected_op_and_patch_roundtrip(): + old: dict[str, typing.Any] = {} + new = {"age": 30} + ops = diff(new=new, old=old) + + # At least one 'added' operation to $.age (value semantics may differ: new_value vs value) + assert any( + o.op == "added" and o.path == "$.age" and _op_get(o, "new_value", "value") == 30 + for o in ops + ) + + # Roundtrip + assert patch(base=old, deltas=ops) == new + + +def test_deleted_key_expected_op_and_patch_roundtrip(): + old = {"name": "Amin"} + new: dict[str, typing.Any] = {} + ops = diff(new=new, old=old) + + # At least one 'deleted' operation from $.name (old_value/value semantics) + assert any( + o.op == "deleted" + and o.path == "$.name" + and _op_get(o, "old_value", "value") == "Amin" + for o in ops + ) + + # Roundtrip + assert patch(base=old, deltas=ops) == new + + +@pytest.mark.parametrize( + ("old", "new"), + [ + # Modify primitive and add nested dict + ({"a": 1}, {"a": 2, "b": {"c": "x"}}), + # Modify list items and length + ({"list": [1, 2, 3]}, {"list": [1, 4, 3, 5]}), + # Nested lists and dicts + ( + {"details": {"matrix": [[1, 2], [3, 4]], "meta": {"active": True}}}, + {"details": {"matrix": [[1, 2], [3, 5], [8]], "meta": {"active": False}}}, + ), + # List of dicts + ( + {"items": [{"id": 1, "v": "a"}, {"id": 2}]}, + {"items": [{"id": 1, "v": "b"}, {"id": 2, "x": 1}, {"id": 3}]}, + ), + # None to value, value to None + ( + {"n": None, "z": 1}, + {"n": 0, "z": None}, + ), + ], +) +def test_roundtrip_both_directions(old, new): + # Convert old -> new + ops_forward = diff(new=new, old=old) + result_forward = patch(base=old, deltas=ops_forward) + assert result_forward == new + + # Convert new -> old + ops_backward = diff(new=old, old=new) + result_backward = patch(base=new, deltas=ops_backward) + assert result_backward == old + + +def test_none_handling_as_value_vs_absence(): + # None treated as a value: modification + old = {"x": None} + new = {"x": 1} + ops = diff(new=new, old=old) + # Should be a modified (or possibly added if implementation replaces container), + # but at least path is present and patch succeeds. + assert any(o.path == "$.x" for o in ops) + assert patch(base=old, deltas=ops) == new + + # Removing a key entirely should be a 'deleted' + old2 = {"y": None} + new2: dict[str, typing.Any] = {} + ops2 = diff(new=new2, old=old2) + assert any(o.op == "deleted" and o.path == "$.y" for o in ops2) + assert patch(base=old2, deltas=ops2) == new2 + + +def test_multiple_changes_in_one_structure(): + old = { + "user": {"name": "Amin", "age": 30}, + "tags": ["a", "b", "c"], + "settings": {"theme": "light"}, + } + new = { + "user": {"name": "Amin2", "age": 31, "role": "dev"}, + "tags": ["a", "c", "d"], # b->deleted, d->added, index change + "settings": {"theme": "dark"}, + } + + ops = diff(new=new, old=old) + + # Expect at least these paths to show up with the right op kinds + expect_paths = { + "$.user.name": "modified", + "$.user.age": "modified", + "$.user.role": "added", + "$.settings.theme": "modified", + } + seen = {o.path: o.op for o in ops if o.path in expect_paths} + for p, expected_op in expect_paths.items(): + assert seen.get(p) == expected_op, ( + f"Expected {expected_op} at {p}, got {seen.get(p)}" + ) + + # Roundtrip must work + assert patch(base=old, deltas=ops) == new + + +def test_diff_then_patch_is_pure_function_of_inputs(): + """ + Ensure calling diff multiple times returns consistent operations + for the same inputs, and patch result is consistent. + """ + old = {"x": [1, 2, {"y": "z"}]} + new = {"x": [1, 3, {"y": "Z"}], "n": 1} + + ops1 = diff(new=new, old=old) + ops2 = diff(new=new, old=old) + # Equality of ops may depend on internal ordering; at least lengths match and patch result is same + assert len(ops1) == len(ops2) + assert patch(base=old, deltas=ops1) == new + assert patch(base=old, deltas=ops2) == new + + +def test_reverse_diff_converts_back_and_forth(): + """ + Explicitly verify that forward diff patches old->new and reverse diff patches new->old. + """ + old = {"a": 1, "k": {"x": [1, 2]}} + new = {"a": 2, "k": {"x": [1, 2, 3]}, "b": {"p": 9}} + + forward = diff(new=new, old=old) # old -> new + backward = diff(new=old, old=new) # new -> old + + assert patch(base=old, deltas=forward) == new + assert patch(base=new, deltas=backward) == old