├── .dockerignore ├── .editorconfig ├── .envrc ├── .github └── workflows │ ├── main.yml │ ├── publish-dev-images.yml │ ├── publish-docs.yml │ └── publish.yml ├── .gitignore ├── .prettierrc ├── .vscode-python.env ├── CHANGELOG.md ├── Dockerfile ├── LICENSE.md ├── Makefile ├── README.md ├── docker-bake.hcl ├── docker-compose.devcontainer.yml ├── docker-compose.yml ├── docs ├── .gitignore ├── _extensions │ ├── machow │ │ └── interlinks │ │ │ ├── .gitignore │ │ │ ├── _extension.yml │ │ │ └── interlinks.lua │ └── mcanouil │ │ └── iconify │ │ ├── LICENSE │ │ ├── _extension.yml │ │ ├── iconify-icon.min.js │ │ └── iconify.lua ├── _quarto.yml ├── explanation │ └── v8_serialization_format.qmd ├── howto │ ├── install.qmd │ └── use_at_a_glance.qmd ├── index.qmd ├── styles.css ├── tutorials │ └── js_to_py.qmd ├── v8serialize_logo.svg └── v8serialize_logo_auto.svg ├── poetry.lock ├── pyproject.toml ├── scripts ├── ci-build-docs.sh ├── ci-publish-docs.sh └── list-echoservers.sh ├── src └── v8serialize │ ├── __init__.py │ ├── _decorators.py │ ├── _enums.py │ ├── _errors.py │ ├── _pycompat │ ├── __init__.py │ ├── builtins.py │ ├── dataclasses.py │ ├── enum.py │ ├── exceptions.py │ ├── inspect.py │ ├── re.py │ ├── types.py │ └── typing.py │ ├── _recursive_eq.py │ ├── _references.py │ ├── _typing.py │ ├── _values.py │ ├── _versions.py │ ├── constants.py │ ├── decode.py │ ├── encode.py │ ├── extensions.py │ ├── jstypes │ ├── __init__.py │ ├── _equality.py │ ├── _normalise_property_key.py │ ├── _repr.py │ ├── _v8.py │ ├── _v8traceback.py │ ├── jsarray.py │ ├── jsarrayproperties.py │ ├── jsbigint.py │ ├── jsbuffers.py │ ├── jserror.py │ ├── jsmap.py │ ├── jsobject.py │ ├── jsprimitiveobject.py │ ├── jsregexp.py │ ├── jsset.py │ └── jsundefined.py │ └── py.typed ├── test ├── __init__.py ├── conftest.py ├── jstypes │ ├── __init__.py │ ├── _v8_traceback_error_fixtures.py │ ├── snapshots │ │ ├── repr__jsarray_maxjsarray__0.txt │ │ ├── repr__jsarray_maxjsarray__1.txt │ │ ├── repr__jsarray_maxjsarray__2.txt │ │ ├── repr__jsarray_maxjsarray__3.txt │ │ ├── repr__jsarray_maxjsarray__4.txt │ │ ├── repr__jsarray_maxjsarray__5.txt │ │ ├── repr__jsarray_maxjsarray__6.txt │ │ ├── repr__jsarray_maxjsarray__7.txt │ │ ├── repr__jsarray_maxjsarray_indented__0.txt │ │ ├── repr__jsarray_maxjsarray_indented__1.txt │ │ ├── repr__jsarray_maxjsarray_indented__2.txt │ │ ├── repr__jsarray_maxjsarray_indented__3.txt │ │ ├── repr__jsarray_maxjsarray_indented__4.txt │ │ ├── repr__jsarray_maxjsarray_indented__5.txt │ │ ├── repr__jsarray_maxjsarray_indented__6.txt │ │ ├── repr__jsarray_maxjsarray_indented__7.txt │ │ ├── repr__jsarray_maxlevel__0.txt │ │ ├── repr__jsarray_repr__0.txt │ │ ├── repr__jsarray_repr__1.txt │ │ ├── repr__jsarray_repr__10.txt │ │ ├── repr__jsarray_repr__11.txt │ │ ├── repr__jsarray_repr__2.txt │ │ ├── repr__jsarray_repr__3.txt │ │ ├── repr__jsarray_repr__4.txt │ │ ├── repr__jsarray_repr__5.txt │ │ ├── repr__jsarray_repr__6.txt │ │ ├── repr__jsarray_repr__7.txt │ │ ├── repr__jsarray_repr__8.txt │ │ ├── repr__jsarray_repr__9.txt │ │ ├── repr__jsobject_maxjsobject__0.txt │ │ ├── repr__jsobject_maxjsobject__1.txt │ │ ├── repr__jsobject_maxjsobject__2.txt │ │ ├── repr__jsobject_maxjsobject_indented__0.txt │ │ ├── repr__jsobject_maxjsobject_indented__1.txt │ │ ├── repr__jsobject_maxjsobject_indented__2.txt │ │ ├── repr__jsobject_maxjsobject_indented__3.txt │ │ ├── repr__jsobject_repr__0.txt │ │ ├── repr__jsobject_repr__1.txt │ │ ├── repr__jsobject_repr__2.txt │ │ ├── repr__jsobject_repr__3.txt │ │ ├── repr__jsobject_repr__4.txt │ │ ├── repr__jsobject_repr__5.txt │ │ ├── v8_traceback__format_exception_for_v8__represents_ExceptionGroup_py__0.exc.txt │ │ ├── v8_traceback__format_exception_for_v8__represents_ExceptionGroup_v8__0.exc.txt │ │ ├── v8_traceback__format_exception_for_v8__represents_ExceptionGroup_with_context_py__0.exc.txt │ │ ├── v8_traceback__format_exception_for_v8__represents_ExceptionGroup_with_context_v8__0.exc.txt │ │ ├── v8_traceback__format_exception_for_v8__represents_exception_with_context_py__0.exc.txt │ │ ├── v8_traceback__format_exception_for_v8__represents_exception_with_context_v8__0.exc.txt │ │ ├── v8_traceback__format_exception_for_v8__represents_nested_ExceptionGroups_with_context_py__0.exc.txt │ │ ├── v8_traceback__format_exception_for_v8__represents_nested_ExceptionGroups_with_context_v8__0.exc.txt │ │ ├── v8_traceback__format_exception_for_v8__represents_simple_exception_py__0.exc.txt │ │ └── v8_traceback__format_exception_for_v8__represents_simple_exception_v8__0.exc.txt │ ├── test__equality.py │ ├── test__repr.py │ ├── test__v8_traceback.py │ ├── test_jsarray.py │ ├── test_jsarrayproperties.py │ ├── test_jsbigint.py │ ├── test_jsbuffers.py │ ├── test_jserror.py │ ├── test_jsmap.py │ ├── test_jsobject.py │ ├── test_jsobject__statemachine.py │ ├── test_jsprimitiveobject.py │ ├── test_jsregexp.py │ ├── test_jsset.py │ └── test_normalise_property_key.py ├── pycompat │ ├── __init__.py │ ├── test_dataclasses.py │ └── test_typing.py ├── strategies.py ├── test__recursive_eq.py ├── test_codec_round_trip.py ├── test_constants.py ├── test_decode.py ├── test_encode.py ├── test_errors.py ├── test_extensions.py ├── test_protocol_abc_inheritance.py ├── test_protocol_dataclass_interaction.py ├── test_references.py ├── test_round_trip_with_v8.py └── utils.py └── testing ├── smoketest ├── README.md ├── pyproject.toml └── smoketest.py └── v8serialize-echo ├── .dockerignore ├── .envrc ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── deno.json ├── deno.lock ├── docker-bake.hcl ├── main.ts └── scripts └── build_npm.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | .* 2 | **/__pycache__ 3 | **/dist 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://editorconfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | end_of_line = lf 8 | insert_final_newline = true 9 | indent_size = 2 10 | 11 | [*.py] 12 | indent_size = 4 13 | 14 | # Trailing whitespace is significant in .diff files 15 | [*.diff] 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | source_env_if_exists .local.env 2 | source_env testing/v8serialize-echo 3 | 4 | # Defines the echoservers tested in test/test_round_trip_with_v8.py 5 | V8SERIALIZE_ECHOSERVERS=$(./scripts/list-echoservers.sh) 6 | export V8SERIALIZE_ECHOSERVERS 7 | 8 | # Get the name of the devcontainer volume if we are running in a devcontainer 9 | # with a named volume as the workspace. 10 | function workspace_volume_name() { 11 | local CONTAINER_ID 12 | CONTAINER_ID="$(hostname)" 13 | # shellcheck disable=SC2016 14 | local WORKSPACE_MOUNT_SOURCE_FMT=' 15 | {{- $source := "" }} 16 | {{- range .HostConfig.Mounts }} 17 | {{- if (and (eq .Type "volume") (eq .Target "/workspaces")) }} 18 | {{- $source = .Source }} 19 | {{- end }} 20 | {{- end }} 21 | {{- $source }}' 22 | docker container inspect "$CONTAINER_ID" \ 23 | --format="$WORKSPACE_MOUNT_SOURCE_FMT" 2>/dev/null 24 | } 25 | 26 | # Enable the devcontainer compose config if we're in a devcontainer with a 27 | # volume-mounted workspace. 28 | V8SERIALIZE_DEVCONTAINER_VOLUME=$(workspace_volume_name) 29 | if [[ "${V8SERIALIZE_DEVCONTAINER_VOLUME?}" ]]; then 30 | export V8SERIALIZE_DEVCONTAINER_VOLUME 31 | export WORKSPACE_MOUNT_PATH=/workspaces 32 | export "COMPOSE_FILE=$(pwd)/docker-compose.yml:$(pwd)/docker-compose.devcontainer.yml" 33 | fi 34 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: "Main" 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | 8 | env: 9 | ECHOSERVER_VERSION: 0.3.0 10 | 11 | jobs: 12 | prepare_checks: 13 | runs-on: ubuntu-latest 14 | outputs: 15 | targets: ${{ steps.generate.outputs.targets }} 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: List targets 21 | id: generate 22 | uses: docker/bake-action/subaction/list-targets@v4 23 | with: 24 | target: default 25 | files: docker-bake.hcl 26 | 27 | run_check: 28 | runs-on: ubuntu-latest 29 | needs: 30 | - prepare_checks 31 | strategy: 32 | fail-fast: false 33 | matrix: 34 | target: ${{ fromJson(needs.prepare_checks.outputs.targets) }} 35 | steps: 36 | - name: Checkout 37 | uses: actions/checkout@v4 38 | 39 | - name: Check 40 | uses: docker/bake-action@v5 41 | with: 42 | targets: ${{ matrix.target }} 43 | files: docker-bake.hcl 44 | 45 | prepare_integration_checks: 46 | runs-on: ubuntu-latest 47 | outputs: 48 | targets: ${{ steps.generate.outputs.targets }} 49 | steps: 50 | - name: Checkout 51 | uses: actions/checkout@v4 52 | 53 | - name: List targets 54 | id: generate 55 | uses: docker/bake-action/subaction/list-targets@v4 56 | with: 57 | target: dev 58 | files: docker-bake.hcl 59 | 60 | run_integration_check: 61 | runs-on: ubuntu-latest 62 | needs: 63 | - prepare_integration_checks 64 | strategy: 65 | fail-fast: false 66 | matrix: 67 | target: 68 | ${{ fromJson(needs.prepare_integration_checks.outputs.targets) }} 69 | steps: 70 | - name: Checkout 71 | uses: actions/checkout@v4 72 | 73 | - name: Build dev environment image 74 | id: dev_image 75 | uses: docker/bake-action@v5 76 | with: 77 | targets: ${{ matrix.target }} 78 | files: docker-bake.hcl 79 | push: false 80 | load: true 81 | 82 | - name: Run integration test 83 | env: 84 | BAKE_META: ${{ steps.dev_image.outputs.metadata }} 85 | DEV_IMAGE_TARGET: ${{ matrix.target }} 86 | run: | 87 | set -x 88 | # Find the image name built by the preceding step for the Python 89 | # version being tested. 90 | V8SERIALIZE_DEV_IMAGE=$(jq <<<"${BAKE_META:?}" -re \ 91 | '.[$ENV.DEV_IMAGE_TARGET]["image.name"] | split(",") | first' 92 | ) 93 | 94 | # Start the V8 echoservers that the integration test communicates with 95 | docker compose up -d 96 | 97 | # Discover the running echoservers 98 | V8SERIALIZE_ECHOSERVERS=$(./scripts/list-echoservers.sh) 99 | export V8SERIALIZE_DEV_IMAGE V8SERIALIZE_ECHOSERVERS 100 | 101 | docker compose run integration_test 102 | -------------------------------------------------------------------------------- /.github/workflows/publish-dev-images.yml: -------------------------------------------------------------------------------- 1 | name: "Publish Dev/CI Container Images" 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths: 7 | - testing/v8serialize-echo/**/* 8 | schedule: 9 | # weekly, Wednesday @ 04:51 10 | - cron: 51 4 * * 3 11 | workflow_dispatch: 12 | 13 | env: 14 | REGISTRY: ghcr.io 15 | TAG_PREFIX: ghcr.io/${{ github.repository }} 16 | 17 | jobs: 18 | v8serialize-echo: 19 | name: Build & Publish v8serialize/echoserver Container Images 20 | runs-on: ubuntu-latest 21 | permissions: 22 | packages: write 23 | id-token: write # needed for signing the images with GitHub OIDC Token 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v4 28 | 29 | - name: Install Cosign 30 | uses: sigstore/cosign-installer@v3.6.0 31 | 32 | - name: Set up QEMU 33 | uses: docker/setup-qemu-action@v3 34 | 35 | - name: Set up Docker Buildx 36 | uses: docker/setup-buildx-action@v3 37 | 38 | - name: Log in to the Container registry 39 | uses: docker/login-action@v3 40 | with: 41 | registry: ${{ env.REGISTRY }} 42 | username: ${{ github.actor }} 43 | password: ${{ secrets.GITHUB_TOKEN }} 44 | 45 | - name: Set Environment Variables 46 | run: | 47 | set -x 48 | cd testing/v8serialize-echo 49 | source .envrc 50 | echo "ECHOSERVER_VERSION=${ECHOSERVER_VERSION:?}" >> "${GITHUB_ENV:?}" 51 | 52 | - name: Build & Publish CI Container Images 53 | id: bake 54 | uses: docker/bake-action@v5 55 | with: 56 | workdir: testing/v8serialize-echo 57 | provenance: true 58 | sbom: true 59 | push: true 60 | set: | 61 | *.cache-from=type=gha 62 | *.cache-to=type=gha,mode=max 63 | 64 | - name: Sign the images with GitHub OIDC Token 65 | env: 66 | BAKE_META: ${{ steps.bake.outputs.metadata }} 67 | run: | 68 | readarray -t image_refs < <( 69 | jq <<<"${BAKE_META:?}" -r ' 70 | .[] 71 | | select(.["image.name"]? and .["containerimage.digest"]?) 72 | | (.["containerimage.digest"]) as $containerimage_digest 73 | | (.["image.name"] | split(",")) as $image_names 74 | | $image_names[] | "\(.)@\($containerimage_digest)" 75 | ' 76 | ) 77 | 78 | echo "Images to sign:" 79 | printf ' - %s\n' "${image_refs[@]}" 80 | 81 | cosign sign --yes ${image_refs[@]:?} 82 | -------------------------------------------------------------------------------- /.github/workflows/publish-docs.yml: -------------------------------------------------------------------------------- 1 | name: Publish docs to GitHub Pages 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [main] 7 | 8 | jobs: 9 | build-deploy: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | env: 14 | GIT_AUTHOR_NAME: "GitHub Actions" 15 | GIT_AUTHOR_EMAIL: 16 | "${{ github.repository_owner }}@users.noreply.github.com" 17 | steps: 18 | - name: Check out repository 19 | uses: actions/checkout@v4 20 | 21 | - name: "Fetch gh-pages" 22 | run: git fetch origin gh-pages 23 | 24 | - name: Set up Quarto 25 | uses: quarto-dev/quarto-actions/setup@v2 26 | 27 | - uses: actions/setup-python@v5 28 | with: 29 | python-version: "3.12" 30 | 31 | - name: "Install project dependencies" 32 | run: | 33 | pip install poetry 34 | poetry install 35 | 36 | - name: "Build & Publish" 37 | run: | 38 | poetry run scripts/ci-publish-docs.sh 39 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | on: 3 | push: 4 | tags: 5 | - v** 6 | jobs: 7 | pypi-publish: 8 | name: Upload to PyPI 9 | environment: release 10 | runs-on: ubuntu-latest 11 | permissions: 12 | # IMPORTANT: this permission is mandatory for trusted publishing 13 | id-token: write 14 | steps: 15 | - uses: actions/checkout@v3 16 | - uses: actions/setup-python@v4 17 | with: 18 | python-version: "3.12" 19 | - name: Install tools 20 | run: pip install poetry 21 | - name: Build the release packages 22 | run: | 23 | poetry build 24 | sha256sum dist/* 25 | 26 | # only publish tagged commits to PyPI 27 | - name: Publish distribution 📦 to PyPI 28 | if: startsWith(github.ref, 'refs/tags/v') 29 | uses: pypa/gh-action-pypi-publish@release/v1 30 | with: 31 | print-hash: true 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .* 2 | !.devcontainer 3 | !.dockerignore 4 | !.editorconfig 5 | !.env.local.example 6 | !.envrc 7 | !.git* 8 | !.prettierrc 9 | !.vscode-python.env 10 | 11 | **/__pycache__ 12 | /htmlcov 13 | **/dist 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "proseWrap": "always" 5 | } 6 | -------------------------------------------------------------------------------- /.vscode-python.env: -------------------------------------------------------------------------------- 1 | # To have VSCode use this file, configure settings.json with: 2 | # "python.envFile": "${workspaceFolder}/.vscode-python.env" 3 | 4 | # Dynamically list running echoservers when running test/test_round_trip_with_v8.py 5 | V8SERIALIZE_ECHOSERVERS=scripts/list-echoservers.sh 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to v8serialize will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 | and this project adheres to 7 | [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 8 | 9 | ## [Unreleased] 10 | 11 | ### Added 12 | 13 | - Marked SerializationFeature.Float16Array as released from V8 13.1.201 (was 14 | marked as unreleased). ([#3](https://github.com/h4l/v8serialize/pull/3)) 15 | - `JSBigInt` type — a Python `int` subclass that always encodes to BigInt. 16 | ([#7](https://github.com/h4l/v8serialize/pull/7)) 17 | 18 | ### Changed 19 | 20 | - The API docs now include type annotations in the textual function signatures. 21 | Previously types were only shown in the table of parameters. Overloaded 22 | functions with multiple signatures are not shown, only the catch-all signature 23 | is (this is a limitation of quartodoc). 24 | ([#6](https://github.com/h4l/v8serialize/pull/6)) 25 | - Adjust number encoding rules. 26 | 27 | - The JSBigInt type added in this release always encodes to JavaScript bigint, 28 | and serialized bigints always decode to JSBigInt. 29 | - Previously bigint decoded to plain `int`, which meant that bigints in the 30 | safe float range would not round-trip as bigint, they'd be encoded as one 31 | of the int types, like Int32, or Double. 32 | - JavaScript Numbers encoded as Double (float64) now decode to Python `int` if 33 | they are exact integers in the range of integers that float64 can represent 34 | exactly. 35 | - Previously JavaScript Numbers that weren't serialized as one of the int 36 | types (like Int32) were always decoded as Python `float`. 37 | 38 | The encoding rules now are: 39 | 40 | - Python `float` always encodes to Double/float64. 41 | - Python `int` encodes to Double/float64 if it's within the float-safe range 42 | but too large for the int types, like Int32. 43 | - Python `int` encodes to JavaScript bigint if it's outside than the 44 | float-safe range. 45 | - Python `JSBigInt` always encodes to JavaScript bigint. 46 | 47 | The decoding rules now are: 48 | 49 | - JavaScript Double/float64 decodes to Python `float`, unless it's an exact 50 | integer within the float-safe integer range, in which case it decodes as 51 | Python `int`. 52 | - Small int values like Int32 decode as Python `int`. 53 | - JavaScript bigint values decode as Python `JSBigInt`. 54 | 55 | So values round-trip with the same types consistently, with the exceptions of: 56 | 57 | - large Python `int` which becomes `JSBigInt` after a round-trip. 58 | - exact integer Python `float` round-trip to Python `int` 59 | - This is compatible with Python's numeric type system as `int` types are 60 | accepted by types requiring `float`, and the `/` and `//` operators work 61 | equivalently with `int` and `float` representations of the same value. 62 | 63 | ([#7](https://github.com/h4l/v8serialize/pull/7)) 64 | 65 | ## [0.1.0] - 2024-09-24 66 | 67 | ### Added 68 | 69 | - The first stable release. 70 | 71 | [unreleased]: https://github.com/h4l/v8serialize/compare/v0.1.0...HEAD 72 | [0.1.0]: https://github.com/h4l/v8serialize/releases/tag/v0.1.0 73 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG PYTHON_VER 2 | 3 | FROM python:${PYTHON_VER:?} AS python-base 4 | ENV PIP_DISABLE_PIP_VERSION_CHECK=1 5 | 6 | 7 | FROM python-base AS poetry 8 | RUN --mount=type=cache,target=/root/.cache pip install poetry 9 | RUN python -m venv /venv 10 | ENV VIRTUAL_ENV=/venv \ 11 | PATH="/venv/bin:$PATH" 12 | RUN poetry config virtualenvs.create false 13 | WORKDIR /workspace 14 | COPY pyproject.toml poetry.lock /workspace/ 15 | 16 | # Poetry needs these to exist to setup the editable install 17 | RUN mkdir -p src/v8serialize && touch src/v8serialize/__init__.py README.md 18 | RUN --mount=type=cache,target=/root/.cache poetry install 19 | 20 | 21 | FROM poetry AS test 22 | RUN --mount=source=.,target=/workspace,rw \ 23 | --mount=type=cache,uid=1000,target=.pytest_cache \ 24 | --mount=type=cache,uid=1000,target=.hypothesis \ 25 | pytest 26 | 27 | 28 | FROM poetry AS lint-setup 29 | # invalidate cache so that the lint tasks run. We use no-cache-filter here but 30 | # not on the lint-* tasks so that the tasks can mount cache dirs themselves. 31 | RUN touch .now 32 | 33 | 34 | FROM lint-setup AS lint-check 35 | RUN --mount=source=.,target=/workspace,rw \ 36 | ruff check src test 37 | 38 | 39 | FROM lint-setup AS lint-format 40 | RUN --mount=source=.,target=/workspace,rw \ 41 | poetry run ruff format --check --diff . 42 | 43 | 44 | FROM lint-setup AS lint-mypy 45 | RUN --mount=source=.,target=/workspace,rw \ 46 | --mount=type=cache,target=.mypy_cache \ 47 | poetry run mypy . 48 | 49 | 50 | FROM poetry AS smoketest-pkg-build 51 | RUN --mount=source=testing/smoketest,target=.,rw \ 52 | mkdir /dist && poetry build -o /dist 53 | 54 | 55 | FROM scratch AS smoketest-pkg 56 | COPY --from=smoketest-pkg-build /dist/* . 57 | 58 | 59 | FROM poetry AS v8serialize-pkg-build 60 | RUN --mount=source=.,target=/workspace,rw \ 61 | mkdir /dist && poetry build -o /dist 62 | 63 | 64 | FROM scratch AS v8serialize-pkg 65 | COPY --from=v8serialize-pkg-build /dist/* . 66 | 67 | 68 | FROM python-base AS test-package 69 | RUN python -m venv /env 70 | ENV PATH=/env/bin:$PATH 71 | RUN --mount=from=smoketest-pkg,target=/pkg/smoketest \ 72 | --mount=from=v8serialize-pkg,target=/pkg/v8serialize \ 73 | pip install /pkg/smoketest/*.whl /pkg/v8serialize/*.whl 74 | RUN pip list 75 | RUN python -m smoketest 76 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright 2024 The v8serialize Authors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the “Software”), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := bash 2 | .ONESHELL: 3 | .SHELLFLAGS := -eu -o pipefail -c 4 | .RECIPEPREFIX = > 5 | .DELETE_ON_ERROR: 6 | MAKEFLAGS += --warn-undefined-variables 7 | MAKEFLAGS += --no-builtin-rules 8 | 9 | # This should be the first rule so that it runs by default when running `$ make` 10 | # without arguments. 11 | help: 12 | > @echo "Targets:" 13 | > grep -P '^([\w-]+)(?=:)' --only-matching Makefile | sort 14 | .PHONY: default 15 | 16 | clean: 17 | > rm -rf dist out 18 | .PHONY: clean 19 | 20 | install: 21 | > poetry install 22 | .PHONY: install 23 | 24 | test: 25 | > pytest 26 | .PHONY: test 27 | 28 | test-cov: 29 | > pytest --cov=v8serialize --cov-report=html 30 | .PHONY: test 31 | 32 | out/: 33 | > mkdir out 34 | 35 | typecheck: 36 | > @if dmypy status; then 37 | > dmypy run src test 38 | > else 39 | > mypy src test 40 | > fi 41 | .PHONY: typecheck 42 | 43 | lint: check-code-issues check-code-import-order check-code-format check-misc-file-formatting 44 | .PHONY: lint 45 | 46 | check-code-issues: 47 | > ruff check src test 48 | .PHONY: check-code-issues 49 | 50 | check-code-format: 51 | > ruff format --check src test 52 | .PHONY: check-code-format 53 | 54 | check-misc-file-formatting: 55 | > npx prettier --check . 56 | .PHONY: check-misc-file-formatting 57 | 58 | reformat-code: 59 | > @if [[ "$$(git status --porcelain)" != "" ]]; then 60 | > echo "Refusing to reformat code: files have uncommitted changes" >&2 ; exit 1 61 | > fi 62 | > ruff format src test 63 | .PHONY: reformat-code 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | The v8serialize logo. Monochome. Large "V8" and smaller "serialize" in a handwritten style, with the 8 stylized to look like a snake. 3 |

4 | 5 |

Imagine having postMessage() between JavaScript and Python.

6 | 7 | 8 | 9 | 10 | 11 |
pip install v8serialize
12 | 13 |

Documentation

14 | 15 | --- 16 | 17 | # `v8serialize` 18 | 19 | A Python library to read & write JavaScript values in [V8 serialization format] 20 | with Python. 21 | 22 | [V8 serialization format]: 23 | https://h4l.github.io/v8serialize/en/latest/explanation/v8_serialization_format.html 24 | 25 | ## Examples 26 | 27 | These examples demonstrate serializing and deserializing the same selection of 28 | JavaScript values in Python and JavaScript. JavaScript types supported by 29 | JavaScript's [`structuredClone()` algorithm] can be serialized. 30 | 31 | [`structuredClone()` algorithm]: 32 | https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm 33 | 34 | ### Serialize with Python 35 | 36 | ```python 37 | from base64 import b64encode 38 | from datetime import datetime, UTC 39 | import re 40 | 41 | from v8serialize import dumps 42 | from v8serialize.jstypes import JSObject, JSArray, JSUndefined 43 | 44 | serialized = dumps( 45 | [ 46 | "strings 🧵🧶🪡", 47 | 123, 48 | None, 49 | JSUndefined, 50 | JSArray({0: 'a', 1: 'b', 123456789: 'sparse'}), 51 | JSObject({"msg": "Hi"}), 52 | b"\xc0\xff\xee", 53 | 2**128, 54 | {"maps": True}, 55 | {"sets", "yes"}, 56 | re.compile(r"^\w+$"), 57 | datetime(2024, 1, 1, tzinfo=UTC), 58 | ] 59 | ) 60 | 61 | print(b64encode(serialized).decode()) 62 | ``` 63 | 64 | **Output** 65 | 66 | ``` 67 | /w9BDFMUc3RyaW5ncyDwn6e18J+ntvCfqqFVezBfYZaa7zpVAFMBYVUBUwFiVZWa7zpTBnNwYXJzZUADlprvOm9TA21zZ1MCSGl7AUIDwP/uWiIAAAAAAAAAAAAAAAAAAAAAATtTBG1hcHNUOgInUwRzZXRzUwN5ZXMsAlJTBV5cdyskgAJEAABAHyXMeEIkAAw= 68 | ``` 69 | 70 | ### Deserialize with Python 71 | 72 | ```python 73 | from base64 import b64decode 74 | from v8serialize import loads 75 | 76 | # The output of the JavaScript example 77 | serialized = b64decode( 78 | "/w9BDGMccwB0AHIAaQBuAGcAcwAgAD7Y9d0+2PbdPtih3kn2ATBfYZaa7zpJACIBYUkCIgFiSaq03nUiBnNwYXJzZUADlprvOm8iA21zZyICSGl7AUIDwP/uWjAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAA7IgRtYXBzVDoCJyIDeWVzIgRzZXRzLAJSIgVeXHcrJIACRAAAQB8lzHhCJAAM" 79 | ) 80 | print(loads(serialized)) 81 | ``` 82 | 83 | **Output** 84 | 85 | ```python 86 | JSArray([ 87 | 'strings 🧵🧶🪡', 88 | 123, 89 | None, 90 | JSUndefined, 91 | JSArray({ 92 | 0: 'a', 93 | 1: 'b', 94 | 123456789: 'sparse', 95 | }), 96 | JSObject(msg='Hi'), 97 | JSArrayBuffer(b'\xc0\xff\xee'), 98 | 340282366920938463463374607431768211456, 99 | JSMap({ 100 | 'maps': True, 101 | }), 102 | JSSet([ 103 | 'yes', 104 | 'sets', 105 | ]), 106 | JSRegExp(source='^\\w+$', flags=), 107 | datetime.datetime(2024, 1, 1, 0, 0), 108 | ]) 109 | ``` 110 | 111 | ### Serialize with Node.js / Deno 112 | 113 | ```javascript 114 | import * as v8 from "node:v8"; 115 | 116 | const sparseArray = ["a", "b"]; 117 | sparseArray[123456789] = "sparse"; 118 | 119 | const buffer = v8.serialize([ 120 | "strings 🧵🧶🪡", 121 | 123, 122 | null, 123 | undefined, 124 | sparseArray, 125 | { msg: "Hi" }, 126 | Uint8Array.from([0xc0, 0xff, 0xee]).buffer, 127 | 2n ** 128n, 128 | new Map([["maps", true]]), 129 | new Set(["yes", "sets"]), 130 | /^\w+$/v, 131 | new Date(Date.UTC(2024, 0, 1)), 132 | ]); 133 | 134 | console.log(buffer.toString("base64")); 135 | ``` 136 | 137 | **Output** 138 | 139 | ``` 140 | /w9BDGMccwB0AHIAaQBuAGcAcwAgAD7Y9d0+2PbdPtih3kn2ATBfYZaa7zpJACIBYUkCIgFiSaq03nUiBnNwYXJzZUADlprvOm8iA21zZyICSGl7AUIDwP/uWjAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAA7IgRtYXBzVDoCJyIDeWVzIgRzZXRzLAJSIgVeXHcrJIACRAAAQB8lzHhCJAAM 141 | ``` 142 | 143 | ## Deserialize with Node.js / Deno 144 | 145 | ```javascript 146 | import * as v8 from "node:v8"; 147 | 148 | // The output of the Python example 149 | const buffer = Buffer.from( 150 | "/w9BDFMUc3RyaW5ncyDwn6e18J+ntvCfqqFVezBfYZaa7zpVAFMBYVUBUwFiVZWa7zpTBnNwYXJzZUADlprvOm9TA21zZ1MCSGl7AUIDwP/uWiIAAAAAAAAAAAAAAAAAAAAAATtTBG1hcHNUOgInUwN5ZXNTBHNldHMsAlJTBV5cdyskgAJEAABAHyXMeEIkAAw=", 151 | "base64" 152 | ); 153 | console.log(v8.deserialize(buffer)); 154 | ``` 155 | 156 | **Output** 157 | 158 | ```javascript 159 | [ 160 | 'strings 🧵🧶🪡', 161 | 123, 162 | null, 163 | undefined, 164 | [ 'a', 'b', <123456787 empty items>, 'sparse' ], 165 | { msg: 'Hi' }, 166 | ArrayBuffer { [Uint8Contents]: , byteLength: 3 }, 167 | 340282366920938463463374607431768211456n, 168 | Map(1) { 'maps' => true }, 169 | Set(2) { 'yes', 'sets' }, 170 | /^\w+$/v, 171 | 2024-01-01T00:00:00.000Z 172 | ] 173 | ``` 174 | -------------------------------------------------------------------------------- /docker-bake.hcl: -------------------------------------------------------------------------------- 1 | group "default" { 2 | targets = ["test", "test_package", "lint"] 3 | } 4 | 5 | // TODO: integration 6 | 7 | py_versions = ["3.9", "3.10", "3.11", "3.12", "3.13"] 8 | 9 | target "test" { 10 | name = "test_py${replace(py, ".", "")}" 11 | matrix = { 12 | py = py_versions, 13 | } 14 | args = { 15 | PYTHON_VER = py == "latest" ? "slim" : "${py}-slim" 16 | } 17 | target = "test" 18 | no-cache-filter = ["test"] 19 | output = ["type=cacheonly"] 20 | } 21 | 22 | target "test_package" { 23 | name = "test_package_py${replace(py, ".", "")}" 24 | matrix = { 25 | py = py_versions, 26 | } 27 | args = { 28 | PYTHON_VER = py == "latest" ? "slim" : "${py}-slim" 29 | } 30 | target = "test-package" 31 | no-cache-filter = ["test-package"] 32 | output = ["type=cacheonly"] 33 | } 34 | 35 | target "lint" { 36 | name = "lint-${lint_type}" 37 | matrix = { 38 | lint_type = ["check", "format", "mypy"], 39 | } 40 | args = { 41 | PYTHON_VER = "slim" 42 | } 43 | target = "lint-${lint_type}" 44 | no-cache-filter = ["lint-setup"] 45 | output = ["type=cacheonly"] 46 | } 47 | 48 | target "dev" { 49 | name = "dev_py${replace(py, ".", "")}" 50 | matrix = { 51 | py = py_versions, 52 | } 53 | inherits = ["test_py${replace(py, ".", "")}"] 54 | no-cache-filter = [] 55 | output = [] 56 | target = "poetry" 57 | tags = ["v8serialize-dev:py${replace(py, ".", "")}"] 58 | } 59 | -------------------------------------------------------------------------------- /docker-compose.devcontainer.yml: -------------------------------------------------------------------------------- 1 | services: 2 | integration_test: 3 | volumes: 4 | - devcontainer_volume:${WORKSPACE_MOUNT_PATH:?} 5 | working_dir: $PWD 6 | 7 | volumes: 8 | devcontainer_volume: 9 | external: true 10 | name: ${V8SERIALIZE_DEVCONTAINER_VOLUME:?} 11 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | name: v8serialize 2 | services: 3 | echoserver-node-22: 4 | container_name: ${COMPOSE_PROJECT_NAME:?}-echoserver-node-22 5 | image: ghcr.io/h4l/v8serialize/echoserver:${ECHOSERVER_VERSION:?}-node-22 6 | networks: [v8serialize] 7 | labels: 8 | com.github.h4l.v8serialize.echoserver: "true" 9 | 10 | echoserver-node-18: 11 | container_name: ${COMPOSE_PROJECT_NAME:?}-echoserver-node-18 12 | image: ghcr.io/h4l/v8serialize/echoserver:${ECHOSERVER_VERSION:?}-node-18 13 | networks: [v8serialize] 14 | labels: 15 | com.github.h4l.v8serialize.echoserver: "true" 16 | 17 | echoserver-deno: 18 | container_name: ${COMPOSE_PROJECT_NAME:?}-echoserver-deno 19 | image: ghcr.io/h4l/v8serialize/echoserver:${ECHOSERVER_VERSION:?}-deno-1.46.1 20 | networks: [v8serialize] 21 | labels: 22 | com.github.h4l.v8serialize.echoserver: "true" 23 | 24 | integration_test: 25 | profiles: [integration] 26 | image: ${V8SERIALIZE_DEV_IMAGE:-v8serialize-dev:py312} 27 | networks: [v8serialize] 28 | environment: 29 | V8SERIALIZE_ECHOSERVERS: "${V8SERIALIZE_ECHOSERVERS:-__required_but_not_set__}" 30 | depends_on: 31 | echoserver-node-22: 32 | condition: service_healthy 33 | echoserver-node-18: 34 | condition: service_healthy 35 | echoserver-deno: 36 | condition: service_healthy 37 | command: pytest -m integration -vv 38 | volumes: 39 | - .:${WORKSPACE_MOUNT_PATH:-/workspace} 40 | working_dir: /workspace 41 | 42 | networks: 43 | v8serialize: 44 | external: ${V8SERIALIZE_NETWORK_EXTERNAL:-false} 45 | name: ${V8SERIALIZE_NETWORK:-v8serialize} 46 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | /.quarto/ 2 | 3 | # Generated by quarto 4 | /_site 5 | 6 | # Generated by quartodoc 7 | /reference 8 | /_inv 9 | /objects.json 10 | /_sidebar.yml 11 | -------------------------------------------------------------------------------- /docs/_extensions/machow/interlinks/.gitignore: -------------------------------------------------------------------------------- 1 | *.html 2 | *.pdf 3 | *_files/ 4 | -------------------------------------------------------------------------------- /docs/_extensions/machow/interlinks/_extension.yml: -------------------------------------------------------------------------------- 1 | title: Interlinks 2 | author: Michael Chow 3 | version: 1.1.0 4 | quarto-required: ">=1.2.0" 5 | contributes: 6 | filters: 7 | - interlinks.lua 8 | -------------------------------------------------------------------------------- /docs/_extensions/mcanouil/iconify/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Mickaël Canouil 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/_extensions/mcanouil/iconify/_extension.yml: -------------------------------------------------------------------------------- 1 | title: Iconify support 2 | author: Mickaël Canouil 3 | version: 2.1.2 4 | quarto-required: ">=1.2.280" 5 | contributes: 6 | shortcodes: 7 | - iconify.lua 8 | -------------------------------------------------------------------------------- /docs/_extensions/mcanouil/iconify/iconify.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | # MIT License 3 | # 4 | # Copyright (c) Mickaël Canouil 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | 13 | # The above copyright notice and this permission notice shall be included in all 14 | # copies or substantial portions of the Software. 15 | 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | # SOFTWARE. 23 | ]] 24 | 25 | local function ensure_html_deps() 26 | quarto.doc.add_html_dependency({ 27 | name = 'iconify', 28 | version = '2.1.0', 29 | scripts = {"iconify-icon.min.js"} 30 | }) 31 | end 32 | 33 | local function is_empty(s) 34 | return s == nil or s == '' 35 | end 36 | 37 | local function is_valid_size(size) 38 | if is_empty(size) then 39 | return '' 40 | end 41 | local size_table = { 42 | ["tiny"] = "0.5em", 43 | ["scriptsize"] = "0.7em", 44 | ["footnotesize"] = "0.8em", 45 | ["small"] = "0.9em", 46 | ["normalsize"] = "1em", 47 | ["large"] = "1.2em", 48 | ["Large"] = "1.5em", 49 | ["LARGE"] = "1.75em", 50 | ["huge"] = "2em", 51 | ["Huge"] = "2.5em", 52 | ["1x"] = "1em", 53 | ["2x"] = "2em", 54 | ["3x"] = "3em", 55 | ["4x"] = "4em", 56 | ["5x"] = "5em", 57 | ["6x"] = "6em", 58 | ["7x"] = "7em", 59 | ["8x"] = "8em", 60 | ["9x"] = "9em", 61 | ["10x"] = "10em", 62 | ["2xs"] = "0.625em", 63 | ["xs"] = "0.75em", 64 | ["sm"] = "0.875em", 65 | ["lg"] = "1.25em", 66 | ["xl"] = "1.5em", 67 | ["2xl"] = "2em" 68 | } 69 | for key, value in pairs(size_table) do 70 | if key == size then 71 | return 'font-size: ' .. value .. ';' 72 | end 73 | end 74 | return 'font-size: ' .. size .. ';' 75 | end 76 | 77 | return { 78 | ["iconify"] = function(args, kwargs) 79 | -- detect html (excluding epub which won't handle fa) 80 | if quarto.doc.is_format("html:js") then 81 | ensure_html_deps() 82 | local icon = pandoc.utils.stringify(args[1]) 83 | local set = "fluent-emoji" 84 | 85 | if #args > 1 and string.find(pandoc.utils.stringify(args[2]), ":") then 86 | quarto.log.warning( 87 | 'Use "set:icon" or "set icon" syntax, not both! ' .. 88 | 'Using "set:icon" syntax and discarding first argument!' 89 | ) 90 | icon = pandoc.utils.stringify(args[2]) 91 | end 92 | 93 | if string.find(icon, ":") then 94 | set = string.sub(icon, 1, string.find(icon, ":") - 1) 95 | icon = string.sub(icon, string.find(icon, ":") + 1) 96 | elseif #args > 1 then 97 | set = icon 98 | icon = pandoc.utils.stringify(args[2]) 99 | end 100 | 101 | local attributes = ' icon="' .. set .. ':' .. icon .. '"' 102 | local default_label = 'Icon ' .. icon .. ' from ' .. set .. ' Iconify.design set.' 103 | 104 | local size = is_valid_size(pandoc.utils.stringify(kwargs["size"])) 105 | if not is_empty(size) then 106 | attributes = attributes .. ' style="' .. size .. '"' 107 | end 108 | 109 | local aria_label = pandoc.utils.stringify(kwargs["label"]) 110 | if is_empty(aria_label) then 111 | aria_label = ' aria-label="' .. default_label .. '"' 112 | else 113 | aria_label = ' aria-label="' .. aria_label .. '"' 114 | end 115 | 116 | local title = pandoc.utils.stringify(kwargs["title"]) 117 | if is_empty(title) then 118 | title = ' title="' .. default_label .. '"' 119 | else 120 | title = ' title="' .. title .. '"' 121 | end 122 | 123 | attributes = attributes .. aria_label .. title 124 | 125 | local width = pandoc.utils.stringify(kwargs["width"]) 126 | if not is_empty(width) and is_empty(size) then 127 | attributes = attributes .. ' width="' .. width .. '"' 128 | end 129 | local height = pandoc.utils.stringify(kwargs["height"]) 130 | if not is_empty(height) and is_empty(size) then 131 | attributes = attributes .. ' height="' .. height .. '"' 132 | end 133 | local flip = pandoc.utils.stringify(kwargs["flip"]) 134 | if not is_empty(flip) then 135 | attributes = attributes .. ' flip="' .. flip.. '"' 136 | end 137 | local rotate = pandoc.utils.stringify(kwargs["rotate"]) 138 | if not is_empty(rotate) then 139 | attributes = attributes .. ' rotate="' .. rotate .. '"' 140 | end 141 | 142 | local inline = pandoc.utils.stringify(kwargs["inline"]) 143 | if is_empty(inline) or inline ~= "false" then 144 | attributes = ' inline ' .. attributes 145 | end 146 | 147 | 148 | return pandoc.RawInline( 149 | 'html', 150 | '' 151 | ) 152 | else 153 | return pandoc.Null() 154 | end 155 | end 156 | } 157 | -------------------------------------------------------------------------------- /docs/explanation/v8_serialization_format.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Introduction to the V8 serialization format" 3 | --- 4 | 5 | The V8 JavaScript Engine is used by several well-known JavaScript platforms, like Node.js and Deno. V8 has built-in support for serializing JavaScript values to binary data, and deserializing them back to values. 6 | 7 | The capabilities of the serialization format are compatible with the Web Platform's [Structured Clone] algorithm, which is used to send JavaScript values between contexts, such as [`postMessage()`] to send values to background workers, and to persistently store JavaScript values as data in [IndexedDB]. 8 | 9 | [Structured Clone]: https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm 10 | [`postMessage()`]: https://developer.mozilla.org/en-US/docs/Web/API/Worker/postMessage 11 | [IndexedDB]: https://developer.mozilla.org/en-US/docs/Glossary/IndexedDB 12 | 13 | The appealing thing about this from a JavaScript developer's point of view is that many common JavaScript types can be transparently moved between contexts without needing to manually serialize and deserialize them. For example, `Date`, `Map` and `Set`, as well as primitives like `undefined` are transparently handled, whereas with JSON these types need to be explicitly converted to plain objects, `null`, etc. 14 | 15 | V8's code base states that its value serialization format is used to persist data, and requires that changes to its own code [maintain backwards compatability][backwards compatability], meaning that newer V8 versions must be able to read values serialized by older versions, but values serialized by newer versions are not required to be readable by older versions. 16 | 17 | [backwards compatability]: https://github.com/v8/v8/blob/d49151b/src/objects/value-serializer.cc#L51 18 | 19 | ## Users of the format 20 | 21 | * Node.js [exposes V8 value serialization to users via its `v8` module][node-v8] as `serialize()`/`deserialize()` and `v8.Serializer`/`v8.Deserializer`. 22 | * Deno [also implements the `node:v8` module] as part of its Node.js compatibility. 23 | * Deno uses the format to store JavaScript values in its [Deno KV database](https://deno.com/kv). 24 | * A few projects on GitHub aim to implement the format in plain JavaScript, to allow it to be used in browsers or other non-V8 runtimes. For example, [worker-tools/v8-value-serializer](https://github.com/worker-tools/v8-value-serializer) 25 | 26 | [node-v8]: https://nodejs.org/docs/latest/api/v8.html#serialization-api 27 | [deno-v8]: https://docs.deno.com/api/node/v8/ 28 | 29 | ## Capabilities 30 | 31 | The format is able to represent the JavaScript types that cover typical data used in programs: 32 | 33 | - Array 34 | - ArrayBuffer 35 | - Boolean 36 | - DataView 37 | - Date 38 | - Error types (with a fixed set of error names). 39 | - Map 40 | - Number 41 | - Object (plain objects only — prototypes, functions and get/set properties are stripped) 42 | - Primitive (including BigInt, but not Symbol) 43 | - RegExp 44 | - Set 45 | - String 46 | - TypedArray 47 | 48 | The format supports reference cycles, so complex object structures with inter-linked objects are not a problem. It supports multiple references to the same value, so strings can be de-duplicated. It also supports JavaScript's sparse arrays. 49 | 50 | These features make it largely transparant for JavaScript to send and receive serialized values. 51 | 52 | ## Considerations 53 | 54 | ### JavaScript details 55 | 56 | Although the format is very easy to use from JavaScript, using it outside JavaScript is complicated by the format's close ties to the JavaScript data types. Implementations must deal with JavaScript features like sparse arrays, support for mixing integer and string properties in arrays and objects, the object-identity equality used by Map and Set, and the wide variety of binary data representations (ArrayBuffer, DataView and all the TypedArray subclases). 57 | 58 | These aspects complicate interoperability with non-JavaScript languages, and mean that the format makes sense in a context where interoperability with JavaScript is an important requirement. For example, when maximising ease of use for the JavaScript side of an application is a priority. For general purpose data interchange, a simpler format like JSON, or a format designed for cross-language support like Protocol Buffers would be more suitable. 59 | 60 | ### Stability 61 | 62 | Although the V8 source code now clearly describes its backwards compatibility requirement, in the past the format has mistakenly broken backwards compatibility. An example of this was [support for Error objects that self-reference in their cause][CircularErrorCause]. Previously V8 failed to deserialize Error objects that self-referenced, but in fixing this the the Error deserialization logic was changed in such a way as to not support reading errors serialized by the previous implementation. 63 | 64 | Judging by the history of changes made to V8's serialization code, the backwards compatability requirement became more clearly and strongly emphasised since this occured. 65 | 66 | [CircularErrorCause]: `v8serialize.SerializationFeature.CircularErrorCause` 67 | 68 | 69 | ### Endianness 70 | 71 | V8 defines the format as using the native byte order of the computer V8 runs on. In theory this could pose interoperability problems. In practice serialized data is always little-endian, as big-endian devices are not common. 72 | -------------------------------------------------------------------------------- /docs/howto/install.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "How to install v8serialize" 3 | --- 4 | 5 | v8serialize is [published on PyPI as `v8serialize`][v8serialize-pypi] and supports Python 3.9 and above. 6 | 7 | To install with pip, run: 8 | 9 | ```bash 10 | pip install v8serialize 11 | ``` 12 | 13 | [v8serialize-pypi]: https://pypi.org/project/v8serialize/ 14 | -------------------------------------------------------------------------------- /docs/howto/use_at_a_glance.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "How to use at a glance" 3 | number-sections: true 4 | --- 5 | 6 | For more details, see [the tutorials](../tutorials/js_to_py.qmd). 7 | 8 | ## Serialize with Python 9 | ```{python} 10 | from base64 import b64encode 11 | from datetime import datetime, UTC 12 | import re 13 | 14 | from v8serialize import dumps 15 | from v8serialize.jstypes import JSObject, JSArray, JSUndefined 16 | 17 | serialized = dumps( 18 | [ 19 | "strings 🧵🧶🪡", 20 | 123, 21 | None, 22 | JSUndefined, 23 | JSArray([(0, "a"), (1, "b"), (123456789, "sparse")]), 24 | JSObject({"msg": "Hi"}), 25 | b"\xc0\xff\xee", 26 | 2**128, 27 | {"maps": True}, 28 | {"sets", "yes"}, 29 | re.compile(r"^\w+$"), 30 | datetime(2024, 1, 1, tzinfo=UTC), 31 | ] 32 | ) 33 | 34 | print(b64encode(serialized).decode()) 35 | ``` 36 | 37 | ## Deserialize with Python 38 | ```{python} 39 | from base64 import b64decode 40 | from v8serialize import loads 41 | 42 | # The output of the JavaScript example 43 | serialized = b64decode( 44 | "/w9BDGMccwB0AHIAaQBuAGcAcwAgAD7Y9d0+2PbdPtih3kn2ATBfYZaa7zpJACIBYUkCIgFiSaq03nUiBnNwYXJzZUADlprvOm8iA21zZyICSGl7AUIDwP/uWjAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAA7IgRtYXBzVDoCJyIDeWVzIgRzZXRzLAJSIgVeXHcrJIACRAAAQB8lzHhCJAAM" 45 | ) 46 | print(loads(serialized)) 47 | ``` 48 | Output: 49 | ``` 50 | JSArray([ 51 | 'strings 🧵🧶🪡', 52 | 123, 53 | None, 54 | JSUndefined, 55 | JSArray({ 56 | 0: 'a', 57 | 1: 'b', 58 | 123456789: 'sparse', 59 | }), 60 | JSObject(msg='Hi'), 61 | JSArrayBuffer(b'\xc0\xff\xee'), 62 | 340282366920938463463374607431768211456, 63 | JSMap({ 64 | 'maps': True, 65 | }), 66 | JSSet([ 67 | 'yes', 68 | 'sets', 69 | ]), 70 | JSRegExp(source='^\\w+$', flags=), 71 | datetime.datetime(2024, 1, 1, 0, 0), 72 | ]) 73 | ``` 74 | 75 | 76 | ## Serialize with Node.js / Deno 77 | 78 | ```javascript 79 | import * as v8 from 'node:v8'; 80 | 81 | const sparseArray = ['a', 'b']; 82 | sparseArray[123456789] = 'sparse'; 83 | 84 | const buffer = v8.serialize([ 85 | 'strings 🧵🧶🪡', 86 | 123, 87 | null, 88 | undefined, 89 | sparseArray, 90 | {msg: 'Hi'}, 91 | Uint8Array.from([0xc0, 0xff, 0xee]).buffer, 92 | 2n**128n, 93 | new Map([['maps', true]]), 94 | new Set(['yes', 'sets']), 95 | /^\w+$/v, 96 | new Date(Date.UTC(2024, 0, 1)), 97 | ]); 98 | 99 | console.log(buffer.toString('base64')); 100 | ``` 101 | ``` 102 | /w9BDGMccwB0AHIAaQBuAGcAcwAgAD7Y9d0+2PbdPtih3kn2ATBfYZaa7zpJACIBYUkCIgFiSaq03nUiBnNwYXJzZUADlprvOm8iA21zZyICSGl7AUIDwP/uWjAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAA7IgRtYXBzVDoCJyIDeWVzIgRzZXRzLAJSIgVeXHcrJIACRAAAQB8lzHhCJAAM 103 | ``` 104 | 105 | ## Deserialize with Node.js / Deno 106 | 107 | ```javascript 108 | import * as v8 from 'node:v8'; 109 | 110 | // The output of the Python example 111 | const buffer = Buffer.from('/w9BDFMUc3RyaW5ncyDwn6e18J+ntvCfqqFVezBfYZaa7zpVAFMBYVUBUwFiVZWa7zpTBnNwYXJzZUADlprvOm9TA21zZ1MCSGl7AUIDwP/uWiIAAAAAAAAAAAAAAAAAAAAAATtTBG1hcHNUOgInUwN5ZXNTBHNldHMsAlJTBV5cdyskgAJEAABAHyXMeEIkAAw=', 'base64'); 112 | console.log(v8.deserialize(buffer)); 113 | ``` 114 | ``` 115 | [ 116 | 'strings 🧵🧶🪡', 117 | 123, 118 | null, 119 | undefined, 120 | [ 'a', 'b', <123456787 empty items>, 'sparse' ], 121 | { msg: 'Hi' }, 122 | ArrayBuffer { [Uint8Contents]: , byteLength: 3 }, 123 | 340282366920938463463374607431768211456n, 124 | Map(1) { 'maps' => true }, 125 | Set(2) { 'yes', 'sets' }, 126 | /^\w+$/v, 127 | 2024-01-01T00:00:00.000Z 128 | ] 129 | ``` 130 | -------------------------------------------------------------------------------- /docs/index.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: v8serialize 3 | description: "Read & write JavaScript values in [V8 serialization format](./explanation/v8_serialization_format.qmd) with Python." 4 | image: v8serialize_logo.svg 5 | image-alt: The v8serialize logo. Monochome. Large "V8" and smaller "serialize" in a handwritten style, with the 8 stylized to look like a snake. 6 | about: 7 | template: jolla 8 | links: 9 | - icon: github 10 | text: GitHub 11 | href: https://github.com/h4l/v8serialize/ 12 | - text: "{{< iconify file-icons:pypi >}} PyPI" 13 | href: https://pypi.org/project/v8serialize/ 14 | --- 15 | 16 | ```bash 17 | pip install v8serialize 18 | ``` 19 | 20 | -------------------------------------------------------------------------------- /docs/styles.css: -------------------------------------------------------------------------------- 1 | /* Get rid of the circle clip on the about template image used on the homepage. */ 2 | div.quarto-about-jolla img.round { 3 | border-radius: 0; 4 | } 5 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "v8serialize" 3 | version = "0.1.0" 4 | description = "Read & write JavaScript values from Python with the V8 serialization format." 5 | authors = ["Hal Blackburn "] 6 | license = "MIT" 7 | readme = "README.md" 8 | 9 | [tool.poetry.dependencies] 10 | python = "^3.9" 11 | packaging = ">=14.5" # this version introduces the VERSION_PATTERN constant 12 | 13 | [tool.poetry.group.dev.dependencies] 14 | exceptiongroup = { version = "^1", python = "<3.11" } 15 | hypothesis = "^6.108.8" 16 | # pytest must be ^7 because pytest-insta 0.2 requires pytest <8 17 | pytest = "^7" 18 | mypy = "^1.11.1" 19 | frozendict = "^2.4.4" 20 | pytest-cov = "^6.0.0" 21 | # pytest-insta must be ^0.2 because 0.3 requires python 3.10 22 | pytest-insta = "^0.2.0" 23 | httpx = "^0.28.1" 24 | typing-extensions = "^4.12.2" 25 | pytest-xdist = "^3.6.1" 26 | ruff = "^0.8.4" 27 | 28 | 29 | [tool.poetry.group.docs.dependencies] 30 | quartodoc = "^0.9.1" 31 | jupyter = "^1.1.1" 32 | 33 | 34 | [build-system] 35 | requires = ["poetry-core"] 36 | build-backend = "poetry.core.masonry.api" 37 | 38 | [tool.mypy] 39 | strict = true 40 | 41 | [tool.coverage.report] 42 | exclude_also = ["if TYPE_CHECKING:"] 43 | 44 | [tool.pytest.ini_options] 45 | addopts = [ 46 | "-n", 47 | "auto", 48 | "--strict-markers", 49 | "-m", 50 | "not integration", 51 | "--doctest-modules", 52 | ] 53 | markers = ["integration: Integration tests"] 54 | 55 | [tool.ruff.lint] 56 | select = [ 57 | "B", # flake8-bugbear 58 | "D", # pydocstyle 59 | "E", 60 | "F", 61 | "FA", # flake8-future-annotations 62 | "PYI", # flake8-pyi 63 | "I", # isort 64 | ] 65 | ignore = [ 66 | "PYI041", # prefer "float" over "int | float". They are not the same! 67 | "D100", # "Missing docstring in public module" — not everything is documented yet 68 | "D101", # "Missing docstring in public class" — not everything is documented yet 69 | "D102", # "Missing docstring in public method" — not everything is documented yet 70 | "D103", # "Missing docstring in public function" — not everything is documented yet 71 | "D104", # "Missing docstring in public package" — not everything is documented yet 72 | "D105", # "Missing docstring in magic method" — not everything is documented yet 73 | ] 74 | 75 | [tool.ruff.lint.pydocstyle] 76 | convention = "numpy" 77 | 78 | [tool.ruff.lint.isort] 79 | extra-standard-library = ["typing_extensions", "_typeshed"] 80 | -------------------------------------------------------------------------------- /scripts/ci-build-docs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | cd "$(dirname "${BASH_SOURCE[0]:?}")/../docs" 5 | 6 | rm -rf _site 7 | quartodoc interlinks 8 | quartodoc build 9 | quarto render 10 | -------------------------------------------------------------------------------- /scripts/ci-publish-docs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -xeuo pipefail 3 | 4 | cd "$(dirname "${BASH_SOURCE[0]:?}")/.." 5 | 6 | built_site_dir="$(pwd)/docs/_site" 7 | gh_pages_dir=$(mktemp -d) 8 | git_head_sha=$(git rev-parse --short HEAD) 9 | publish_prefix_dir=en/latest 10 | 11 | if git rev-parse --verify gh-pages >/dev/null 2>&1; then 12 | git worktree add "${gh_pages_dir:?}" gh-pages 13 | else 14 | git worktree add "${gh_pages_dir:?}" origin/gh-pages -b gh-pages 15 | fi 16 | 17 | scripts/ci-build-docs.sh 18 | cd "${gh_pages_dir:?}" 19 | git rm -r "${publish_prefix_dir:?}" 20 | mkdir -p "$(dirname "${publish_prefix_dir:?}")" 21 | cp -a "${built_site_dir:?}" "${publish_prefix_dir:?}" 22 | git add "${publish_prefix_dir:?}" 23 | 24 | if [[ "$(git status --porcelain)" != "" ]]; then 25 | if ! git config user.email >/dev/null; then 26 | git config user.email "${GIT_AUTHOR_EMAIL:?}" 27 | fi 28 | if ! git config user.name >/dev/null; then 29 | git config user.name "${GIT_AUTHOR_NAME:?}" 30 | fi 31 | 32 | git commit -m "docs: update ${publish_prefix_dir@Q} docs built at ${git_head_sha:?}" 33 | git push origin gh-pages 34 | else 35 | echo "No changes in ${publish_prefix_dir@Q} after rebuilding docs at ${git_head_sha:?}" 36 | fi 37 | 38 | git worktree remove "${gh_pages_dir:?}" 39 | -------------------------------------------------------------------------------- /scripts/list-echoservers.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | # Output a JSON object listing URLs of running echoserver containers 5 | # 6 | # e.g. '{"echoserver-node-18":"http://v8serialize-echoserver-node-18:8000/"}' 7 | # 8 | # Containers must be labelled as indicated by --filter. 9 | docker container ls \ 10 | --filter=label=com.github.h4l.v8serialize.echoserver=true \ 11 | --format=json \ 12 | | jq -sce '[ 13 | .[] 14 | | (.Names | (capture("^v8serialize-(?.*)$") | .name) // .) as $name 15 | | "http://\(.Names):8000/" as $url 16 | | { key: $name, value: $url } 17 | ] | from_entries' 18 | -------------------------------------------------------------------------------- /src/v8serialize/__init__.py: -------------------------------------------------------------------------------- 1 | """The main public API of v8serialize.""" 2 | 3 | from __future__ import annotations 4 | 5 | from v8serialize._errors import DecodeV8SerializeError as DecodeV8SerializeError 6 | from v8serialize._errors import JSRegExpV8SerializeError as JSRegExpV8SerializeError 7 | from v8serialize._errors import ( 8 | UnhandledTagDecodeV8SerializeError as UnhandledTagDecodeV8SerializeError, 9 | ) 10 | from v8serialize._errors import V8SerializeError as V8SerializeError 11 | from v8serialize._pycompat.typing import Buffer as Buffer 12 | from v8serialize._pycompat.typing import BufferSequence as BufferSequence 13 | from v8serialize._pycompat.typing import ReadableBinary as ReadableBinary 14 | from v8serialize._references import ( 15 | IllegalCyclicReferenceV8SerializeError as IllegalCyclicReferenceV8SerializeError, 16 | ) 17 | from v8serialize._typing import SparseMutableSequence as SparseMutableSequence 18 | from v8serialize._typing import SparseSequence as SparseSequence 19 | from v8serialize.constants import JSErrorName as JSErrorName 20 | from v8serialize.constants import JSRegExpFlag as JSRegExpFlag 21 | from v8serialize.constants import SerializationFeature as SerializationFeature 22 | from v8serialize.constants import SymbolicVersion as SymbolicVersion 23 | from v8serialize.decode import Decoder as Decoder 24 | from v8serialize.decode import DecodeStep as DecodeStep 25 | from v8serialize.decode import DecodeStepFn as DecodeStepFn 26 | from v8serialize.decode import DecodeStepObject as DecodeStepObject 27 | from v8serialize.decode import TagReader as TagReader 28 | from v8serialize.decode import default_decode_steps as default_decode_steps 29 | from v8serialize.decode import loads as loads 30 | from v8serialize.encode import Encoder as Encoder 31 | from v8serialize.encode import EncodeV8SerializeError as EncodeV8SerializeError 32 | from v8serialize.encode import ( 33 | FeatureNotEnabledEncodeV8SerializeError as FeatureNotEnabledEncodeV8SerializeError, 34 | ) 35 | from v8serialize.encode import ( 36 | UnhandledValueEncodeV8SerializeError as UnhandledValueEncodeV8SerializeError, 37 | ) 38 | from v8serialize.encode import default_encode_steps as default_encode_steps 39 | from v8serialize.encode import dumps as dumps 40 | -------------------------------------------------------------------------------- /src/v8serialize/_decorators.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import singledispatchmethod as _singledispatchmethod 4 | from typing import TYPE_CHECKING, Any, Callable, Generic, TypeVar, overload 5 | 6 | if TYPE_CHECKING: 7 | from functools import _SingleDispatchCallable 8 | from typing_extensions import Concatenate, ParamSpec 9 | 10 | T = TypeVar("T") 11 | F = TypeVar("F", bound=Callable[..., Any]) 12 | 13 | 14 | # typeshed's singledispatchmethod type annotations don't keep the function's 15 | # argument types. We re-define its types to fix that. 16 | if TYPE_CHECKING: 17 | P = ParamSpec("P") 18 | S = TypeVar("S") # self / class 19 | D = TypeVar("D") # default dispatch type 20 | D1 = TypeVar("D1") # overload dispatch type 21 | 22 | class singledispatchmethod(Generic[S, D, P, T]): 23 | dispatcher: _SingleDispatchCallable[T] 24 | func: Callable[Concatenate[S, D, P], T] 25 | 26 | def __init__(self, func: Callable[Concatenate[S, D, P], T]) -> None: ... 27 | 28 | @property 29 | def __isabstractmethod__(self) -> bool: ... 30 | 31 | # The register decorator can be used like @register to use the 1st arg's 32 | # type annotation as the dispatch type. 33 | @overload 34 | def register( 35 | self, cls: Callable[Concatenate[S, D1, P], T] 36 | ) -> Callable[Concatenate[S, D1, P], T]: ... 37 | 38 | # ... or with an explicit type, like @register(bool) 39 | @overload 40 | def register( 41 | self, 42 | cls: type, 43 | ) -> Callable[ 44 | [Callable[Concatenate[S, D1, P], T]], Callable[Concatenate[S, D1, P], T] 45 | ]: ... 46 | 47 | @overload 48 | def register( 49 | self, cls: type[Any], method: Callable[Concatenate[D1, P], T] 50 | ) -> Callable[Concatenate[D1, P], T]: ... 51 | 52 | def register(*args: Any, **kwargs: Any) -> Any: ... 53 | 54 | def __call__(self, value: D, *args: P.args, **kwargs: P.kwargs) -> T: ... 55 | 56 | else: 57 | singledispatchmethod = _singledispatchmethod 58 | -------------------------------------------------------------------------------- /src/v8serialize/_enums.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import FrozenInstanceError 4 | from typing import TypeVar 5 | 6 | TypeT = TypeVar("TypeT", bound=type) 7 | 8 | 9 | def frozen_setattr(cls: type, name: str, value: object) -> None: 10 | raise FrozenInstanceError(f"Cannot assign to field {name!r}") 11 | 12 | 13 | def frozen(cls: TypeT) -> TypeT: 14 | """Disable `__setattr__`, much like @dataclass(frozen=True).""" 15 | cls.__setattr__ = frozen_setattr # type: ignore[method-assign,assignment] 16 | return cls 17 | -------------------------------------------------------------------------------- /src/v8serialize/_errors.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, fields 4 | from typing import TYPE_CHECKING, cast 5 | 6 | from v8serialize._pycompat.dataclasses import slots_if310 7 | from v8serialize._pycompat.typing import ReadableBinary 8 | 9 | if TYPE_CHECKING: 10 | from v8serialize.constants import SerializationTag 11 | 12 | 13 | @dataclass(init=False) 14 | class V8SerializeError(Exception): 15 | """The base class that all v8serialize errors are subclasses of.""" 16 | 17 | if not TYPE_CHECKING: 18 | message: str # needed to have dataclass include message in the repr, etc 19 | 20 | def __init__(self, message: str, *args: object) -> None: 21 | super().__init__(message, *args) 22 | 23 | @property 24 | def message(self) -> str: 25 | return cast(str, self.args[0]) 26 | 27 | def __str__(self) -> str: 28 | field_values = [ 29 | (f.name, getattr(self, f.name)) for f in fields(self) if f.name != "message" 30 | ] 31 | values_fmt = ", ".join(f"{f}={v!r}" for (f, v) in field_values) 32 | 33 | if values_fmt: 34 | return f"{self.message}: {values_fmt}" 35 | return self.message 36 | 37 | 38 | # TODO: str/repr needs customising to abbreviate the data field 39 | @dataclass(init=False) 40 | class DecodeV8SerializeError(V8SerializeError, ValueError): 41 | position: int 42 | data: ReadableBinary 43 | 44 | def __init__( 45 | self, message: str, *args: object, position: int, data: ReadableBinary 46 | ) -> None: 47 | super().__init__(message, *args) 48 | self.position = position 49 | self.data = data 50 | 51 | 52 | @dataclass(init=False) 53 | class UnhandledTagDecodeV8SerializeError(DecodeV8SerializeError): 54 | """ 55 | No `TagReader` is able to handle a `SerializationTag`. 56 | 57 | Raised when attempting to deserialize a tag that no `TagReader` is able to 58 | handle (by reading the tag's data from the stream and representing the data 59 | as a Python object). 60 | """ 61 | 62 | if not TYPE_CHECKING: 63 | tag: SerializationTag 64 | 65 | def __init__( 66 | self, 67 | message: str, 68 | *args: object, 69 | tag: SerializationTag, 70 | position: int, 71 | data: ReadableBinary, 72 | ) -> None: 73 | super().__init__(message, tag, *args, position=position, data=data) 74 | 75 | @property 76 | def tag(self) -> SerializationTag: 77 | return cast("SerializationTag", self.args[1]) 78 | 79 | 80 | @dataclass(init=False, **slots_if310()) 81 | class NormalizedKeyError(KeyError): 82 | """A JSObject does not contain a property for the requested key. 83 | 84 | JSObjects store and look up integer keys differently from non-integer keys, 85 | so the actual key used in the lookup may not be the same as the same as the 86 | original, raw key. The `normalized_key` and `raw_key` properties hold both 87 | versions of the key. 88 | """ 89 | 90 | normalized_key: object 91 | raw_key: object 92 | 93 | def __init__(self, normalized_key: object, raw_key: object) -> None: 94 | self.normalized_key = normalized_key 95 | self.raw_key = raw_key 96 | super(NormalizedKeyError, self).__init__( 97 | f"{self.normalized_key!r} (normalized from {self.raw_key!r})" 98 | ) 99 | 100 | 101 | class JSRegExpV8SerializeError(V8SerializeError): 102 | pass 103 | -------------------------------------------------------------------------------- /src/v8serialize/_pycompat/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | -------------------------------------------------------------------------------- /src/v8serialize/_pycompat/builtins.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from typing import TYPE_CHECKING, TypeVar 5 | 6 | if TYPE_CHECKING: 7 | from typing_extensions import ParamSpec 8 | 9 | _P = ParamSpec("_P") 10 | _R_co = TypeVar("_R_co", covariant=True) 11 | 12 | # @staticmethod decorator is not callable in py3.9, you must reference it via 13 | # the class it's used in. We call it from within the class definition to define 14 | # hypothesis strategies, so we need to call it directly. 15 | if sys.version_info >= (3, 10): 16 | callable_staticmethod = staticmethod 17 | else: 18 | if TYPE_CHECKING: 19 | 20 | class callable_staticmethod(staticmethod[_P, _R_co]): 21 | def __call__(self, *args: _P.args, **kwargs: _P.kwargs) -> _R_co: ... 22 | 23 | else: 24 | 25 | class callable_staticmethod(staticmethod): 26 | def __call__(self, *args, **kwargs): 27 | return self.__func__(*args, **kwargs) 28 | -------------------------------------------------------------------------------- /src/v8serialize/_pycompat/dataclasses.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from dataclasses import FrozenInstanceError, dataclass 5 | from dataclasses import fields as dataclass_fields 6 | from typing import Literal, TypedDict 7 | 8 | 9 | class NoArg(TypedDict): 10 | pass 11 | 12 | 13 | class SlotsTrue(TypedDict): 14 | slots: Literal[True] 15 | 16 | 17 | if sys.version_info < (3, 10): 18 | 19 | def slots_if310() -> NoArg: 20 | return NoArg() 21 | 22 | else: 23 | 24 | def slots_if310() -> SlotsTrue: 25 | return SlotsTrue(slots=True) 26 | 27 | 28 | @dataclass 29 | class FrozenAfterInitDataclass: 30 | """A mixin for dataclasses that disallows changing fields after init. 31 | 32 | Fields can be set once and not again. This is an alternative to 33 | `@dataclass(frozen=True)` — it only freezes dataclass-managed fields, so it 34 | doesn't affect non-dataclass fields, such as typing.Generic's dunder fields. 35 | """ 36 | 37 | def __delattr__(self, name: str) -> None: 38 | if name in (f.name for f in dataclass_fields(self)): 39 | raise FrozenInstanceError(f"cannot delete field {name}") 40 | super(FrozenAfterInitDataclass, self).__delattr__(name) 41 | 42 | def __setattr__(self, name: str, value: object) -> None: 43 | if name in (f.name for f in dataclass_fields(self)): 44 | if hasattr(self, name): 45 | raise FrozenInstanceError(f"cannot set {name!r}") 46 | super(FrozenAfterInitDataclass, self).__setattr__(name, value) 47 | -------------------------------------------------------------------------------- /src/v8serialize/_pycompat/enum.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from enum import EnumMeta, Flag, IntFlag 5 | from typing import TYPE_CHECKING, Iterator 6 | 7 | if TYPE_CHECKING: 8 | from typing_extensions import Self 9 | 10 | 11 | class ContainsValueEnumMeta(EnumMeta): 12 | def __contains__(cls, value: object) -> bool: 13 | if value in cls._value2member_map_: 14 | return True 15 | return False 16 | 17 | 18 | if sys.version_info < (3, 12): 19 | from enum import Enum 20 | 21 | class StrEnum(str, Enum, metaclass=ContainsValueEnumMeta): 22 | def __str__(self) -> str: 23 | assert self._value_ is not self 24 | return str(self._value_) 25 | 26 | else: 27 | from enum import StrEnum as StrEnum # noqa: F401 # re-export 28 | 29 | 30 | if sys.version_info < (3, 11): 31 | # Even though IntFlag is just (int, Flag), MyPy errors if we subclass 32 | # (IterableFlag, IntFlag), so we have to redundantly define this for both 33 | # types. 34 | class IterableFlag(Flag): 35 | def __iter__(self) -> Iterator[Self]: 36 | for flag in type(self): 37 | if self & flag: 38 | yield flag 39 | 40 | class IterableIntFlag(IntFlag): 41 | def __iter__(self) -> Iterator[Self]: 42 | for flag in type(self): 43 | if self & flag: 44 | yield flag 45 | 46 | else: 47 | 48 | class IterableFlag(Flag): 49 | pass 50 | 51 | class IterableIntFlag(IntFlag): 52 | pass 53 | 54 | 55 | # In py3.12 you can do things like `42 in SomeIntEnum`, returning True/False. 56 | # In previous versions you get a TypeError. 57 | if sys.version_info < (3, 12): 58 | from enum import IntEnum as _IntEnum 59 | 60 | class IntEnum(_IntEnum, metaclass=ContainsValueEnumMeta): 61 | def __str__(self) -> str: 62 | assert self._value_ is not self 63 | return str(self._value_) 64 | 65 | else: 66 | from enum import IntEnum as IntEnum # noqa: F401 # re-export 67 | -------------------------------------------------------------------------------- /src/v8serialize/_pycompat/exceptions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Protocol, cast 4 | 5 | if TYPE_CHECKING: 6 | from typing_extensions import TypeGuard 7 | 8 | 9 | class Notes(Protocol): 10 | __notes__: list[str] 11 | 12 | 13 | def has_notes(exc: BaseException) -> TypeGuard[Notes]: 14 | return isinstance(getattr(exc, "__notes__", None), list) 15 | 16 | 17 | def add_note(exc: BaseException, note: str) -> None: 18 | if not isinstance(note, str): 19 | raise TypeError("note must be a str") 20 | if not has_notes(exc): 21 | exc_with_notes = cast(Notes, exc) 22 | exc_with_notes.__notes__ = notes = [] 23 | else: 24 | notes = exc.__notes__ 25 | notes.append(note) 26 | -------------------------------------------------------------------------------- /src/v8serialize/_pycompat/inspect.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import enum 4 | import sys 5 | 6 | # BufferFlags does not exist until 3.12 7 | if sys.version_info >= (3, 12): 8 | from inspect import BufferFlags as BufferFlags 9 | else: 10 | 11 | class BufferFlags(enum.IntFlag): 12 | SIMPLE = 0 13 | -------------------------------------------------------------------------------- /src/v8serialize/_pycompat/re.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from re import ASCII as ASCII 4 | from re import DOTALL as DOTALL 5 | from re import IGNORECASE as IGNORECASE 6 | from re import LOCALE as LOCALE 7 | from re import MULTILINE as MULTILINE 8 | from re import UNICODE as UNICODE 9 | from re import VERBOSE as VERBOSE 10 | 11 | from v8serialize._pycompat.enum import IterableIntFlag 12 | 13 | 14 | # 3.9 doesn't define NOFLAG, and default IntFlag is not iterable in 3.9. 15 | class RegexFlag(IterableIntFlag): 16 | NOFLAG = 0 17 | ASCII = ASCII 18 | DOTALL = DOTALL 19 | IGNORECASE = IGNORECASE 20 | LOCALE = LOCALE 21 | MULTILINE = MULTILINE 22 | UNICODE = UNICODE 23 | VERBOSE = VERBOSE 24 | -------------------------------------------------------------------------------- /src/v8serialize/_pycompat/types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | 5 | if sys.version_info >= (3, 10): 6 | from types import NoneType as NoneType 7 | else: 8 | NoneType = type(None) 9 | -------------------------------------------------------------------------------- /src/v8serialize/_pycompat/typing.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from array import array 4 | from typing import TYPE_CHECKING, Sequence, Union, overload 5 | 6 | from v8serialize._pycompat.inspect import BufferFlags 7 | 8 | if TYPE_CHECKING: 9 | from typing_extensions import Buffer as Buffer 10 | from typing_extensions import TypeAlias, TypeGuard 11 | 12 | class BufferSequence(Sequence[int], Buffer): 13 | """ 14 | Binary data, like `bytes`, `bytearray`, `array.array` and `memoryview`. 15 | 16 | Essentially a `Sequence[int]` that's also a `Buffer`. 17 | """ 18 | 19 | @overload # type: ignore[override] 20 | def __getitem__(self, index: slice, /) -> BufferSequence: ... 21 | 22 | @overload 23 | def __getitem__(self, index: int, /) -> int: ... 24 | 25 | def __getitem__(self, index: int | slice, /) -> int | BufferSequence: ... 26 | 27 | else: 28 | try: 29 | from collections.abc import Buffer 30 | except ImportError: 31 | from abc import ABC, abstractmethod 32 | 33 | class Buffer(ABC): 34 | """An alias of [`collections.abc.Buffer`](`collections.abc.Buffer`).""" 35 | 36 | @abstractmethod 37 | def __buffer__(self, flags: int) -> memoryview: ... 38 | 39 | class BufferSequence(Sequence, Buffer): 40 | """ 41 | A `Sequence[int]` that's also a `Buffer`. 42 | 43 | Essentially binary data, like `bytes`, `bytearray`, `array.array` and 44 | `memoryview`. 45 | """ 46 | 47 | 48 | ReadableBinary: TypeAlias = Union[ 49 | "bytes | bytearray | memoryview | array[int] | BufferSequence" 50 | ] 51 | """ 52 | Binary data such as `bytes`, `bytearray`, `array.array` and `memoryview`. 53 | 54 | Can also be any [`BufferSequence`](`v8serialize.BufferSequence`). 55 | """ 56 | 57 | 58 | def is_readable_binary(buffer: Buffer) -> TypeGuard[ReadableBinary]: 59 | """Return True if a binary value can be read without wrapping in a `memoryview`.""" 60 | return isinstance(buffer, (bytes, bytearray, memoryview, array, Sequence)) 61 | 62 | 63 | def get_buffer( 64 | buffer: Buffer, flags: int | BufferFlags = BufferFlags.SIMPLE 65 | ) -> memoryview: 66 | """Get a bytes-format memoryview of a value supporting the Buffer protocol. 67 | 68 | Returns 69 | ------- 70 | : 71 | A memoryview with itemsize 1, 1 dimension and `B` (uint8) format. 72 | """ 73 | # Python buffer protocol API only available from Python 3.12 74 | if hasattr(buffer, "__buffer__"): 75 | buf = buffer.__buffer__(flags) 76 | else: 77 | buf = memoryview(buffer) 78 | if not (buf.format == "B" and buf.ndim == 1 and buf.itemsize == 1): 79 | buf = buf.cast("B") 80 | return buf 81 | -------------------------------------------------------------------------------- /src/v8serialize/_recursive_eq.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from _thread import get_ident 4 | from functools import wraps 5 | from typing import Final, TypeVar 6 | 7 | _RUNNING_EQ_KEYS: Final[set[tuple[int, int]]] = set() 8 | 9 | _T = TypeVar("_T") 10 | 11 | 12 | def recursive_eq(cls: type[_T]) -> type[_T]: 13 | """Allow `==` of classes with self-referencing fields. 14 | 15 | This class decorator wraps `__eq__` to detect and short-circuit `__eq__` 16 | being called on an object as a result of itself checking its own equality. 17 | This allows objects that contain direct or indirect references to themself 18 | to compare with `==`, without infinite recursive calls until stack overflow. 19 | 20 | Self-referential objects are equal to others if both sides use the same 21 | object-identity structure (according to `is` / `id()`) and their own 22 | `__eq__` is `True`. 23 | 24 | For example `a -> b -> a` can be equal to `a' -> b' -> a'`, but will not be 25 | equal to `a' -> b' -> A' -> B' -> a'` (assuming `a` would be equal to `A` in 26 | isolation). In the first case, the identity structure has two nodes 27 | repeating, in the second there are four nodes, so the object-identity 28 | structure is different. 29 | """ 30 | wrapped_eq = cls.__eq__ 31 | 32 | @wraps(wrapped_eq) 33 | def decorator(self: object, other: object) -> bool: 34 | # We must stop if self is other, otherwise we'd try to add ourself 35 | # twice, which shouldn't be allowed — it'd fail when removing. 36 | # Could parametrize a default result here for some kind of weird 37 | # recursive type that doesn't eq itself, but that seems unlikely. 38 | if self is other: 39 | return True 40 | 41 | # Keys are different across threads so that concurrent eq calls are 42 | # independent. 43 | thread_id = get_ident() 44 | self_key = id(self), thread_id 45 | other_key = id(other), thread_id 46 | self_running = self_key in _RUNNING_EQ_KEYS 47 | other_running = other_key in _RUNNING_EQ_KEYS 48 | 49 | # If either side already has __eq__ running in the call stack, we must 50 | # stop recursing, otherwise we'll infinitely loop. 51 | if self_running or other_running: 52 | # If both sides were already seen, we might be equal (caller decides) 53 | # If only one side was running, we can't be equal, as there's a 54 | # different structure referencing this point. 55 | return self_running and other_running 56 | 57 | # Register these objects as running so that we won't descend into them 58 | # if they are referenced again. 59 | _RUNNING_EQ_KEYS.add(self_key) 60 | _RUNNING_EQ_KEYS.add(other_key) 61 | 62 | # Allow the type to do its own equality check as normal, descending into 63 | # children if required. 64 | try: 65 | eq = wrapped_eq(self, other) 66 | finally: 67 | _RUNNING_EQ_KEYS.remove(self_key) 68 | _RUNNING_EQ_KEYS.remove(other_key) 69 | return eq 70 | 71 | cls.__eq__ = decorator # type: ignore[method-assign] 72 | return cls 73 | -------------------------------------------------------------------------------- /src/v8serialize/_values.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import ( 4 | TYPE_CHECKING, 5 | Literal, 6 | NewType, 7 | Protocol, 8 | TypeVar, 9 | overload, 10 | runtime_checkable, 11 | ) 12 | 13 | from v8serialize._pycompat.typing import ReadableBinary 14 | from v8serialize.constants import ArrayBufferViewTag, JSErrorName 15 | 16 | if TYPE_CHECKING: 17 | from typing_extensions import TypeAlias 18 | 19 | T_co = TypeVar("T_co", covariant=True) 20 | 21 | SharedArrayBufferId = NewType("SharedArrayBufferId", int) 22 | TransferId = NewType("TransferId", int) 23 | 24 | 25 | @runtime_checkable 26 | class AnyArrayBuffer(Protocol): 27 | """ 28 | A Protocol matching [JSArrayBuffer]. 29 | 30 | [JSArrayBuffer]: `v8serialize.jstypes.JSArrayBuffer` 31 | """ 32 | 33 | if TYPE_CHECKING: 34 | 35 | @property 36 | def data(self) -> ReadableBinary: ... 37 | @property 38 | def max_byte_length(self) -> int: ... 39 | @property 40 | def resizable(self) -> bool: ... 41 | 42 | else: 43 | # test/test_protocol_dataclass_interaction.py 44 | data = ... 45 | max_byte_length = ... 46 | resizable = ... 47 | 48 | 49 | @runtime_checkable 50 | class AnySharedArrayBuffer(Protocol): 51 | """ 52 | A Protocol matching [JSSharedArrayBuffer]. 53 | 54 | [JSSharedArrayBuffer]: `v8serialize.jstypes.JSSharedArrayBuffer` 55 | """ 56 | 57 | if TYPE_CHECKING: 58 | 59 | @property 60 | def buffer_id(self) -> SharedArrayBufferId: ... 61 | 62 | else: 63 | # test/test_protocol_dataclass_interaction.py 64 | buffer_id = ... 65 | 66 | 67 | @runtime_checkable 68 | class AnyArrayBufferTransfer(Protocol): 69 | """ 70 | A Protocol matching [JSArrayBufferTransfer]. 71 | 72 | [JSArrayBufferTransfer]: `v8serialize.jstypes.JSArrayBufferTransfer` 73 | """ 74 | 75 | if TYPE_CHECKING: 76 | 77 | @property 78 | def transfer_id(self) -> TransferId: ... 79 | 80 | else: 81 | # test/test_protocol_dataclass_interaction.py 82 | transfer_id = ... 83 | 84 | 85 | @runtime_checkable 86 | class AnyArrayBufferView(Protocol): 87 | """ 88 | A Protocol matching [JSJSTypedArray] and [JSJSDataView]. 89 | 90 | [JSJSTypedArray]: `v8serialize.jstypes.JSJSTypedArray` 91 | [JSJSDataView]: `v8serialize.jstypes.JSJSDataView` 92 | """ 93 | 94 | if TYPE_CHECKING: 95 | 96 | @property 97 | def view_tag(self) -> ArrayBufferViewTag: ... 98 | @property 99 | def byte_offset(self) -> int: ... 100 | @property 101 | def byte_length(self) -> int: ... 102 | @property 103 | def is_length_tracking(self) -> bool: ... 104 | @property 105 | def is_backing_buffer_resizable(self) -> bool: ... 106 | 107 | else: 108 | # test/test_protocol_dataclass_interaction.py 109 | view_tag = ... 110 | byte_offset = ... 111 | byte_length = ... 112 | is_length_tracking = ... 113 | is_backing_buffer_resizable = ... 114 | 115 | 116 | AnyArrayBufferData: TypeAlias = ( 117 | "AnyArrayBuffer | AnySharedArrayBuffer | AnyArrayBufferTransfer" 118 | ) 119 | """Any of the 3 ArrayBuffer types.""" 120 | 121 | BufferT = TypeVar("BufferT") 122 | BufferT_co = TypeVar("BufferT_co", covariant=True) 123 | BufferT_con = TypeVar("BufferT_con", contravariant=True) 124 | ViewT = TypeVar("ViewT") 125 | ViewT_co = TypeVar("ViewT_co", covariant=True) 126 | 127 | 128 | class ArrayBufferConstructor(Protocol[BufferT_co]): 129 | """A function that creates a representation of a serialized ArrayBuffer.""" 130 | 131 | @overload 132 | def __call__( 133 | self, data: memoryview, *, max_byte_length: None, resizable: Literal[False] 134 | ) -> BufferT_co: ... 135 | 136 | @overload 137 | def __call__( 138 | self, data: memoryview, *, max_byte_length: int, resizable: Literal[True] 139 | ) -> BufferT_co: ... 140 | 141 | 142 | class SharedArrayBufferConstructor(Protocol[BufferT_co]): 143 | """A function that creates a representation of a serialized SharedArrayBuffer.""" 144 | 145 | def __call__(self, buffer_id: SharedArrayBufferId) -> BufferT_co: ... 146 | 147 | 148 | class ArrayBufferTransferConstructor(Protocol[BufferT_co]): 149 | """A function that creates a representation of a serialized ArrayBufferTransfer.""" 150 | 151 | def __call__(self, transfer_id: TransferId) -> BufferT_co: ... 152 | 153 | 154 | class ArrayBufferViewConstructor(Protocol[BufferT_con, ViewT_co]): 155 | """A function that creates a representation of a serialized ArrayBuffer view.""" 156 | 157 | def __call__( 158 | self, 159 | buffer: BufferT_con, 160 | format: ArrayBufferViewTag, 161 | *, 162 | byte_offset: int, 163 | byte_length: int | None, 164 | ) -> ViewT_co: ... 165 | 166 | 167 | class AnyJSError(Protocol): 168 | """ 169 | A Protocol matching [JSError] and [JSErrorData]. 170 | 171 | [JSError]: `v8serialize.jstypes.JSError` 172 | [JSErrorData]: `v8serialize.jstypes.JSErrorData` 173 | """ 174 | 175 | # properties from protocols mess up concrete classes if they exist at runtime 176 | if TYPE_CHECKING: 177 | 178 | @property 179 | def name(self) -> str | JSErrorName: ... 180 | @name.setter 181 | def name(self, name: JSErrorName) -> None: ... 182 | 183 | message: str | None 184 | stack: str | None 185 | cause: object | None 186 | 187 | 188 | class JSErrorBuilder(Protocol[T_co]): 189 | """A function that creates a representation of a serialized Error.""" 190 | 191 | def __call__(self, partial: AnyJSError, /) -> tuple[T_co, AnyJSError]: ... 192 | -------------------------------------------------------------------------------- /src/v8serialize/_versions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | 5 | from packaging.version import VERSION_PATTERN, InvalidVersion, Version 6 | 7 | LENIENT_VERSION_PATTERN = re.compile( 8 | f"^(?P{VERSION_PATTERN})(?:-(?P\\S+))?$", re.VERBOSE 9 | ) 10 | 11 | 12 | def parse_lenient_version(version: str) -> Version: 13 | """ 14 | Parse a version number less strictly than `packaging.version.parse()`. 15 | 16 | Versions can have arbitrary suffixes after a `-`, like `12.9.202.2-rusty`. 17 | These suffixes become the local part of a Python Version. 18 | 19 | >>> version = parse_lenient_version("12.9.202.2-rusty") 20 | >>> version 21 | 22 | >>> version.local 23 | 'rusty' 24 | 25 | >>> parse_lenient_version("12.9.202.2") 26 | 27 | 28 | >>> parse_lenient_version("afsdf") # doctest: +IGNORE_EXCEPTION_DETAIL 29 | Traceback (most recent call last): 30 | packaging.version.InvalidVersion: afsdf 31 | """ 32 | match = LENIENT_VERSION_PATTERN.match(version) 33 | if not match: 34 | raise InvalidVersion(version) 35 | 36 | # We allow trailing local part starting with a dash. The Python version 37 | # expects a local part to start with a + and contain anything, but doesn't 38 | # allow a - suffix. Note that if the default pattern's local part matches, 39 | # we must have no suffix, as local will have consumed it all. 40 | lenient_suffix = match.group("suffix") 41 | if lenient_suffix: 42 | assert not match.group("local") 43 | return Version(f"{match.group('version')}+{lenient_suffix}") 44 | return Version(version) 45 | -------------------------------------------------------------------------------- /src/v8serialize/jstypes/__init__.py: -------------------------------------------------------------------------------- 1 | """Python representations of the JavaScript types in the V8 Serialization format.""" 2 | 3 | from __future__ import annotations 4 | 5 | from v8serialize.constants import JSRegExpFlag as JSRegExpFlag 6 | from v8serialize.jstypes._equality import JSSameValueZero as JSSameValueZero 7 | from v8serialize.jstypes._equality import same_value_zero as same_value_zero 8 | from v8serialize.jstypes._repr import JSRepr as JSRepr 9 | from v8serialize.jstypes._repr import ( 10 | JSReprSettingsNotRestored as JSReprSettingsNotRestored, 11 | ) 12 | from v8serialize.jstypes._repr import default_js_repr as default_js_repr 13 | from v8serialize.jstypes._repr import js_repr_settings as js_repr_settings 14 | from v8serialize.jstypes.jsarray import JSArray as JSArray 15 | from v8serialize.jstypes.jsarrayproperties import JSHole as JSHole 16 | from v8serialize.jstypes.jsarrayproperties import JSHoleType as JSHoleType 17 | from v8serialize.jstypes.jsbigint import JSBigInt as JSBigInt 18 | from v8serialize.jstypes.jsbuffers import ( 19 | ArrayBufferViewStructFormat as ArrayBufferViewStructFormat, 20 | ) 21 | from v8serialize.jstypes.jsbuffers import ( 22 | BoundsJSArrayBufferError as BoundsJSArrayBufferError, 23 | ) 24 | from v8serialize.jstypes.jsbuffers import ( 25 | ByteLengthJSArrayBufferError as ByteLengthJSArrayBufferError, 26 | ) 27 | from v8serialize.jstypes.jsbuffers import DataFormat as DataFormat 28 | from v8serialize.jstypes.jsbuffers import DataType as DataType 29 | from v8serialize.jstypes.jsbuffers import DataViewBuffer as DataViewBuffer 30 | from v8serialize.jstypes.jsbuffers import ( 31 | ItemSizeJSArrayBufferError as ItemSizeJSArrayBufferError, 32 | ) 33 | from v8serialize.jstypes.jsbuffers import JSArrayBuffer as JSArrayBuffer 34 | from v8serialize.jstypes.jsbuffers import JSArrayBufferError as JSArrayBufferError 35 | from v8serialize.jstypes.jsbuffers import JSArrayBufferTransfer as JSArrayBufferTransfer 36 | from v8serialize.jstypes.jsbuffers import JSBigInt64Array as JSBigInt64Array 37 | from v8serialize.jstypes.jsbuffers import JSBigUint64Array as JSBigUint64Array 38 | from v8serialize.jstypes.jsbuffers import JSDataView as JSDataView 39 | from v8serialize.jstypes.jsbuffers import JSFloat16Array as JSFloat16Array 40 | from v8serialize.jstypes.jsbuffers import JSFloat32Array as JSFloat32Array 41 | from v8serialize.jstypes.jsbuffers import JSFloat64Array as JSFloat64Array 42 | from v8serialize.jstypes.jsbuffers import JSInt8Array as JSInt8Array 43 | from v8serialize.jstypes.jsbuffers import JSInt16Array as JSInt16Array 44 | from v8serialize.jstypes.jsbuffers import JSInt32Array as JSInt32Array 45 | from v8serialize.jstypes.jsbuffers import JSSharedArrayBuffer as JSSharedArrayBuffer 46 | from v8serialize.jstypes.jsbuffers import JSTypedArray as JSTypedArray 47 | from v8serialize.jstypes.jsbuffers import JSUint8Array as JSUint8Array 48 | from v8serialize.jstypes.jsbuffers import JSUint8ClampedArray as JSUint8ClampedArray 49 | from v8serialize.jstypes.jsbuffers import JSUint16Array as JSUint16Array 50 | from v8serialize.jstypes.jsbuffers import JSUint32Array as JSUint32Array 51 | from v8serialize.jstypes.jsbuffers import create_view as create_view 52 | from v8serialize.jstypes.jserror import JSError as JSError 53 | from v8serialize.jstypes.jserror import JSErrorData as JSErrorData 54 | from v8serialize.jstypes.jsmap import JSMap as JSMap 55 | from v8serialize.jstypes.jsobject import JSObject as JSObject 56 | from v8serialize.jstypes.jsprimitiveobject import ( 57 | FalseJSPrimitiveObject as FalseJSPrimitiveObject, 58 | ) 59 | from v8serialize.jstypes.jsprimitiveobject import JSPrimitiveObject as JSPrimitiveObject 60 | from v8serialize.jstypes.jsprimitiveobject import ( 61 | NumberJSPrimitiveObject as NumberJSPrimitiveObject, 62 | ) 63 | from v8serialize.jstypes.jsprimitiveobject import ( 64 | PrimitiveObjectValue as PrimitiveObjectValue, 65 | ) 66 | from v8serialize.jstypes.jsprimitiveobject import ( 67 | StringJSPrimitiveObject as StringJSPrimitiveObject, 68 | ) 69 | from v8serialize.jstypes.jsprimitiveobject import ( 70 | TrueJSPrimitiveObject as TrueJSPrimitiveObject, 71 | ) 72 | from v8serialize.jstypes.jsprimitiveobject import ( 73 | UnknownJSPrimitiveObject as UnknownJSPrimitiveObject, 74 | ) 75 | from v8serialize.jstypes.jsregexp import JSRegExp as JSRegExp 76 | from v8serialize.jstypes.jsset import JSSet as JSSet 77 | from v8serialize.jstypes.jsundefined import JSUndefined as JSUndefined 78 | from v8serialize.jstypes.jsundefined import JSUndefinedType as JSUndefinedType 79 | -------------------------------------------------------------------------------- /src/v8serialize/jstypes/_equality.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from math import isnan 4 | from typing import Final, NewType 5 | 6 | from v8serialize.jstypes.jsundefined import JSUndefinedEnum 7 | 8 | _nankey: Final = (float("nan"),) # equal thanks to tuple identity 9 | 10 | JSSameValueZero = NewType("JSSameValueZero", object) 11 | """ 12 | The type of the opaque values returned by [`same_value_zero`]. 13 | 14 | [`same_value_zero`]: `v8serialize.jstypes.same_value_zero` 15 | """ 16 | 17 | 18 | def same_value_zero(value: object) -> JSSameValueZero: 19 | """ 20 | Get a surrogate value that follows [JavaScript same-value-zero equality rules][samevaluezero]. 21 | 22 | Python values can be compared according to same-value-zero by using `==`, 23 | `hash()` on the result of calling this function on the values, rather than 24 | on the values them directly. Like a key function when sorting. 25 | 26 | `same_value_zero(x) == same_value_zero(y)` is `True` if `x` and `y` are 27 | equal under [JavaScript's same-value-zero rule][samevaluezero]. 28 | 29 | [samevaluezero]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/\ 30 | Equality_comparisons_and_sameness#same-value-zero_equality 31 | 32 | Parameters 33 | ---------- 34 | value 35 | Any Python object 36 | 37 | Returns 38 | ------- 39 | : 40 | An opaque value that follows the same-value-zero rules when compared 41 | with `==` or passed to `hash()`. 42 | 43 | 44 | Examples 45 | -------- 46 | >>> NaN = float('nan') 47 | >>> NaN == NaN 48 | False 49 | >>> same_value_zero(NaN) == same_value_zero(NaN) 50 | True 51 | 52 | >>> True == 1 53 | True 54 | >>> same_value_zero(True) == same_value_zero(1) 55 | False 56 | 57 | >>> l1, l2 = [0], [0] 58 | >>> l1 is l2 59 | False 60 | >>> l1 == l2 61 | True 62 | >>> same_value_zero(l1) == same_value_zero(l2) 63 | False 64 | >>> same_value_zero(l1) == same_value_zero(l1) 65 | True 66 | 67 | Strings and numbers are equal by value. 68 | 69 | >>> s1, s2 = str([ord('a')]), str([ord('a')]) 70 | >>> s1 is s2 71 | False 72 | >>> s1 == s2 73 | True 74 | >>> same_value_zero(s1) == same_value_zero(s2) 75 | True 76 | >>> same_value_zero(1.0) == same_value_zero(1) 77 | True 78 | """ # noqa: E501 79 | # These values are equal by value. 80 | if isinstance(value, bool): 81 | # bools are equal to 0 and 1 by default 82 | return (bool, value) # type: ignore[return-value] 83 | elif isinstance(value, (int, float)): 84 | if isnan(value): 85 | # Represent nan as a value equal to itself. 86 | return _nankey # type: ignore[return-value] 87 | return value # type: ignore[return-value] 88 | 89 | # bytes is included here despite not existing in JS. I don't think it makes 90 | # sense to consider bytes equal by identity; it's a type that would be 91 | # equal under these rules if it did exist in JS. 92 | elif isinstance(value, (JSUndefinedEnum, type(None), str, bytes)): 93 | return value # type: ignore[return-value] 94 | # Everything else is equal by object identity. Wrap id in a tuple to avoid 95 | # clashing with int. 96 | return (id(value),) # type: ignore[return-value] 97 | -------------------------------------------------------------------------------- /src/v8serialize/jstypes/_normalise_property_key.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from v8serialize.jstypes.jsarrayproperties import MAX_ARRAY_LENGTH 4 | 5 | 6 | def canonical_numeric_index_string(value: str) -> int | None: 7 | """Get the int representation of value or None. 8 | 9 | The result is None unless interpreting value as a base10 int and back to a 10 | string is equal to value. 11 | 12 | This is very similar to the ECMA spec's function, except that negative 13 | values are returned as None. 14 | https://tc39.es/ecma262/#sec-canonicalnumericindexstring 15 | """ 16 | # isdecimal includes non-ascii decimal numbers. 17 | if value.isdecimal() and value.isascii(): 18 | int_value = int(value) 19 | # numbers with unnecessary leading zeros are not canonical 20 | return None if value[0] == "0" and value != "0" else int_value 21 | return None 22 | 23 | 24 | def normalise_property_key(key: str | int | float) -> str | int: 25 | """Get the canonical representation of a JavaScript property key as int or str. 26 | 27 | A key is an int if the str value is the base10 representation of the same 28 | integer and falls in the inclusive range 0..2**32-2 (which is the max 29 | JavaScript array index). 30 | 31 | We support floats as input as well as ints, because V8-serialized JSObject 32 | data can store keys as floating point values. Handling these in the same way 33 | as JavaScript requires some care, so by doing it here we can remove the need 34 | for users or other parts of the API to know about the differences. 35 | 36 | >>> normalise_property_key('3') 37 | 3 38 | >>> normalise_property_key('A') 39 | 'A' 40 | >>> normalise_property_key('-3') 41 | '-3' 42 | >>> normalise_property_key(1.0) 43 | 1 44 | >>> normalise_property_key("-0") 45 | '-0' 46 | >>> normalise_property_key(-0.0) 47 | 0 48 | >>> normalise_property_key(-1.0) 49 | '-1' 50 | >>> normalise_property_key(-1.5) 51 | '-1.5' 52 | 53 | This reflects the behaviour defined in: https://tc39.es/ecma262/#integer-index 54 | """ 55 | if isinstance(key, str): 56 | int_value = canonical_numeric_index_string(key) 57 | if int_value is None: 58 | return key 59 | key = int_value 60 | 61 | if isinstance(key, int) or key.is_integer(): 62 | if 0 <= key < MAX_ARRAY_LENGTH: 63 | return int(key) 64 | # Format out-of-range integer floats without decimal point, as JS does 65 | # not use them for integer numbers. 66 | key = int(key) 67 | return str(key) 68 | -------------------------------------------------------------------------------- /src/v8serialize/jstypes/_v8.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABC 4 | from dataclasses import dataclass 5 | from typing import NewType 6 | 7 | from v8serialize._pycompat.dataclasses import slots_if310 8 | 9 | V8SharedValueId = NewType("V8SharedValueId", int) 10 | 11 | 12 | @dataclass(frozen=True, **slots_if310()) 13 | class V8SharedObjectReference(ABC): 14 | """Represents an inaccessible shared object in a V8 process. 15 | 16 | This can only be used to round-trip a value back to V8. It should resolve to 17 | an actual JavaScript value. There's no real use-case for this type, it only 18 | really exists to avoid being unable to deserialize a a larger object graph 19 | that happens to contain a shared object that is of no consequence. 20 | """ 21 | 22 | shared_value_id: V8SharedValueId 23 | -------------------------------------------------------------------------------- /src/v8serialize/jstypes/_v8traceback.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Iterable, Sequence 4 | from itertools import zip_longest 5 | from traceback import FrameSummary, TracebackException 6 | from typing import Generator, cast 7 | 8 | 9 | def format_exception_for_v8( 10 | tbe: TracebackException, group_path: Sequence[int] = () 11 | ) -> Generator[str]: 12 | r"""Render a Python exception in the style of a V8 Error stack trace. 13 | 14 | Returns an iterable of strings ending with `"\n"`. (Join the lines to get a 15 | complete stack trace.) 16 | 17 | This aims to follow the layout described in https://v8.dev/docs/stack-trace-api 18 | 19 | V8 stack traces are in the reverse order of Python — most recent call first. 20 | They also omit details of of the the source code line that each stack entry 21 | corresponds to. For example, in V8 format: 22 | 23 | ``` 24 | ValueError: Unable to do the thing 25 | at failing_operation (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:66:12) 26 | at raise_simple (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:11:8) 27 | at call_and_capture_tbe (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:82:8) 28 | ``` 29 | 30 | vs Python: 31 | 32 | ``` 33 | Traceback (most recent call last): 34 | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 82, in call_and_capture_tbe 35 | fn() 36 | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 11, in raise_simple 37 | self.failing_operation() 38 | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 66, in failing_operation 39 | raise ValueError("Unable to do the thing") 40 | ValueError: Unable to do the thing 41 | ``` 42 | 43 | V8's JavaScript Errors don't support the __context__ feature of Python 44 | exceptions, and V8 serialization doesn't support JavaScript's 45 | AggregateError, but it does support linking exceptions via `cause`. With this 46 | in mind, this format encodes details of context exceptions and 47 | sub-exceptions of exception groups in the string of the root exception. It 48 | does not include cause exceptions, because causes can be represented 49 | natively as `cause`. 50 | """ # noqa: E501 51 | yield from format_v8_stack(tbe, group_path=group_path) 52 | 53 | for related in walk_related(tbe): 54 | yield from [ 55 | "\n", 56 | "The above exception occurred while handling another exception:\n", 57 | "\n", 58 | ] 59 | yield from format_v8_stack(related, group_path=()) 60 | 61 | 62 | def walk_related(tbe: TracebackException) -> Generator[TracebackException]: 63 | while tbe.__context__ and not tbe.__suppress_context__: 64 | yield tbe.__context__ 65 | tbe = tbe.__context__ 66 | 67 | 68 | def format_v8_stack( 69 | tbe: TracebackException, *, group_path: Sequence[int] 70 | ) -> Generator[str]: 71 | # e.g. "ValueError: foo must be positive" 72 | yield from tbe.format_exception_only() 73 | 74 | if tbe.stack: 75 | # e.g. " at main (/foo/bar.py:8:4)" 76 | yield from (format_v8_frame(fs) for fs in reversed(tbe.stack)) 77 | 78 | sub_exceptions: list[TracebackException] | None = getattr( 79 | tbe, "exceptions", None 80 | ) # 3.11+ 81 | if sub_exceptions: 82 | for i, sub_tbe in enumerate( 83 | cast(Sequence[TracebackException], sub_exceptions), start=1 84 | ): 85 | sub_group_path = [*group_path, i] 86 | yield "\n" 87 | prefixes = [f" ↳ {format_group_path(sub_group_path)}: ", " "] 88 | yield from prefix_lines( 89 | format_exception_for_v8(sub_tbe, group_path=sub_group_path), prefixes 90 | ) 91 | 92 | 93 | def format_group_path(group_path: Sequence[int]) -> str: 94 | return ".".join(map(str, group_path)) 95 | 96 | 97 | def prefix_lines(lines: Iterable[str], prefixes: Iterable[str]) -> Generator[str]: 98 | last_prefix = "" 99 | for line, prefix in zip_longest(lines, prefixes): 100 | if line is None: 101 | return 102 | if prefix is None: 103 | prefix = last_prefix 104 | else: 105 | last_prefix = prefix 106 | result = f"{prefix}{line}" 107 | yield result.lstrip(" ") if result.isspace() else result 108 | 109 | 110 | def format_v8_frame(fs: FrameSummary) -> str: 111 | return f" at {fs.name} {format_v8_frame_location(fs)}\n" 112 | 113 | 114 | def format_v8_frame_location(fs: FrameSummary) -> str: 115 | if fs.filename is None and fs.lineno is None and fs.colno is None: 116 | return "(unknown location)" 117 | 118 | filename = sub_none(fs.filename, "") 119 | lineno = sub_none(fs.lineno, "") 120 | # colno available from 3.11 121 | colno = sub_none(getattr(fs, "colno", None), "") 122 | 123 | return f"({filename}:{lineno}:{colno})" 124 | 125 | 126 | def sub_none(value: object, none_substitute: str) -> str: 127 | return none_substitute if value is None else str(value) 128 | -------------------------------------------------------------------------------- /src/v8serialize/jstypes/jsregexp.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from dataclasses import dataclass, field 5 | from re import Pattern, compile 6 | from typing import AnyStr, Literal, overload 7 | 8 | from v8serialize._errors import JSRegExpV8SerializeError 9 | from v8serialize._pycompat.dataclasses import slots_if310 10 | from v8serialize._pycompat.re import RegexFlag 11 | from v8serialize.constants import JSRegExpFlag 12 | 13 | 14 | @dataclass(frozen=True, order=True, **slots_if310()) 15 | class JSRegExp: 16 | """The data represented by a [JavaScript RegExp]. 17 | 18 | Note that while Python and JavaScript Regular Expressions are similar, they 19 | each have features and syntax not supported by the other. Simple expressions 20 | will work the same in both languages, but this is not the case in general. 21 | 22 | **`JSRegExp` does not support matching text with the RegExp**, but 23 | [`as_python_pattern()`] can work for patterns that use compatible syntax and 24 | flags. 25 | 26 | [`as_python_pattern()`]: `v8serialize.jstypes.JSRegExp.as_python_pattern` 27 | [JavaScript RegExp]: https://developer.mozilla.org/en-US/docs/Web/\ 28 | JavaScript/Reference/Global_Objects/RegExp 29 | """ 30 | 31 | source: str 32 | flags: JSRegExpFlag = field(default=JSRegExpFlag.NoFlag) 33 | 34 | def __post_init__(self) -> None: 35 | if self.source == "": 36 | # JavaScript regexes cannot be empty, because the slash-delimited 37 | # literal syntax would be the same as a comment. Empty regexes are 38 | # represented as an empty non-capturing group. 39 | object.__setattr__(self, "source", "(?:)") 40 | if self.flags & JSRegExpFlag.Unicode and self.flags & JSRegExpFlag.UnicodeSets: 41 | raise ValueError( 42 | "The Unicode and UnicodeSets flags cannot be set together: " 43 | "Setting both is a syntax error in JavaScript because they " 44 | "enable incompatible interpretations of the RegExp source." 45 | ) 46 | 47 | @overload 48 | @staticmethod 49 | def from_python_pattern( 50 | pattern: re.Pattern[AnyStr], throw: Literal[False] 51 | ) -> JSRegExp | None: ... 52 | 53 | @overload 54 | @staticmethod 55 | def from_python_pattern( 56 | pattern: re.Pattern[AnyStr], throw: Literal[True] = True 57 | ) -> JSRegExp: ... 58 | 59 | @staticmethod 60 | def from_python_pattern( 61 | pattern: re.Pattern[AnyStr], throw: bool = True 62 | ) -> JSRegExp | None: 63 | """Naively create a JSRegExp with an un-translated Python re.Pattern. 64 | 65 | As with `as_python_pattern()` this can result in JSRegExp objects that 66 | won't behave on the JavaScript side in the same way as in Python. 67 | 68 | This can fail if the Python pattern has `re.VERBOSE` set, as there's 69 | no equivalent JavaScript flag. 70 | """ 71 | if isinstance(pattern.pattern, bytes): 72 | source = pattern.pattern.decode() 73 | else: 74 | source = pattern.pattern 75 | try: 76 | flags = JSRegExpFlag.from_python_flags(RegexFlag(pattern.flags)) 77 | except JSRegExpV8SerializeError as e: 78 | if throw: 79 | raise JSRegExpV8SerializeError( 80 | f"Python re.Pattern flags cannot be represented by " 81 | f"JavaScript RegExp: {e}" 82 | ) from e 83 | return None 84 | return JSRegExp(source, flags=flags) 85 | 86 | @overload 87 | def as_python_pattern(self, throw: Literal[False]) -> Pattern[str] | None: ... 88 | 89 | @overload 90 | def as_python_pattern(self, throw: Literal[True] = True) -> Pattern[str]: ... 91 | 92 | def as_python_pattern(self, throw: bool = True) -> Pattern[str] | None: 93 | """Naively compile the JavaScript RegExp as a Python re.Pattern. 94 | 95 | The pattern may fail to compile due to syntax incompatibility, or may 96 | compile but behave incorrectly due to differences between Python and 97 | JavaScript's regular expression support. 98 | """ 99 | # There is https://github.com/Zac-HD/js-regex but it seems to be a proof 100 | # of concept in that it doesn't fully parse and translate the regex AST, 101 | # so some patterns still don't work. 102 | try: 103 | return compile(self.source, self.flags.as_python_flags()) 104 | except (JSRegExpV8SerializeError, re.error) as e: 105 | if throw: 106 | raise JSRegExpV8SerializeError( 107 | f"JSRegExp is not a valid Python re.Pattern: {e}" 108 | ) from e 109 | return None 110 | -------------------------------------------------------------------------------- /src/v8serialize/jstypes/jsundefined.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from enum import Enum 4 | from typing import TYPE_CHECKING, Final, Literal 5 | 6 | if TYPE_CHECKING: 7 | from typing_extensions import TypeAlias 8 | 9 | 10 | class JSUndefinedEnum(Enum): 11 | """Defines the JSUndefined enum value.""" 12 | 13 | JSUndefined = "JSUndefined" 14 | """Represents the JavaScript value `undefined`.""" 15 | 16 | def __repr__(self) -> str: 17 | return self.name 18 | 19 | def __str__(self) -> str: 20 | return self.name 21 | 22 | 23 | JSUndefinedType: TypeAlias = Literal[JSUndefinedEnum.JSUndefined] 24 | JSUndefined: Final = JSUndefinedEnum.JSUndefined 25 | """Represents the JavaScript value `undefined`.""" 26 | -------------------------------------------------------------------------------- /src/v8serialize/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/h4l/v8serialize/ad6b5782fb24396b3e7b1a8d7d51130913390dab/src/v8serialize/py.typed -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | import sys 5 | from enum import Enum, auto 6 | from pathlib import Path 7 | 8 | from pytest_insta import Fmt 9 | 10 | 11 | class ExceptionColno(Enum): 12 | Supported = auto() 13 | Unsupported = auto() 14 | 15 | @staticmethod 16 | def get_current() -> ExceptionColno: 17 | return ( 18 | ExceptionColno.Supported 19 | if sys.version_info >= (3, 11) 20 | else ExceptionColno.Unsupported 21 | ) 22 | 23 | @property 24 | def is_current(self) -> bool: 25 | return self == ExceptionColno.get_current() 26 | 27 | 28 | class FmtException(Fmt[str]): # type: ignore[no-untyped-call] # __init_subclass__ 29 | extension = ".exc.txt" 30 | project_dir: Path 31 | colno: ExceptionColno 32 | 33 | def __init__(self) -> None: 34 | self.project_dir = Path(__file__, "../..").resolve() 35 | self.project_dir_pattern = re.escape(str(Path(__file__, "../..").resolve())) 36 | self.colno = ExceptionColno.get_current() 37 | 38 | def with_absolute_paths(self, trace: str) -> str: 39 | return re.sub(r'(?<=[("])/…/v8serialize/', f"{self.project_dir}/", trace) 40 | 41 | def with_relative_paths(self, trace: str) -> str: 42 | return re.sub(f'(?<=[("]){self.project_dir_pattern}/', "/…/v8serialize/", trace) 43 | 44 | def with_unknown_colno(self, trace: str) -> str: 45 | return re.sub( 46 | r"^(\s+at .* \(.*:\d+):(\d+)\)$", 47 | r"\1:)", 48 | trace, 49 | flags=re.MULTILINE, 50 | ) 51 | 52 | def load(self, path: Path) -> str: 53 | exception_trace = path.read_text() 54 | exception_trace = self.with_absolute_paths(exception_trace) 55 | if self.colno is ExceptionColno.Unsupported: 56 | exception_trace = self.with_unknown_colno(exception_trace) 57 | return exception_trace 58 | 59 | def dump(self, path: Path, value: str) -> None: 60 | exception_trace = self.with_relative_paths(value) 61 | if self.colno is ExceptionColno.Unsupported: 62 | raise AssertionError( 63 | f"""\ 64 | Cannot dump a modified exception trace snapshot on a platform with \ 65 | {self.colno}. You must (re)generate the snapshot on a platform that supports \ 66 | column numbers in tracebacks (Python 3.11+) and then run tests on platforms \ 67 | that lack column number support to verify the pre-generated snapshot \ 68 | matches their behaviour (column numbers are ignored when comparing snapshots 69 | on platforms without column number support).""" 70 | ) 71 | path.write_text(exception_trace) 72 | -------------------------------------------------------------------------------- /test/jstypes/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | -------------------------------------------------------------------------------- /test/jstypes/_v8_traceback_error_fixtures.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from traceback import TracebackException 5 | from typing import Callable 6 | from typing_extensions import Never 7 | 8 | if sys.version_info < (3, 11): 9 | from exceptiongroup import ExceptionGroup 10 | 11 | 12 | class ErrorScenario: 13 | """ 14 | Functions that raise errors with various attached context/exception groups. 15 | 16 | Used to generate well-known exceptions to test traceback formatting 17 | behaviour. 18 | """ 19 | 20 | def raise_simple(self) -> Never: 21 | self.failing_operation() 22 | 23 | def raise_group(self) -> Never: 24 | try: 25 | self.failing_operation() 26 | except Exception as e: 27 | fail1 = e 28 | 29 | try: 30 | self.failing_operation2() 31 | except Exception as e: 32 | fail2 = e 33 | 34 | raise ExceptionGroup("Everything went wrong", [fail1, fail2]) 35 | 36 | def raise_context(self) -> Never: 37 | try: 38 | self.failing_operation() 39 | except Exception as e: 40 | e / 0 # type: ignore[operator] 41 | raise AssertionError("unreachable") from e 42 | 43 | def raise_context_group(self) -> Never: 44 | try: 45 | self.raise_group() 46 | except Exception as e: 47 | e / 0 # type: ignore[operator] 48 | raise AssertionError("unreachable") from e 49 | 50 | def raise_group_with_context(self) -> Never: 51 | try: 52 | self.failing_operation_with_context() 53 | except Exception as e: 54 | fail1 = e 55 | 56 | try: 57 | self.failing_operation2() 58 | except Exception as e: 59 | fail2 = e 60 | 61 | raise ExceptionGroup("Everything went wrong", [fail1, fail2]) 62 | 63 | def raise_sub_group_with_context(self) -> Never: 64 | try: 65 | self.raise_group_with_context() 66 | except Exception as e: 67 | fail1 = e 68 | 69 | try: 70 | self.raise_group() 71 | except Exception as e: 72 | fail2 = e 73 | 74 | raise ExceptionGroup("This is fine", [fail1, fail2]) 75 | 76 | def failing_operation(self) -> Never: 77 | if True: 78 | raise ValueError("Unable to do the thing") 79 | 80 | def failing_operation2(self) -> Never: 81 | if True: 82 | raise TypeError("Expected an Apple but received an Orange") 83 | 84 | def failing_operation_with_context(self) -> Never: 85 | try: 86 | self.failing_operation() 87 | except Exception as e: 88 | e.missing_attribute.foo() # type: ignore[attr-defined] 89 | raise AssertionError("unreachable") from e 90 | 91 | 92 | def call_and_capture_tbe(fn: Callable[[], Never]) -> TracebackException: 93 | try: 94 | fn() 95 | except Exception as e: 96 | return TracebackException.from_exception(e) 97 | raise AssertionError("fn did not raise") 98 | -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsarray_maxjsarray__0.txt: -------------------------------------------------------------------------------- 1 | JSArray(c='C') -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsarray_maxjsarray__1.txt: -------------------------------------------------------------------------------- 1 | JSArray(c='C', ...) -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsarray_maxjsarray__2.txt: -------------------------------------------------------------------------------- 1 | JSArray({'!': 'C'}) -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsarray_maxjsarray__3.txt: -------------------------------------------------------------------------------- 1 | JSArray({'!': 'C', ...}) -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsarray_maxjsarray__4.txt: -------------------------------------------------------------------------------- 1 | JSArray(['a']) -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsarray_maxjsarray__5.txt: -------------------------------------------------------------------------------- 1 | JSArray(['a', ...]) -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsarray_maxjsarray__6.txt: -------------------------------------------------------------------------------- 1 | JSArray(['a', ...], ...) -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsarray_maxjsarray__7.txt: -------------------------------------------------------------------------------- 1 | JSArray(['a', ...], **{...}) -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsarray_maxjsarray_indented__0.txt: -------------------------------------------------------------------------------- 1 | JSArray(c='C') -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsarray_maxjsarray_indented__1.txt: -------------------------------------------------------------------------------- 1 | JSArray( 2 | c='C', 3 | ..., 4 | ) -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsarray_maxjsarray_indented__2.txt: -------------------------------------------------------------------------------- 1 | JSArray({'!': 'C'}) -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsarray_maxjsarray_indented__3.txt: -------------------------------------------------------------------------------- 1 | JSArray({ 2 | '!': 'C', 3 | ..., 4 | }) -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsarray_maxjsarray_indented__4.txt: -------------------------------------------------------------------------------- 1 | JSArray([ 2 | 'a', 3 | ]) -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsarray_maxjsarray_indented__5.txt: -------------------------------------------------------------------------------- 1 | JSArray([ 2 | 'a', 3 | ..., 4 | ]) -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsarray_maxjsarray_indented__6.txt: -------------------------------------------------------------------------------- 1 | JSArray([ 2 | 'a', 3 | ..., 4 | ], 5 | ..., 6 | ) -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsarray_maxjsarray_indented__7.txt: -------------------------------------------------------------------------------- 1 | JSArray([ 2 | 'a', 3 | ..., 4 | ], **{ 5 | ..., 6 | }) -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsarray_maxlevel__0.txt: -------------------------------------------------------------------------------- 1 | ... -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsarray_repr__0.txt: -------------------------------------------------------------------------------- 1 | JSArray() -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsarray_repr__1.txt: -------------------------------------------------------------------------------- 1 | JSArray([ 2 | 'a', 3 | 'b', 4 | ]) -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsarray_repr__10.txt: -------------------------------------------------------------------------------- 1 | JSArray([ 2 | 'a', 3 | ..., 4 | ]) -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsarray_repr__11.txt: -------------------------------------------------------------------------------- 1 | JSArray([ 2 | JSObject( 3 | names=JSArray([ 4 | 'Bill', 5 | 'Bob', 6 | ]), 7 | ), 8 | ]) -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsarray_repr__2.txt: -------------------------------------------------------------------------------- 1 | JSArray([ 2 | 'a', 3 | 'b', 4 | ], 5 | x='y', 6 | ) -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsarray_repr__3.txt: -------------------------------------------------------------------------------- 1 | JSArray({ 2 | 1000: 'a', 3 | }) -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsarray_repr__4.txt: -------------------------------------------------------------------------------- 1 | JSArray({ 2 | 1000: 'a', 3 | }, 4 | x='y', 5 | ) -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsarray_repr__5.txt: -------------------------------------------------------------------------------- 1 | JSArray(x=1) -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsarray_repr__6.txt: -------------------------------------------------------------------------------- 1 | JSArray( 2 | x=1, 3 | y=2, 4 | ) -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsarray_repr__7.txt: -------------------------------------------------------------------------------- 1 | JSArray({'!': 1}) -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsarray_repr__8.txt: -------------------------------------------------------------------------------- 1 | JSArray({ 2 | '!': 1, 3 | '!!': 2, 4 | }) -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsarray_repr__9.txt: -------------------------------------------------------------------------------- 1 | JSArray([ 2 | 'a', 3 | ], **{ 4 | '!': 1, 5 | '!!': 2, 6 | }) -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsobject_maxjsobject__0.txt: -------------------------------------------------------------------------------- 1 | JSObject(a=1, ...) -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsobject_maxjsobject__1.txt: -------------------------------------------------------------------------------- 1 | JSObject({'!': 1, ...}) -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsobject_maxjsobject__2.txt: -------------------------------------------------------------------------------- 1 | JSObject({0: 'a'}, ...) -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsobject_maxjsobject_indented__0.txt: -------------------------------------------------------------------------------- 1 | JSObject( 2 | a=1, 3 | ..., 4 | ) -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsobject_maxjsobject_indented__1.txt: -------------------------------------------------------------------------------- 1 | JSObject({ 2 | '!': 1, 3 | ..., 4 | }) -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsobject_maxjsobject_indented__2.txt: -------------------------------------------------------------------------------- 1 | JSObject({ 2 | 0: 'a', 3 | }, 4 | ..., 5 | ) -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsobject_maxjsobject_indented__3.txt: -------------------------------------------------------------------------------- 1 | JSObject({ 2 | 0: 'a', 3 | ..., 4 | }, 5 | ..., 6 | ) -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsobject_repr__0.txt: -------------------------------------------------------------------------------- 1 | JSObject() -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsobject_repr__1.txt: -------------------------------------------------------------------------------- 1 | JSObject( 2 | z=1, 3 | b=2, 4 | c=3, 5 | ) -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsobject_repr__2.txt: -------------------------------------------------------------------------------- 1 | JSObject({ 2 | 1000: 'a', 3 | 1001: 'b', 4 | }, 5 | z='other', 6 | x='X', 7 | ) -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsobject_repr__3.txt: -------------------------------------------------------------------------------- 1 | JSObject({ 2 | 'foo bar': 1, 3 | 'b': 2, 4 | 'c': 3, 5 | }) -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsobject_repr__4.txt: -------------------------------------------------------------------------------- 1 | JSObject( 2 | a=1, 3 | b=..., 4 | ) -------------------------------------------------------------------------------- /test/jstypes/snapshots/repr__jsobject_repr__5.txt: -------------------------------------------------------------------------------- 1 | JSObject( 2 | a=JSArray([ 3 | JSObject(name='Bob'), 4 | JSObject( 5 | name='Alice', 6 | id=2, 7 | ), 8 | ]), 9 | ) -------------------------------------------------------------------------------- /test/jstypes/snapshots/v8_traceback__format_exception_for_v8__represents_ExceptionGroup_py__0.exc.txt: -------------------------------------------------------------------------------- 1 | + Exception Group Traceback (most recent call last): 2 | | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 94, in call_and_capture_tbe 3 | | fn() 4 | | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 34, in raise_group 5 | | raise ExceptionGroup("Everything went wrong", [fail1, fail2]) 6 | | ExceptionGroup: Everything went wrong (2 sub-exceptions) 7 | +-+---------------- 1 ---------------- 8 | | Traceback (most recent call last): 9 | | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 25, in raise_group 10 | | self.failing_operation() 11 | | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 78, in failing_operation 12 | | raise ValueError("Unable to do the thing") 13 | | ValueError: Unable to do the thing 14 | +---------------- 2 ---------------- 15 | | Traceback (most recent call last): 16 | | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 30, in raise_group 17 | | self.failing_operation2() 18 | | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 82, in failing_operation2 19 | | raise TypeError("Expected an Apple but received an Orange") 20 | | TypeError: Expected an Apple but received an Orange 21 | +------------------------------------ 22 | -------------------------------------------------------------------------------- /test/jstypes/snapshots/v8_traceback__format_exception_for_v8__represents_ExceptionGroup_v8__0.exc.txt: -------------------------------------------------------------------------------- 1 | ExceptionGroup: Everything went wrong (2 sub-exceptions) 2 | at raise_group (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:34:8) 3 | at call_and_capture_tbe (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:94:8) 4 | 5 | ↳ 1: ValueError: Unable to do the thing 6 | at failing_operation (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:78:12) 7 | at raise_group (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:25:12) 8 | 9 | ↳ 2: TypeError: Expected an Apple but received an Orange 10 | at failing_operation2 (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:82:12) 11 | at raise_group (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:30:12) 12 | -------------------------------------------------------------------------------- /test/jstypes/snapshots/v8_traceback__format_exception_for_v8__represents_ExceptionGroup_with_context_py__0.exc.txt: -------------------------------------------------------------------------------- 1 | + Exception Group Traceback (most recent call last): 2 | | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 94, in call_and_capture_tbe 3 | | fn() 4 | | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 61, in raise_group_with_context 5 | | raise ExceptionGroup("Everything went wrong", [fail1, fail2]) 6 | | ExceptionGroup: Everything went wrong (2 sub-exceptions) 7 | +-+---------------- 1 ---------------- 8 | | Traceback (most recent call last): 9 | | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 86, in failing_operation_with_context 10 | | self.failing_operation() 11 | | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 78, in failing_operation 12 | | raise ValueError("Unable to do the thing") 13 | | ValueError: Unable to do the thing 14 | | 15 | | During handling of the above exception, another exception occurred: 16 | | 17 | | Traceback (most recent call last): 18 | | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 52, in raise_group_with_context 19 | | self.failing_operation_with_context() 20 | | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 88, in failing_operation_with_context 21 | | e.missing_attribute.foo() # type: ignore[attr-defined] 22 | | ^^^^^^^^^^^^^^^^^^^ 23 | | AttributeError: 'ValueError' object has no attribute 'missing_attribute' 24 | +---------------- 2 ---------------- 25 | | Traceback (most recent call last): 26 | | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 57, in raise_group_with_context 27 | | self.failing_operation2() 28 | | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 82, in failing_operation2 29 | | raise TypeError("Expected an Apple but received an Orange") 30 | | TypeError: Expected an Apple but received an Orange 31 | +------------------------------------ 32 | -------------------------------------------------------------------------------- /test/jstypes/snapshots/v8_traceback__format_exception_for_v8__represents_ExceptionGroup_with_context_v8__0.exc.txt: -------------------------------------------------------------------------------- 1 | ExceptionGroup: Everything went wrong (2 sub-exceptions) 2 | at raise_group_with_context (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:61:8) 3 | at call_and_capture_tbe (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:94:8) 4 | 5 | ↳ 1: AttributeError: 'ValueError' object has no attribute 'missing_attribute' 6 | at failing_operation_with_context (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:88:12) 7 | at raise_group_with_context (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:52:12) 8 | 9 | The above exception occurred while handling another exception: 10 | 11 | ValueError: Unable to do the thing 12 | at failing_operation (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:78:12) 13 | at failing_operation_with_context (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:86:12) 14 | 15 | ↳ 2: TypeError: Expected an Apple but received an Orange 16 | at failing_operation2 (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:82:12) 17 | at raise_group_with_context (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:57:12) 18 | -------------------------------------------------------------------------------- /test/jstypes/snapshots/v8_traceback__format_exception_for_v8__represents_exception_with_context_py__0.exc.txt: -------------------------------------------------------------------------------- 1 | Traceback (most recent call last): 2 | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 38, in raise_context 3 | self.failing_operation() 4 | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 78, in failing_operation 5 | raise ValueError("Unable to do the thing") 6 | ValueError: Unable to do the thing 7 | 8 | During handling of the above exception, another exception occurred: 9 | 10 | Traceback (most recent call last): 11 | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 94, in call_and_capture_tbe 12 | fn() 13 | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 40, in raise_context 14 | e / 0 # type: ignore[operator] 15 | ~~^~~ 16 | TypeError: unsupported operand type(s) for /: 'ValueError' and 'int' 17 | -------------------------------------------------------------------------------- /test/jstypes/snapshots/v8_traceback__format_exception_for_v8__represents_exception_with_context_v8__0.exc.txt: -------------------------------------------------------------------------------- 1 | TypeError: unsupported operand type(s) for /: 'ValueError' and 'int' 2 | at raise_context (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:40:12) 3 | at call_and_capture_tbe (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:94:8) 4 | 5 | The above exception occurred while handling another exception: 6 | 7 | ValueError: Unable to do the thing 8 | at failing_operation (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:78:12) 9 | at raise_context (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:38:12) 10 | -------------------------------------------------------------------------------- /test/jstypes/snapshots/v8_traceback__format_exception_for_v8__represents_nested_ExceptionGroups_with_context_py__0.exc.txt: -------------------------------------------------------------------------------- 1 | + Exception Group Traceback (most recent call last): 2 | | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 94, in call_and_capture_tbe 3 | | fn() 4 | | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 74, in raise_sub_group_with_context 5 | | raise ExceptionGroup("This is fine", [fail1, fail2]) 6 | | ExceptionGroup: This is fine (2 sub-exceptions) 7 | +-+---------------- 1 ---------------- 8 | | Exception Group Traceback (most recent call last): 9 | | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 65, in raise_sub_group_with_context 10 | | self.raise_group_with_context() 11 | | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 61, in raise_group_with_context 12 | | raise ExceptionGroup("Everything went wrong", [fail1, fail2]) 13 | | ExceptionGroup: Everything went wrong (2 sub-exceptions) 14 | +-+---------------- 1 ---------------- 15 | | Traceback (most recent call last): 16 | | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 86, in failing_operation_with_context 17 | | self.failing_operation() 18 | | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 78, in failing_operation 19 | | raise ValueError("Unable to do the thing") 20 | | ValueError: Unable to do the thing 21 | | 22 | | During handling of the above exception, another exception occurred: 23 | | 24 | | Traceback (most recent call last): 25 | | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 52, in raise_group_with_context 26 | | self.failing_operation_with_context() 27 | | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 88, in failing_operation_with_context 28 | | e.missing_attribute.foo() # type: ignore[attr-defined] 29 | | ^^^^^^^^^^^^^^^^^^^ 30 | | AttributeError: 'ValueError' object has no attribute 'missing_attribute' 31 | +---------------- 2 ---------------- 32 | | Traceback (most recent call last): 33 | | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 57, in raise_group_with_context 34 | | self.failing_operation2() 35 | | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 82, in failing_operation2 36 | | raise TypeError("Expected an Apple but received an Orange") 37 | | TypeError: Expected an Apple but received an Orange 38 | +------------------------------------ 39 | +---------------- 2 ---------------- 40 | | Exception Group Traceback (most recent call last): 41 | | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 70, in raise_sub_group_with_context 42 | | self.raise_group() 43 | | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 34, in raise_group 44 | | raise ExceptionGroup("Everything went wrong", [fail1, fail2]) 45 | | ExceptionGroup: Everything went wrong (2 sub-exceptions) 46 | +-+---------------- 1 ---------------- 47 | | Traceback (most recent call last): 48 | | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 25, in raise_group 49 | | self.failing_operation() 50 | | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 78, in failing_operation 51 | | raise ValueError("Unable to do the thing") 52 | | ValueError: Unable to do the thing 53 | +---------------- 2 ---------------- 54 | | Traceback (most recent call last): 55 | | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 30, in raise_group 56 | | self.failing_operation2() 57 | | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 82, in failing_operation2 58 | | raise TypeError("Expected an Apple but received an Orange") 59 | | TypeError: Expected an Apple but received an Orange 60 | +------------------------------------ 61 | -------------------------------------------------------------------------------- /test/jstypes/snapshots/v8_traceback__format_exception_for_v8__represents_nested_ExceptionGroups_with_context_v8__0.exc.txt: -------------------------------------------------------------------------------- 1 | ExceptionGroup: This is fine (2 sub-exceptions) 2 | at raise_sub_group_with_context (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:74:8) 3 | at call_and_capture_tbe (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:94:8) 4 | 5 | ↳ 1: ExceptionGroup: Everything went wrong (2 sub-exceptions) 6 | at raise_group_with_context (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:61:8) 7 | at raise_sub_group_with_context (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:65:12) 8 | 9 | ↳ 1.1: AttributeError: 'ValueError' object has no attribute 'missing_attribute' 10 | at failing_operation_with_context (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:88:12) 11 | at raise_group_with_context (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:52:12) 12 | 13 | The above exception occurred while handling another exception: 14 | 15 | ValueError: Unable to do the thing 16 | at failing_operation (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:78:12) 17 | at failing_operation_with_context (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:86:12) 18 | 19 | ↳ 1.2: TypeError: Expected an Apple but received an Orange 20 | at failing_operation2 (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:82:12) 21 | at raise_group_with_context (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:57:12) 22 | 23 | ↳ 2: ExceptionGroup: Everything went wrong (2 sub-exceptions) 24 | at raise_group (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:34:8) 25 | at raise_sub_group_with_context (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:70:12) 26 | 27 | ↳ 2.1: ValueError: Unable to do the thing 28 | at failing_operation (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:78:12) 29 | at raise_group (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:25:12) 30 | 31 | ↳ 2.2: TypeError: Expected an Apple but received an Orange 32 | at failing_operation2 (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:82:12) 33 | at raise_group (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:30:12) 34 | -------------------------------------------------------------------------------- /test/jstypes/snapshots/v8_traceback__format_exception_for_v8__represents_simple_exception_py__0.exc.txt: -------------------------------------------------------------------------------- 1 | Traceback (most recent call last): 2 | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 94, in call_and_capture_tbe 3 | fn() 4 | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 21, in raise_simple 5 | self.failing_operation() 6 | File "/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py", line 78, in failing_operation 7 | raise ValueError("Unable to do the thing") 8 | ValueError: Unable to do the thing 9 | -------------------------------------------------------------------------------- /test/jstypes/snapshots/v8_traceback__format_exception_for_v8__represents_simple_exception_v8__0.exc.txt: -------------------------------------------------------------------------------- 1 | ValueError: Unable to do the thing 2 | at failing_operation (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:78:12) 3 | at raise_simple (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:21:8) 4 | at call_and_capture_tbe (/…/v8serialize/test/jstypes/_v8_traceback_error_fixtures.py:94:8) 5 | -------------------------------------------------------------------------------- /test/jstypes/test__equality.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from math import isnan 4 | 5 | import pytest 6 | from hypothesis import given 7 | 8 | from test.strategies import values_and_objects as mk_values_and_objects 9 | from v8serialize.jstypes._equality import same_value_zero 10 | from v8serialize.jstypes.jsobject import JSObject 11 | from v8serialize.jstypes.jsprimitiveobject import JSPrimitiveObject 12 | from v8serialize.jstypes.jsundefined import JSUndefined 13 | 14 | values_and_objects = mk_values_and_objects(allow_nan=True, only_hashable=False) 15 | 16 | 17 | # Examples from: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Equality_comparisons_and_sameness#comparing_equality_methods # noqa: E501 18 | @pytest.mark.parametrize( 19 | "x, y, equal", 20 | [ 21 | (JSUndefined, JSUndefined, True), 22 | (None, None, True), 23 | (True, True, True), 24 | (False, False, True), 25 | ("foo", "foo", True), 26 | (b"foo", b"foo", True), 27 | (0, 0, True), 28 | (0.0, 0.0, True), 29 | (-0.0, -0.0, True), 30 | (0, False, False), 31 | (0.0, False, False), 32 | ("", False, False), 33 | (b"", False, False), 34 | ("", 0, False), 35 | (b"", 0, False), 36 | ("0", 0, False), 37 | ("17", 17, False), 38 | ([1, 2], "1,2", False), 39 | (JSPrimitiveObject("foo"), "foo", False), 40 | (None, JSUndefined, False), 41 | (None, False, False), 42 | (JSUndefined, False, False), 43 | (JSObject(foo="bar"), JSObject(foo="bar"), False), 44 | (JSPrimitiveObject("foo"), JSPrimitiveObject("foo"), False), 45 | (0, None, False), 46 | (0, float("nan"), False), 47 | (float("nan"), float("nan"), True), 48 | ], 49 | ) 50 | def test_same_value_zero__mdn_examples(x: object, y: object, equal: bool) -> None: 51 | assert (same_value_zero(x) == same_value_zero(y)) is equal 52 | 53 | 54 | @given(x=values_and_objects, y=values_and_objects) 55 | def test_same_value_zero(x: object, y: object) -> None: 56 | both_same_constant = any( 57 | x is _ and y is _ for _ in [JSUndefined, None, True, False] 58 | ) 59 | both_same_string = isinstance(x, str) and isinstance(y, str) and x == y 60 | both_same_bytes = isinstance(x, bytes) and isinstance(y, bytes) and x == y 61 | both_nan = isinstance(x, float) and isinstance(y, float) and isnan(x) and isnan(y) 62 | # note: -0.0 == 0.0 by default in Python 63 | both_equal_numbers = ( 64 | isinstance(x, (int, float)) and isinstance(y, (int, float)) and x == y 65 | ) 66 | both_same_object = x is y 67 | should_be_equal = ( 68 | both_same_constant 69 | or both_same_string 70 | or both_same_bytes 71 | or both_nan 72 | or both_equal_numbers 73 | or both_same_object 74 | ) 75 | 76 | assert (same_value_zero(x) == same_value_zero(y)) is should_be_equal 77 | -------------------------------------------------------------------------------- /test/jstypes/test__v8_traceback.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | import sys 5 | from typing import Callable, cast 6 | from typing_extensions import Never 7 | 8 | import pytest 9 | from pytest_insta import SnapshotFixture 10 | 11 | from test.jstypes._v8_traceback_error_fixtures import ( 12 | ErrorScenario, 13 | call_and_capture_tbe, 14 | ) 15 | from v8serialize.jstypes._v8traceback import format_exception_for_v8 16 | 17 | FmtExc = Callable[[Callable[[], Never]], str] 18 | 19 | 20 | def call_and_format_exception_for_v8(fn: Callable[[], Never]) -> str: 21 | result = "".join(format_exception_for_v8(call_and_capture_tbe(fn))) 22 | 23 | if sys.version_info < (3, 11): 24 | # we use the exceptiongroup backport before 3.11, and its ExceptionGroup 25 | # is prefixed with its module name in tracebacks. We remove the prefix 26 | # to ignore this difference. 27 | result = re.sub(r"\bexceptiongroup\.ExceptionGroup\b", "ExceptionGroup", result) 28 | 29 | print(result) 30 | return result 31 | 32 | 33 | def call_and_format_exception_for_python(fn: Callable[[], Never]) -> str: 34 | result = "".join(call_and_capture_tbe(fn).format()) 35 | print(result) 36 | return result 37 | 38 | 39 | @pytest.fixture( 40 | params=[ 41 | pytest.param(call_and_format_exception_for_v8, id="v8"), 42 | pytest.param( 43 | call_and_format_exception_for_python, 44 | id="py", 45 | marks=pytest.mark.skipif( 46 | sys.version_info[:2] != (3, 12), 47 | reason="py traceback generated on py3.12 only as a reference " 48 | 'for "correct" behaviour', 49 | ), 50 | ), 51 | ] 52 | ) 53 | def fmt_exc(request: pytest.FixtureRequest) -> FmtExc: 54 | return cast(FmtExc, request.param) 55 | 56 | 57 | @pytest.fixture 58 | def errors() -> ErrorScenario: 59 | return ErrorScenario() 60 | 61 | 62 | def test_format_exception_for_v8__represents_simple_exception( 63 | snapshot: SnapshotFixture, fmt_exc: FmtExc, errors: ErrorScenario 64 | ) -> None: 65 | assert snapshot(".exc.txt") == fmt_exc(errors.raise_simple) 66 | 67 | 68 | def test_format_exception_for_v8__represents_exception_with_context( 69 | snapshot: SnapshotFixture, fmt_exc: FmtExc, errors: ErrorScenario 70 | ) -> None: 71 | assert snapshot(".exc.txt") == fmt_exc(errors.raise_context) 72 | 73 | 74 | def test_format_exception_for_v8__represents_ExceptionGroup( 75 | snapshot: SnapshotFixture, fmt_exc: FmtExc, errors: ErrorScenario 76 | ) -> None: 77 | assert snapshot(".exc.txt") == fmt_exc(errors.raise_group) 78 | 79 | 80 | def test_format_exception_for_v8__represents_ExceptionGroup_with_context( 81 | snapshot: SnapshotFixture, fmt_exc: FmtExc, errors: ErrorScenario 82 | ) -> None: 83 | assert snapshot(".exc.txt") == fmt_exc(errors.raise_group_with_context) 84 | 85 | 86 | def test_format_exception_for_v8__represents_nested_ExceptionGroups_with_context( 87 | snapshot: SnapshotFixture, fmt_exc: FmtExc, errors: ErrorScenario 88 | ) -> None: 89 | assert snapshot(".exc.txt") == fmt_exc(errors.raise_sub_group_with_context) 90 | -------------------------------------------------------------------------------- /test/jstypes/test_jsarray.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Sequence 4 | 5 | from v8serialize.jstypes.jsarray import JSArray 6 | from v8serialize.jstypes.jsarrayproperties import JSHole, JSHoleType 7 | from v8serialize.jstypes.jsobject import JSObject 8 | 9 | 10 | def test_init__array() -> None: 11 | assert dict(JSArray([])) == {} 12 | 13 | items1: list[str] = ["a", "b"] 14 | assert dict(JSArray[str](items1)) == {0: "a", 1: "b"} 15 | 16 | items2: list[str | JSHoleType] = ["a", "b", JSHole, "d"] 17 | assert dict(JSArray[str](items2)) == {0: "a", 1: "b", 3: "d"} 18 | 19 | assert dict(JSArray[str]([JSHole, "b"], x="X")) == {1: "b", "x": "X"} 20 | assert dict(JSArray[str](["a", "b"], x="X", **{"0": "override"})) == ( 21 | {0: "override", 1: "b", "x": "X"} 22 | ) 23 | 24 | 25 | def test_init__mapping() -> None: 26 | assert dict(JSArray({})) == {} 27 | 28 | items1: dict[str | int, str] = {0: "a", "1": "b"} 29 | assert dict(JSArray[str](items1)) == {0: "a", 1: "b"} 30 | 31 | items2: dict[str | int, str] = {0: "a", 1: "b", 3: "d"} 32 | assert dict(JSArray[str](items2)) == {0: "a", 1: "b", 3: "d"} 33 | 34 | assert dict(JSArray[str]({1: "b"}, x="X")) == {1: "b", "x": "X"} 35 | assert dict(JSArray[str]({0: "a", 1: "b"}, x="X", **{"0": "override"})) == ( 36 | {0: "override", 1: "b", "x": "X"} 37 | ) 38 | 39 | 40 | def test_jsarray_is_not_sequence() -> None: 41 | assert not isinstance(JSArray(), Sequence) 42 | # Its array properties are though 43 | assert isinstance(JSArray().array, Sequence) 44 | 45 | 46 | def test_abc_registration() -> None: 47 | class Example: 48 | pass 49 | 50 | assert not isinstance(Example(), JSArray) 51 | assert not isinstance(Example(), JSObject) 52 | JSArray.register(Example) 53 | assert isinstance(Example(), JSArray) 54 | # Registered objects also become subtypes of JSObject, because JSArray is. 55 | assert isinstance(Example(), JSObject) 56 | 57 | 58 | def test_eq() -> None: 59 | assert JSArray() == JSArray() 60 | assert JSArray(**{"0": 1}, a=1, b=2) == JSArray(**{"0": 1}, a=1, b=2) 61 | assert JSArray(a=1, b=2) == JSArray(a=1, b=2) 62 | 63 | assert JSArray() != [] 64 | assert JSArray([1, 2]) != [1, 2] 65 | assert JSArray([1, JSHole, 2]) != [1, 2] 66 | assert JSArray([], a=1) != [] 67 | assert JSArray([], a=1) != {"a": 1} 68 | assert JSArray([], a=1) != JSObject({"a": 1}) 69 | 70 | # Objects are not equal to equivalent arrays, like dicts are not equal to lists 71 | assert JSObject() != JSArray() 72 | assert JSObject(**{"0": 1}) != JSArray([1]) 73 | assert JSObject(a=1) != JSArray(a=1) 74 | 75 | 76 | def test_eq__cycle_direct() -> None: 77 | # Objects that contain reference cycles with the same identity structure are 78 | # equal. See test__recursive_eq.py. 79 | x = JSArray[object](a=1) 80 | x["b"] = x 81 | y = JSArray[object](a=1) 82 | y["b"] = y 83 | 84 | assert x == y 85 | 86 | 87 | def test__eq__cycle_direct_unequal() -> None: 88 | x = JSArray[object](a=1) 89 | x["b"] = x 90 | 91 | # Same shape as l, but identity is different as r and _r repeat alternately 92 | y_ = JSArray[object](a=1) 93 | y = JSArray[object](a=1) 94 | y_["b"] = y 95 | y["b"] = y_ 96 | 97 | assert x != y 98 | 99 | 100 | def test__eq__cycle_indirect() -> None: 101 | # Objects that contain reference cycles with the same identity structure are 102 | # equal. See test__recursive_eq.py. 103 | x = JSArray(a=1, b=(x_b := JSObject())) 104 | x_b["c"] = x 105 | y = JSArray(a=1, b=(y_b := JSObject())) 106 | y_b["c"] = y 107 | 108 | assert x == y 109 | 110 | 111 | def test__eq__cycle_indirect_unequal() -> None: 112 | x = JSArray(a=1, b=(x_b := JSArray())) 113 | x_b["c"] = x 114 | 115 | # Same shape as l, but identity is different as r and _r repeat alternately 116 | y_ = JSArray(a=1, b=(y__b := JSObject())) 117 | y = JSArray(a=1, b=(y_b := JSObject())) 118 | y__b["c"] = y 119 | y_b["c"] = y_ 120 | 121 | assert x != y 122 | -------------------------------------------------------------------------------- /test/jstypes/test_jsbigint.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import operator 4 | import sys 5 | from typing import Any 6 | 7 | from test.utils import typeval 8 | from v8serialize.jstypes.jsbigint import JSBigInt 9 | 10 | 11 | def test_repr() -> None: 12 | assert repr(JSBigInt(42)) == "JSBigInt(42)" 13 | 14 | 15 | def test_is_instanceof_int() -> None: 16 | assert isinstance(JSBigInt(42), int) 17 | 18 | 19 | def check_int_attr(name: str, *, value: int = 42) -> None: 20 | jsbigint = JSBigInt(value) 21 | i = int(value) 22 | assert typeval(getattr(jsbigint, name)) == (JSBigInt, getattr(i, name)) 23 | 24 | 25 | def check_int_method(name: str, *args: Any, value: int = 42) -> None: 26 | jsbigint = JSBigInt(value) 27 | int_value = int(value) 28 | 29 | expected: int | tuple[int, ...] = getattr(int_value, name)(*args) 30 | actual: JSBigInt | tuple[JSBigInt, ...] = getattr(jsbigint, name)(*args) 31 | 32 | if isinstance(expected, tuple): 33 | assert isinstance(actual, tuple) 34 | assert expected == actual 35 | assert set(type(v) for v in actual) == {JSBigInt} 36 | else: 37 | assert typeval(actual) == (JSBigInt, expected) 38 | 39 | 40 | def test_methods_and_operators_return_JSBigInt() -> None: 41 | """All the methods returning int values return JSBigInt.""" 42 | # Same order as methods defined in JSBigInt 43 | check_int_method("as_integer_ratio") 44 | check_int_attr("real") 45 | check_int_attr("imag") 46 | check_int_attr("numerator") 47 | check_int_attr("denominator") 48 | check_int_method("conjugate") 49 | check_int_method("__add__", 2) 50 | check_int_method("__sub__", 2) 51 | check_int_method("__mul__", 2) 52 | check_int_method("__floordiv__", 2) 53 | check_int_method("__mod__", 2) 54 | check_int_method("__divmod__", 2) 55 | check_int_method("__radd__", 2) 56 | check_int_method("__rsub__", 2) 57 | check_int_method("__rmul__", 2) 58 | check_int_method("__rfloordiv__", 2) 59 | check_int_method("__rmod__", 2) 60 | check_int_method("__rdivmod__", 2) 61 | check_int_method("__pow__", 2) 62 | check_int_method("__pow__", 2, 100) 63 | check_int_method("__rpow__", 2) 64 | check_int_method("__rpow__", 2, 100) 65 | check_int_method("__and__", 2) 66 | check_int_method("__or__", 2) 67 | check_int_method("__xor__", 2) 68 | check_int_method("__lshift__", 2) 69 | check_int_method("__rshift__", 2) 70 | check_int_method("__rand__", 2) 71 | check_int_method("__ror__", 2) 72 | check_int_method("__rxor__", 2) 73 | check_int_method("__rlshift__", 2) 74 | check_int_method("__rrshift__", 2) 75 | check_int_method("__neg__") 76 | check_int_method("__pos__") 77 | check_int_method("__invert__") 78 | check_int_method("__trunc__") 79 | check_int_method("__ceil__") 80 | check_int_method("__floor__") 81 | check_int_method("__round__") 82 | check_int_method("__round__", 2) 83 | assert typeval(float(JSBigInt(42))) == (float, 42.0) 84 | assert typeval(JSBigInt(42).__int__()) == (int, 42) 85 | assert typeval(int(JSBigInt(42))) == (int, 42) 86 | check_int_method("__abs__") 87 | assert typeval(hash(JSBigInt(42))) == (int, 42) 88 | 89 | # Before 3.10 operator.index() returns the instance without calling the 90 | # __index__ method, so the type is JSBigInt (we'd rather it was int). 91 | # https://github.com/python/cpython/blob/340a82d9cff7127bb5a777d8b9a30b861bb4beee/Objects/abstract.c#L1324 92 | if sys.version_info >= (3, 10): 93 | assert typeval(operator.index(JSBigInt(42))) == (int, 42) 94 | else: 95 | assert typeval(operator.index(JSBigInt(42))) == (JSBigInt, 42) 96 | 97 | 98 | def test_types() -> None: 99 | # Can assign to int var 100 | i: int = JSBigInt(42) + 3 101 | # Types reflect that JSBigInt is preserved by operators 102 | big: JSBigInt = 4 * JSBigInt(4) 103 | 104 | assert i == 45 105 | assert big == 16 106 | -------------------------------------------------------------------------------- /test/jstypes/test_jserror.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from v8serialize._values import AnyJSError 4 | from v8serialize.constants import JSErrorName 5 | from v8serialize.jstypes.jserror import JSError, JSErrorData 6 | 7 | 8 | def test_JSErrorData_types() -> None: 9 | # Verify that JSErrorData satisfies the AnyJSError protocol 10 | any_error: AnyJSError = JSErrorData( 11 | name=JSErrorName.Error, message="Oops", stack="..." 12 | ) 13 | assert any_error.name == JSErrorName.Error 14 | assert any_error.message == "Oops" 15 | assert any_error.stack == "..." 16 | 17 | 18 | def test_JSError_types() -> None: 19 | # We don't inherit AnyJSError protocol directly because its @properties 20 | # affect the subclass, but JSError must satisfy its type signature. 21 | any_error: AnyJSError = JSError("msg") 22 | # JSError is a virtual subclass of JSErrorData, this is how the encoder 23 | # recognises it as a AnyJSError implementation. 24 | assert isinstance(any_error, JSErrorData) 25 | 26 | 27 | def test_JSError_init() -> None: 28 | jserror = JSError("msg", name=JSErrorName.UriError, stack="...", cause={}) 29 | assert jserror.message == "msg" 30 | assert jserror.name == JSErrorName.UriError 31 | assert jserror.stack == "..." 32 | assert jserror.cause == {} 33 | 34 | 35 | def test_JSError_builder() -> None: 36 | jserror, state = JSError.builder( 37 | JSErrorData(message="msg", name=JSErrorName.UriError, stack="...", cause={}) 38 | ) 39 | assert jserror is state 40 | state.name = JSErrorName.EvalError 41 | state.message = "example" 42 | state.cause = 1 43 | state.stack = "foo" 44 | assert jserror == JSError( 45 | "example", name=JSErrorName.EvalError, stack="foo", cause=1 46 | ) 47 | 48 | 49 | def test_JSError_repr() -> None: 50 | jserror = JSError("msg", name=JSErrorName.UriError, stack="Boom", cause={}) 51 | assert repr(jserror) == ( 52 | "JSError('msg', name=, stack='Boom', " 53 | "cause={})" 54 | ) 55 | 56 | # repr is recursion safe with cycles in cause 57 | jserror.cause = jserror 58 | assert ( 59 | repr(jserror) == "JSError(" 60 | "'msg', name=, stack='Boom', cause=...)" 61 | ) 62 | 63 | 64 | def test_JSError__eq() -> None: 65 | e1 = JSError("foo") 66 | e2 = JSError("foo") 67 | 68 | # JSError are equal as normal (unless they have different tracebacks) 69 | assert e1 == e2 70 | 71 | # recursive cause does not blow up == check 72 | e1.cause = e1 73 | e2.cause = e2 74 | assert e1 == e2 75 | 76 | e1.cause = [e1] 77 | e2.cause = [e2] 78 | assert e1 == e2 79 | 80 | # JSErrors which have been raised have tracebacks, which are not equal for 81 | # distinct raises. 82 | for e in [e1, e2]: 83 | try: 84 | raise e 85 | except JSError: 86 | pass 87 | 88 | assert e1 != e2 89 | 90 | 91 | def test_JSErrorData__eq() -> None: 92 | # Unlike JSError, JSErrorData objects are equal by value 93 | e1 = JSErrorData(name=JSErrorName.EvalError, message="Hi", stack="Xyz", cause=42) 94 | e2 = JSErrorData(name=JSErrorName.EvalError, message="Hi", stack="Xyz", cause=42) 95 | 96 | assert e1 == e2 97 | 98 | # Errors with recursive causes can be eq 99 | e1.cause = JSErrorData(cause=e1) 100 | e2.cause = JSErrorData(cause=e2) 101 | 102 | assert e1 == e2 103 | 104 | # Or not eq 105 | e2.cause.message = "Blah" 106 | 107 | assert e1 != e2 108 | -------------------------------------------------------------------------------- /test/jstypes/test_jsmap.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from math import isnan 4 | 5 | from hypothesis import given 6 | from hypothesis import strategies as st 7 | 8 | from test.strategies import values_and_objects as mk_values_and_objects 9 | from v8serialize.jstypes._equality import same_value_zero 10 | from v8serialize.jstypes._v8 import V8SharedObjectReference, V8SharedValueId 11 | from v8serialize.jstypes.jsmap import JSMap 12 | 13 | hashable_values_and_objects = mk_values_and_objects(allow_nan=False, only_hashable=True) 14 | values_and_objects = mk_values_and_objects(allow_nan=False, only_hashable=False) 15 | 16 | entries = st.lists( 17 | elements=st.tuples(values_and_objects, values_and_objects), 18 | unique_by=lambda t: same_value_zero(t[0]), 19 | ) 20 | 21 | 22 | @given( 23 | mapping=st.dictionaries(keys=hashable_values_and_objects, values=values_and_objects) 24 | ) 25 | def test_equal_to_other_mappings_containing_same_object_instances( 26 | mapping: dict[object, object], 27 | ) -> None: 28 | assert JSMap(mapping.items()) == mapping 29 | 30 | 31 | ID = V8SharedValueId(0) 32 | 33 | 34 | def test_equal_to_other_mappings_containing_different_object_instances() -> None: 35 | k1, k2 = V8SharedObjectReference(ID), V8SharedObjectReference(ID) 36 | assert k1 is not k2 37 | assert k1 == k2 38 | 39 | # JSMap instances are eq from the outside if they contain equal elements in 40 | # the same order 41 | jsmap_1_2, jsmap_2_1 = JSMap([(k1, 0), (k2, 0)]), JSMap([(k2, 0), (k1, 0)]) 42 | 43 | assert jsmap_1_2 == jsmap_2_1 44 | 45 | # Regular dicts are equal by dict's idea of equality (de-dupe equal keys) 46 | jsmap_1, jsmap_2 = JSMap([(k1, 0), (0, 0)]), JSMap([(0, 0), (k2, 0)]) 47 | assert jsmap_1 == {V8SharedObjectReference(ID): 0, 0: 0} 48 | assert jsmap_2 == {V8SharedObjectReference(ID): 0, 0: 0} 49 | 50 | # JSMaps with different item orders are not equal 51 | assert jsmap_1 != jsmap_2 52 | 53 | # Unequal lengths are not equal 54 | assert jsmap_1_2 != {V8SharedObjectReference(ID): 0} 55 | assert jsmap_2_1 != {V8SharedObjectReference(ID): 0} 56 | # ... despite them being equal if they were de-duped 57 | assert dict(jsmap_1_2.items()) == {V8SharedObjectReference(ID): 0} 58 | assert dict(jsmap_2_1.items()) == {V8SharedObjectReference(ID): 0} 59 | 60 | 61 | def test_eq_with_unhashable_keys() -> None: 62 | assert JSMap([({}, 1), ({}, 2)]) != {1: "a", 2: "b"} 63 | assert {1: "a", 2: "b"} != JSMap([({}, 1), ({}, 2)]) 64 | 65 | 66 | def test_eq_with_other_type() -> None: 67 | assert JSMap().__eq__(object()) is NotImplemented 68 | assert not (JSMap() == object()) 69 | 70 | 71 | def test_eq_with_cycles() -> None: 72 | x = JSMap({}) 73 | x[x] = x 74 | y = JSMap({}) 75 | y[y] = y 76 | assert x == y 77 | 78 | # different equality pattern 79 | x = JSMap({}) 80 | x[x] = x 81 | y_ = JSMap({}) 82 | y = JSMap({}) 83 | y[y_] = y_ 84 | y_[y] = y 85 | assert x != y 86 | 87 | 88 | def test_eq_with_cycles_indirect() -> None: 89 | # indirect 90 | x = JSMap({0: JSMap()}) 91 | x[0][x] = x 92 | y = JSMap[int, JSMap]({0: JSMap()}) 93 | y[0][y] = y 94 | assert x == y 95 | 96 | 97 | def test_jsmap_nan() -> None: 98 | m = JSMap[float]() 99 | nan = float("nan") 100 | m[nan] = 1 101 | assert m[nan] == 1 102 | assert isnan(list(m)[0]) 103 | assert nan in m 104 | m[nan] = 2 105 | assert len(m) == 1 106 | assert m[nan] == 2 107 | del m[nan] 108 | assert len(m) == 0 109 | 110 | 111 | @given(entries=entries) 112 | def test_crud(entries: list[tuple[object, object]]) -> None: 113 | m = JSMap() 114 | assert len(m) == 0 115 | for i, (k, v) in enumerate(entries): 116 | assert k not in m 117 | m[k] = v 118 | assert len(m) == i + 1 119 | assert k in m 120 | assert m[k] is v 121 | 122 | assert list(m.items()) == entries 123 | assert list(m) == [k for k, _ in entries] 124 | assert list(m.values()) == [v for _, v in entries] 125 | 126 | for i, (k, _) in enumerate(entries): 127 | assert k in m 128 | del m[k] 129 | assert k not in m 130 | assert len(m) == len(entries) - 1 - i 131 | 132 | assert m == {} 133 | 134 | 135 | def test_init_types() -> None: 136 | assert JSMap() == {} 137 | 138 | a = JSMap(a=1, b=2) 139 | b = JSMap([("a", 1), ("b", 2)]) 140 | c = JSMap([("a", 1)], b=2) 141 | d = JSMap({"a": 1, "b": 2}) 142 | e = JSMap({"a": 1}, b=2) 143 | 144 | maps = [a, b, c, d, e] 145 | for m in maps: 146 | assert m == {"a": 1, "b": 2} 147 | assert list(m) == ["a", "b"] 148 | 149 | 150 | def test_repr() -> None: 151 | assert repr(JSMap(c=5, a=1, b=2)) == "JSMap({'c': 5, 'a': 1, 'b': 2})" 152 | 153 | 154 | def test__str() -> None: 155 | m = JSMap({"a": 1}) 156 | assert str(m) == repr(m) 157 | 158 | 159 | def test_abc_register() -> None: 160 | class FooMapping: 161 | pass 162 | 163 | JSMap.register(FooMapping) 164 | assert isinstance(FooMapping(), JSMap) 165 | -------------------------------------------------------------------------------- /test/jstypes/test_jsobject.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from types import MappingProxyType 4 | 5 | import pytest 6 | 7 | from v8serialize.jstypes.jsarray import JSArray 8 | from v8serialize.jstypes.jsarrayproperties import JSHole 9 | from v8serialize.jstypes.jsobject import JSObject 10 | 11 | 12 | def test_init() -> None: 13 | assert dict(JSObject()) == {} 14 | 15 | with pytest.raises(TypeError, match=r"'NoneType' object is not iterable"): 16 | JSObject(None) # type: ignore[call-overload] 17 | 18 | 19 | def test_init__keys_and_get_item() -> None: 20 | assert dict(JSObject({})) == {} 21 | assert dict(JSObject({"x": "X"})) == {"x": "X"} 22 | # Supports arbitrary mapping types 23 | assert dict(JSObject(MappingProxyType({"x": "X"}))) == {"x": "X"} 24 | assert dict(JSObject({"x": "X", 0: "zero"})) == {0: "zero", "x": "X"} 25 | assert dict(JSObject({"x": "XA", 0: "zeroA", "0": "zeroB"}, x="XB")) == ( 26 | {0: "zeroB", "x": "XB"} 27 | ) 28 | 29 | 30 | def test_init__iterable_kv_pairs() -> None: 31 | assert dict(JSObject([])) == {} 32 | assert dict(JSObject([("x", "X")])) == {"x": "X"} 33 | assert dict(JSObject(iter([("x", "X")]))) == {"x": "X"} 34 | assert dict(JSObject([("x", "X"), (0, "zero")])) == {0: "zero", "x": "X"} 35 | assert dict(JSObject([("x", "XA"), (0, "zeroA"), ("0", "zeroB")], x="XB")) == ( 36 | {0: "zeroB", "x": "XB"} 37 | ) 38 | 39 | with pytest.raises(ValueError, match=r"not enough values to unpack"): 40 | JSObject(["a", "b", "c"]) # type: ignore[list-item] 41 | 42 | 43 | def test_jshole_assignment() -> None: 44 | # Properties created with JSHole values are the same as not providing them 45 | obj = JSObject({0: "a", 1: JSHole, 2: "c", "x": "X", "y": JSHole}) 46 | assert dict(obj) == {0: "a", 2: "c", "x": "X"} 47 | 48 | # Assigning JSHole values to existing keys removes them 49 | assert 0 in obj 50 | obj[0] = JSHole 51 | assert 0 not in obj 52 | 53 | assert "x" in obj 54 | obj["x"] = JSHole 55 | assert "x" not in obj 56 | 57 | 58 | def test_write_methods_accept_float_keys_for_compatibility_with_v8_serialized_data() -> ( # noqa: E501 59 | None 60 | ): 61 | obj = JSObject() 62 | obj[-0.0] = "foo" 63 | assert obj["0"] == "foo" 64 | assert obj[0] == "foo" 65 | # works, but type error because I don't want to encourage using floats normally 66 | assert obj[0.0] == "foo" # type: ignore[index] 67 | assert obj[-0.0] == "foo" # type: ignore[index] 68 | 69 | obj.setdefault(1.0, "one") 70 | assert obj["1"] == "one" 71 | assert obj[1] == "one" 72 | assert obj[1.0] == "one" # type: ignore[index] 73 | 74 | obj.update({-0.0: "x", 1.0: "y", "2": "z"}) 75 | obj.update({3: "a", 4.0: "b"}) 76 | obj.update({"5": "c", 6.0: "d", -1.0: "!", "-0": "!!"}) 77 | 78 | assert dict(obj) == { 79 | 0: "x", 80 | 1: "y", 81 | 2: "z", 82 | 3: "a", 83 | 4: "b", 84 | 5: "c", 85 | 6: "d", 86 | "-1": "!", 87 | "-0": "!!", 88 | } 89 | 90 | 91 | def test__eq() -> None: 92 | assert JSObject() == JSObject() 93 | assert JSObject(**{"0": 1}, a=1, b=2) == JSObject(**{"0": 1}, a=1, b=2) 94 | assert JSObject(a=1, b=2) == JSObject(a=1, b=2) 95 | 96 | assert JSObject() != {} 97 | assert JSObject(**{"0": 1}, a=1, b=2) != {0: 1, "a": 1, "b": 2} 98 | assert JSObject(a=1, b=2) != dict(a=1, b=2) 99 | 100 | # Objects are not equal to equivalent arrays, like dicts are not equal to lists 101 | assert JSObject() != JSArray() 102 | assert JSObject(**{"0": 1}) != JSArray([1]) 103 | assert JSObject(a=1) != JSArray(a=1) 104 | 105 | 106 | def test__eq__cycle_direct() -> None: 107 | # Objects that contain reference cycles with the same identity structure are 108 | # equal. See test__recursive_eq.py. 109 | x = JSObject[object](a=1) 110 | x["b"] = x 111 | y = JSObject[object](a=1) 112 | y["b"] = y 113 | 114 | assert x == y 115 | 116 | 117 | def test__eq__cycle_direct_unequal() -> None: 118 | x = JSObject[object](a=1) 119 | x["b"] = x 120 | 121 | # Same shape as l, but identity is different as r and _r repeat alternately 122 | y_ = JSObject[object](a=1) 123 | y = JSObject[object](a=1) 124 | y_["b"] = y 125 | y["b"] = y_ 126 | 127 | assert x != y 128 | 129 | 130 | def test__eq__cycle_indirect() -> None: 131 | # Objects that contain reference cycles with the same identity structure are 132 | # equal. See test__recursive_eq.py. 133 | x = JSObject(a=1, b=(x_b := JSObject())) 134 | x_b["c"] = x 135 | y = JSObject(a=1, b=(y_b := JSObject())) 136 | y_b["c"] = y 137 | 138 | assert x == y 139 | 140 | 141 | def test__eq__cycle_indirect_unequal() -> None: 142 | x = JSObject(a=1, b=(x_b := JSObject())) 143 | x_b["c"] = x 144 | 145 | # Same shape as l, but identity is different as r and _r repeat alternately 146 | y_ = JSObject(a=1, b=(y__b := JSObject())) 147 | y = JSObject(a=1, b=(y_b := JSObject())) 148 | y__b["c"] = y 149 | y_b["c"] = y_ 150 | 151 | assert x != y 152 | 153 | 154 | def test_abc_registration() -> None: 155 | class Example: 156 | pass 157 | 158 | assert not isinstance(Example(), JSObject) 159 | JSObject.register(Example) 160 | assert isinstance(Example(), JSObject) 161 | -------------------------------------------------------------------------------- /test/jstypes/test_jsprimitiveobject.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | from typing_extensions import assert_type 3 | 4 | from v8serialize.constants import FLOAT64_SAFE_INT_RANGE, SerializationTag 5 | from v8serialize.jstypes import JSPrimitiveObject 6 | from v8serialize.jstypes.jsbigint import JSBigInt 7 | from v8serialize.jstypes.jsprimitiveobject import ( 8 | BigIntJSPrimitiveObject, 9 | FalseJSPrimitiveObject, 10 | NumberJSPrimitiveObject, 11 | StringJSPrimitiveObject, 12 | TrueJSPrimitiveObject, 13 | ) 14 | 15 | # 2**53 + 2 is an integer that can be represented exactly as a float64, but is 16 | # outside the limit of sequential integers that float64 can represent. 17 | # (e.g. float64 cannot represent 2**53 + 1 exactly.) 18 | UNSAFE_INTEGER_FLOAT: Final = float(2**53 + 2) 19 | assert int(UNSAFE_INTEGER_FLOAT) == 2**53 + 2 20 | 21 | 22 | def test_init_types() -> None: 23 | true_obj = assert_type(JSPrimitiveObject(True), TrueJSPrimitiveObject) 24 | assert true_obj.value is True 25 | assert true_obj.tag is SerializationTag.kTrueObject 26 | 27 | false_obj = assert_type(JSPrimitiveObject(False), FalseJSPrimitiveObject) 28 | assert false_obj.value is False 29 | assert false_obj.tag is SerializationTag.kFalseObject 30 | 31 | number_obj = assert_type(JSPrimitiveObject(1.0), NumberJSPrimitiveObject) 32 | assert number_obj.value == 1.0 33 | assert type(number_obj.value) is float 34 | assert number_obj.tag is SerializationTag.kNumberObject 35 | 36 | # int values are mapped to JavaScript Number when in the safe int range, 37 | # or JavaScript bigint outside that. 38 | 39 | # mypy doesn't match the ambiguous int overload for some reason, it falls 40 | # back to the default typevar bounds. 41 | int_bigint_obj = assert_type( # type: ignore[assert-type] 42 | JSPrimitiveObject(FLOAT64_SAFE_INT_RANGE.stop), 43 | "NumberJSPrimitiveObject | BigIntJSPrimitiveObject", 44 | ) 45 | assert int_bigint_obj.value == FLOAT64_SAFE_INT_RANGE.stop 46 | assert type(int_bigint_obj.value) is JSBigInt 47 | assert int_bigint_obj.tag is SerializationTag.kBigIntObject 48 | 49 | # force int as bigint by specifying tag 50 | int_bigint_obj2 = assert_type( 51 | JSPrimitiveObject(0, tag=SerializationTag.kBigIntObject), 52 | BigIntJSPrimitiveObject, 53 | ) 54 | assert int_bigint_obj2.value == 0 55 | assert type(int_bigint_obj2.value) is JSBigInt 56 | assert int_bigint_obj2.tag is SerializationTag.kBigIntObject 57 | 58 | # mypy doesn't match the ambiguous int overload for some reason 59 | int_number_obj = assert_type( # type: ignore[assert-type] 60 | JSPrimitiveObject(FLOAT64_SAFE_INT_RANGE.stop - 1), 61 | "NumberJSPrimitiveObject | BigIntJSPrimitiveObject", 62 | ) 63 | assert int_number_obj.value == FLOAT64_SAFE_INT_RANGE.stop - 1 64 | assert type(int_number_obj.value) is float 65 | assert int_number_obj.tag is SerializationTag.kNumberObject 66 | 67 | # force int as number by specifying tag 68 | int_number_obj2 = assert_type( 69 | JSPrimitiveObject( 70 | int(UNSAFE_INTEGER_FLOAT), tag=SerializationTag.kNumberObject 71 | ), 72 | NumberJSPrimitiveObject, 73 | ) 74 | assert int_number_obj2.value == UNSAFE_INTEGER_FLOAT 75 | assert type(int_number_obj2.value) is float 76 | assert int_number_obj2.tag is SerializationTag.kNumberObject 77 | 78 | string_obj = assert_type(JSPrimitiveObject("s"), StringJSPrimitiveObject) 79 | assert string_obj.value == "s" 80 | assert string_obj.tag is SerializationTag.kStringObject 81 | -------------------------------------------------------------------------------- /test/jstypes/test_jsregexp.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | 5 | import pytest 6 | from hypothesis import given 7 | 8 | from test.strategies import js_regexp_flags 9 | from v8serialize._errors import JSRegExpV8SerializeError 10 | from v8serialize.constants import JSRegExpFlag 11 | from v8serialize.jstypes.jsregexp import JSRegExp 12 | 13 | 14 | @pytest.mark.parametrize( 15 | "jsregexp,msg", 16 | [ 17 | pytest.param( 18 | JSRegExp(r".*", JSRegExpFlag.Linear), 19 | "No equivalent Python flags exist for JSRegExp.Linear", 20 | id="invalid_flags", 21 | ), 22 | pytest.param( 23 | JSRegExp(r"\cJ"), 24 | "bad escape \\c at position 0", 25 | id="invalid_syntax", 26 | ), 27 | ], 28 | ) 29 | def test_compile__incompatible(jsregexp: JSRegExp, msg: str) -> None: 30 | with pytest.raises( 31 | JSRegExpV8SerializeError, 32 | match=re.escape(f"JSRegExp is not a valid Python re.Pattern: {msg}"), 33 | ): 34 | jsregexp.as_python_pattern() 35 | 36 | assert jsregexp.as_python_pattern(throw=False) is None 37 | 38 | 39 | def test_from_python_pattern() -> None: 40 | assert JSRegExp.from_python_pattern( 41 | re.compile(".*", re.UNICODE | re.DOTALL) 42 | ) == JSRegExp(".*", JSRegExpFlag.UnicodeSets | JSRegExpFlag.DotAll) 43 | 44 | assert JSRegExp.from_python_pattern(re.compile(b".*")) == JSRegExp(".*") 45 | 46 | with pytest.raises( 47 | JSRegExpV8SerializeError, 48 | match=re.escape( 49 | "Python re.Pattern flags cannot be represented by JavaScript RegExp: " 50 | "No equivalent JavaScript RegExp flags exist for RegexFlag.VERBOSE" 51 | ), 52 | ): 53 | JSRegExp.from_python_pattern(re.compile("", re.VERBOSE)) 54 | 55 | assert JSRegExp.from_python_pattern(re.compile("", re.VERBOSE), throw=False) is None 56 | 57 | 58 | def test_empty_source_is_non_capturing_group() -> None: 59 | assert JSRegExp(source="").source == "(?:)" 60 | 61 | 62 | @given(any_flags=js_regexp_flags()) 63 | def test_flags_cannot_have_both_unicode_flags_set(any_flags: JSRegExpFlag) -> None: 64 | with pytest.raises( 65 | ValueError, match=r"The Unicode and UnicodeSets flags cannot be set together" 66 | ): 67 | JSRegExp("", flags=any_flags | JSRegExpFlag.Unicode | JSRegExpFlag.UnicodeSets) 68 | -------------------------------------------------------------------------------- /test/jstypes/test_jsset.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from math import isnan 4 | 5 | from hypothesis import given 6 | from hypothesis import strategies as st 7 | 8 | from test.strategies import values_and_objects as mk_values_and_objects 9 | from v8serialize.jstypes._equality import same_value_zero 10 | from v8serialize.jstypes._v8 import V8SharedObjectReference, V8SharedValueId 11 | from v8serialize.jstypes.jsset import JSSet 12 | 13 | hashable_values_and_objects = mk_values_and_objects(allow_nan=False, only_hashable=True) 14 | values_and_objects = mk_values_and_objects(allow_nan=False, only_hashable=False) 15 | 16 | elements = st.lists(elements=values_and_objects, unique_by=same_value_zero) 17 | 18 | 19 | @given(set_=st.sets(elements=hashable_values_and_objects)) 20 | def test_equal_to_other_sets(set_: set[object]) -> None: 21 | assert JSSet(set_) == set_ 22 | 23 | 24 | ID = V8SharedValueId(0) 25 | 26 | 27 | def test_equal_to_other_sets_containing_different_object_instances() -> None: 28 | k1, k2 = V8SharedObjectReference(ID), V8SharedObjectReference(ID) 29 | assert k1 is not k2 30 | assert k1 == k2 31 | 32 | # JSSet instances are eq from the outside if they contain equal elements in 33 | # the same order 34 | jsset_1_2, jsset_2_1 = JSSet([k1, k2]), JSSet([k2, k1]) 35 | 36 | assert jsset_1_2 == jsset_2_1 37 | 38 | # Regular sets are equal by set's idea of equality (de-dupe equal members) 39 | jsset_1, jsset_2 = JSSet([k1, 0]), JSSet([0, k2]) 40 | assert jsset_1 == {V8SharedObjectReference(ID), 0} 41 | assert jsset_2 == {V8SharedObjectReference(ID), 0} 42 | 43 | # JSSets with different member orders are not equal 44 | assert jsset_1 != jsset_2 45 | 46 | # Unequal lengths are not equal 47 | assert jsset_1_2 != {V8SharedObjectReference(ID)} 48 | assert jsset_2_1 != {V8SharedObjectReference(ID)} 49 | # ... despite them being equal if they were de-duped 50 | assert set(jsset_1_2) == {V8SharedObjectReference(ID)} 51 | assert set(jsset_2_1) == {V8SharedObjectReference(ID)} 52 | 53 | 54 | def test_eq_with_unhashable_elements() -> None: 55 | assert JSSet([{}, {}]) != {1, 2} 56 | assert {1, 2} != JSSet([{}, {}]) 57 | 58 | 59 | def test_eq_with_other_type() -> None: 60 | assert JSSet().__eq__(object()) is NotImplemented 61 | assert not (JSSet() == object()) 62 | 63 | 64 | def test_eq_with_cycles() -> None: 65 | x = JSSet([]) 66 | x.add(x) 67 | y = JSSet([]) 68 | y.add(y) 69 | assert x == y 70 | 71 | # different equality pattern 72 | x = JSSet([]) 73 | x.add(x) 74 | y_ = JSSet([]) 75 | y = JSSet([]) 76 | y.add(y_) 77 | y_.add(y) 78 | assert x != y 79 | 80 | # indirect 81 | x = JSSet([inner_x := JSSet()]) 82 | inner_x.add(x) 83 | y = JSSet([inner_y := JSSet()]) 84 | inner_y.add(y) 85 | assert x == y 86 | 87 | 88 | def test_nan() -> None: 89 | s = JSSet[float]() 90 | nan = float("nan") 91 | s.add(nan) 92 | assert len(s) == 1 93 | assert isnan(next(iter(s))) 94 | assert nan in s 95 | s.add(nan) 96 | assert len(s) == 1 97 | s.remove(nan) 98 | assert len(s) == 0 99 | 100 | 101 | @given(elements=elements) 102 | def test_crud(elements: list[object]) -> None: 103 | s = JSSet() 104 | assert len(s) == 0 105 | for i, e in enumerate(elements): 106 | assert e not in s 107 | s.add(e) 108 | assert len(s) == i + 1 109 | assert e in s 110 | 111 | assert list(s) == elements 112 | 113 | for i, e in enumerate(elements): 114 | assert e in s 115 | s.remove(e) 116 | assert e not in s 117 | assert len(s) == len(elements) - 1 - i 118 | 119 | assert s == set() 120 | 121 | 122 | def test_jsset__init_types() -> None: 123 | assert JSSet() == set() 124 | 125 | s = JSSet(["c", "a", "b"]) 126 | assert s == {"c", "a", "b"} 127 | assert list(s) == ["c", "a", "b"] 128 | 129 | 130 | def test_repr() -> None: 131 | assert repr(JSSet(["a", True, {}])) == "JSSet(['a', True, {}])" 132 | 133 | 134 | def test_str() -> None: 135 | s = JSSet([1, 2]) 136 | assert str(s) == repr(s) 137 | 138 | 139 | def test_abc_register() -> None: 140 | class FooSet: 141 | pass 142 | 143 | JSSet.register(FooSet) 144 | assert isinstance(FooSet(), JSSet) 145 | -------------------------------------------------------------------------------- /test/jstypes/test_normalise_property_key.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | 5 | from hypothesis import example, given 6 | from hypothesis import strategies as st 7 | 8 | from v8serialize.jstypes._normalise_property_key import ( 9 | canonical_numeric_index_string, 10 | normalise_property_key, 11 | ) 12 | from v8serialize.jstypes.jsarrayproperties import MAX_ARRAY_LENGTH 13 | 14 | int_integer_indexes = st.integers(min_value=0, max_value=MAX_ARRAY_LENGTH - 1) 15 | float_integer_indexes = st.one_of(st.just(-0.0), int_integer_indexes.map(float)) 16 | integer_indexes = st.one_of(int_integer_indexes, float_integer_indexes) 17 | 18 | invalid_integer_indexes = st.one_of( 19 | st.integers(max_value=-1), st.integers(min_value=MAX_ARRAY_LENGTH) 20 | ) 21 | """Integers below or above the allowed range for array indexes.""" 22 | 23 | invalid_float_indexes = st.one_of( 24 | st.floats(min_value=0, max_value=MAX_ARRAY_LENGTH - 1).filter( 25 | lambda f: not f.is_integer() 26 | ), 27 | st.floats(max_value=-0.0, exclude_max=True), # exclude -0.0, which is valid 28 | st.floats(min_value=MAX_ARRAY_LENGTH), 29 | ) 30 | 31 | 32 | non_canonical_integers = st.integers().map(lambda i: f"0{i}") 33 | # Note: this includes the string "-0" which does not match index 0. 34 | # See test_normalise_property_key__handles_negative_zero(). 35 | negative_integer_strings = st.integers(min_value=0).map(lambda i: f"-{i}") 36 | non_int_strings = st.text().filter(lambda x: not is_base10_int_str(x)) 37 | 38 | non_index_strings = st.one_of( 39 | non_canonical_integers, negative_integer_strings, non_int_strings 40 | ) 41 | 42 | 43 | def is_base10_int_str(value: str) -> bool: 44 | return bool(re.match(r"^(0|[1-9][0-9]*)$", value)) 45 | 46 | 47 | @given(valid_index=int_integer_indexes) 48 | def test_canonical_numeric_index_string__range__matches_valid_indexes( 49 | valid_index: int, 50 | ) -> None: 51 | result = canonical_numeric_index_string(str(valid_index)) 52 | assert result == valid_index 53 | 54 | 55 | @given(non_index=non_index_strings) 56 | @example("-0") 57 | def test_canonical_numeric_index_string__range__rejects_non_index_strings( 58 | non_index: str, 59 | ) -> None: 60 | result = canonical_numeric_index_string(non_index) 61 | assert result is None 62 | 63 | 64 | @given(valid_index=integer_indexes) 65 | def test_normalise_property_key__returns_int_for_integers_in_array_index_range( 66 | valid_index: int | float, 67 | ) -> None: 68 | assert normalise_property_key(valid_index) == valid_index 69 | 70 | 71 | @given(valid_index=int_integer_indexes) 72 | def test_normalise_property_key__returns_int_for_canonical_integer_strings_in_array_index_range( # noqa: E501 73 | valid_index: int, 74 | ) -> None: 75 | assert normalise_property_key(str(valid_index)) == valid_index 76 | 77 | 78 | @given(invalid_index=invalid_integer_indexes) 79 | def test_normalise_property_key__returns_str_for_ints_not_in_array_index_range( 80 | invalid_index: int, 81 | ) -> None: 82 | assert normalise_property_key(invalid_index) == str(invalid_index) 83 | 84 | 85 | @given(invalid_index=invalid_integer_indexes) 86 | def test_normalise_property_key__returns_str_for_str_ints_not_in_array_index_range( 87 | invalid_index: int, 88 | ) -> None: 89 | assert normalise_property_key(str(invalid_index)) == str(invalid_index) 90 | 91 | 92 | @given(invalid_index=invalid_float_indexes) 93 | def test_normalise_property_key__returns_str_for_floats_that_are_not_valid( 94 | invalid_index: float, 95 | ) -> None: 96 | result = normalise_property_key(invalid_index) 97 | if invalid_index.is_integer(): 98 | assert result == f"{int(invalid_index)}" 99 | else: 100 | assert result == str(invalid_index) 101 | 102 | 103 | @given(invalid_index=invalid_float_indexes) 104 | def test_normalise_property_key__returns_str_for_float_strings_that_are_not_valid( 105 | invalid_index: float, 106 | ) -> None: 107 | # We can use "-1.0" rather than "-1" because we test negative int strings 108 | # separately. 109 | assert normalise_property_key(str(invalid_index)) == str(invalid_index) 110 | 111 | 112 | @given(non_index_strings) 113 | def test_normalise_property_key__returns_str_for_non_index_strings( 114 | non_index: str, 115 | ) -> None: 116 | assert normalise_property_key(non_index) == non_index 117 | 118 | 119 | def test_normalise_property_key__handles_negative_zero() -> None: 120 | # ECMA 262 notes in particular that: 121 | # > "-0" is neither an integer index nor an array index. 122 | # -0 is a special case as it could be interpreted as -0.0 or 0. 123 | assert normalise_property_key("-0") == "-0" 124 | 125 | # Despite the above, the actual float value -0.0 is treated as 0. The reason 126 | # this is the case may not be immediately obvious from reading the spec: 127 | # - https://tc39.es/ecma262/#sec-object-type 128 | # - The spec requires that CanonicalNumericIndexString(n) returns a 129 | # non-negative integer. And if you read the definition of 130 | # CanonicalNumericIndexString(n) you see a rule that "-0" returns -0. 131 | # - The thing to realise is that the argument n is a string, so -0.0 is 132 | # converted to a string first. Number::toString returns -0 as "0": 133 | # > If x is either +0𝔽 or -0𝔽, return "0". 134 | # - Hence with -0 we call CanonicalNumericIndexString("0") which is 0. 135 | assert type(normalise_property_key(-0.0)) is int 136 | assert normalise_property_key(-0.0) == 0 137 | -------------------------------------------------------------------------------- /test/pycompat/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | -------------------------------------------------------------------------------- /test/pycompat/test_dataclasses.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from dataclasses import FrozenInstanceError, dataclass 5 | from typing import Generic, TypeVar 6 | 7 | import pytest 8 | 9 | from v8serialize._pycompat.dataclasses import FrozenAfterInitDataclass, slots_if310 10 | 11 | T = TypeVar("T") 12 | 13 | 14 | @pytest.mark.skipif( 15 | sys.version_info[:2] != (3, 10), reason="Test applies only to py310" 16 | ) 17 | def test_frozen_generic_dataclass() -> None: 18 | @dataclass(frozen=True, **slots_if310()) 19 | class BrokenOn310(Generic[T]): 20 | foo: T 21 | 22 | # Using the class directly works 23 | assert BrokenOn310(foo="") 24 | 25 | # Subscripting with a type does not 26 | with pytest.raises( 27 | TypeError, 28 | match=r"super\(type, obj\): obj must be an instance or subtype of type", 29 | ): 30 | BrokenOn310[str](foo="") 31 | 32 | @dataclass(**slots_if310()) 33 | class OKOn310(FrozenAfterInitDataclass, Generic[T]): 34 | foo: T 35 | 36 | assert OKOn310[str](foo="") 37 | 38 | 39 | def test_FrozenAfterInitDataclass() -> None: 40 | @dataclass(unsafe_hash=True, **slots_if310()) 41 | class Example(FrozenAfterInitDataclass): 42 | a: int 43 | 44 | e = Example(a=1) 45 | assert e.a == 1 46 | 47 | with pytest.raises(FrozenInstanceError): 48 | e.a = 2 49 | 50 | with pytest.raises(FrozenInstanceError): 51 | del e.a 52 | 53 | assert Example(a=1) == Example(a=1) 54 | assert hash(Example(a=1)) == hash(Example(a=1)) 55 | 56 | # No need for object.__setattr__ to initialise frozen fields 57 | @dataclass(init=False) 58 | class Example2(Example): 59 | b: str 60 | 61 | def __init__(self, a: int) -> None: 62 | super().__init__(a) 63 | self.b = str(a) 64 | 65 | ex2 = Example2(10) 66 | assert ex2.a == 10 67 | assert ex2.b == "10" 68 | -------------------------------------------------------------------------------- /test/pycompat/test_typing.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from array import array 4 | 5 | import pytest 6 | 7 | from v8serialize._pycompat.typing import ( 8 | Buffer, 9 | ReadableBinary, 10 | get_buffer, 11 | is_readable_binary, 12 | ) 13 | 14 | 15 | @pytest.mark.parametrize( 16 | "buffer", [b"a", bytearray(b"a"), memoryview(b"a"), array("B", b"a")] 17 | ) 18 | def test_is_readable_binary(buffer: Buffer) -> None: 19 | assert is_readable_binary(buffer) 20 | assert read_something(buffer) == b"a"[0] 21 | 22 | # getitem slice must return a ReadableBinary, not just Sequence[int] 23 | piece = buffer[:1] 24 | assert read_something(piece) == b"a"[0] 25 | 26 | # regular types can be assigned to a ReadableBinary var 27 | rb: ReadableBinary = b"abc" 28 | assert rb 29 | 30 | 31 | @pytest.mark.parametrize( 32 | "buffer", 33 | [ 34 | b"a", 35 | bytearray(b"a"), 36 | memoryview(b"a"), 37 | memoryview(b"a").cast("b", (1, 1)), 38 | array("B", b"a"), 39 | # get_buffer returns flat uint8 memoryview 40 | array("I", [ord(b"a")] * 4), 41 | ], 42 | ) 43 | def test_get_buffer(buffer: Buffer) -> None: 44 | mv = get_buffer(buffer) 45 | assert mv.format == "B" 46 | assert mv.ndim == 1 47 | assert mv.itemsize == 1 48 | assert mv[0] == b"a"[0] 49 | 50 | 51 | def read_something(data: ReadableBinary) -> int: 52 | return data[0] 53 | -------------------------------------------------------------------------------- /test/test__recursive_eq.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | 5 | import pytest 6 | 7 | from v8serialize._recursive_eq import recursive_eq 8 | 9 | 10 | @recursive_eq 11 | @dataclass 12 | class Node: 13 | value: int 14 | child: Node | tuple[Node, ...] | None = field(default=None) 15 | 16 | 17 | def test_recursive_eq__same_instance() -> None: 18 | b = Node(2) 19 | a = Node(1, child=b) 20 | 21 | for _ in range(2): 22 | assert b == b 23 | assert a == a 24 | assert a != b 25 | 26 | a.child = a 27 | 28 | assert a == a 29 | assert a != b 30 | 31 | 32 | def test_recursive_eq__same_structure_same_identity() -> None: 33 | b = Node(2) 34 | a = Node(1, child=b) 35 | 36 | _b = Node(2) 37 | _a = Node(1, child=_b) 38 | 39 | for _ in range(2): 40 | assert b == _b 41 | assert a == _a 42 | 43 | b.child = a 44 | _b.child = _a 45 | 46 | for _ in range(2): 47 | assert a == _a 48 | 49 | c = Node(3, child=a) 50 | b.child = c 51 | _c = Node(3, child=_a) 52 | _b.child = _c 53 | 54 | for _ in range(2): 55 | assert a == _a 56 | 57 | 58 | def test_recursive_eq__same_structure_different_identity() -> None: 59 | # value: #1 -> #2 -> #1 -> #2 ... 60 | # identity: a -> b -> a -> b ... 61 | b = Node(2) 62 | a = Node(1, child=b) 63 | b.child = a 64 | 65 | # value: #1 -> #2 -> #1 -> #2 ... 66 | # identity: _a1 -> _b1 -> _a2 -> _b2 -> _a1 ... 67 | _b2 = Node(2) 68 | _a2 = Node(1, child=_b2) 69 | _b1 = Node(2, child=_a2) 70 | _a1 = Node(1, child=_b1) 71 | _b2.child = _a1 72 | 73 | # These are not equal because the recursive identity structure is different. 74 | for _ in range(2): 75 | assert a != _a1 76 | # Could be made eq by using a key based on the node's state rather than id(node) 77 | 78 | 79 | def test_recursive_eq__handles_failure_in_wrapped_eq() -> None: 80 | class FailingNode(Node): 81 | def __eq__(self, value: object) -> bool: 82 | raise RuntimeError("oops") 83 | 84 | b = Node(2) 85 | a = Node(1, child=b) 86 | b.child = a 87 | 88 | _b = Node(2) 89 | _a = Node(1, child=_b) 90 | _b.child = _a 91 | 92 | for _ in range(2): 93 | assert a == _a 94 | 95 | bad_b = Node(2, child=FailingNode(1)) # like a, but eq fails 96 | bad_a = Node(1, child=bad_b) 97 | 98 | with pytest.raises(RuntimeError, match="oops"): 99 | a.__eq__(bad_a) 100 | 101 | # State tracking in-progress eq was correctly reset after the error 102 | for _ in range(2): 103 | assert a == _a 104 | -------------------------------------------------------------------------------- /test/test_constants.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import operator 4 | from functools import reduce 5 | 6 | import pytest 7 | from packaging.version import Version 8 | 9 | from v8serialize._errors import JSRegExpV8SerializeError 10 | from v8serialize._pycompat.re import RegexFlag 11 | from v8serialize.constants import ( 12 | JSErrorName, 13 | JSRegExpFlag, 14 | SerializationErrorTag, 15 | SerializationFeature, 16 | SerializationTag, 17 | SymbolicVersion, 18 | ) 19 | 20 | 21 | def test_SerializationTag() -> None: 22 | assert int(SerializationTag.kBeginJSObject) in SerializationTag 23 | assert -1 not in SerializationTag 24 | assert 0xFFFF not in SerializationTag 25 | assert SerializationTag(int(SerializationTag.kRegExp)) is SerializationTag.kRegExp 26 | 27 | 28 | def test_SerializationErrorTag() -> None: 29 | assert SerializationErrorTag.Message in SerializationErrorTag 30 | assert int(SerializationErrorTag.Message) in SerializationErrorTag 31 | assert -1 not in SerializationErrorTag 32 | assert 0xFFFF not in SerializationErrorTag 33 | assert ( 34 | SerializationErrorTag(int(SerializationErrorTag.Cause)) 35 | is SerializationErrorTag.Cause 36 | ) 37 | 38 | 39 | def test_RegExpFlag() -> None: 40 | assert JSRegExpFlag.Global == 1 # type: ignore[comparison-overlap] 41 | assert JSRegExpFlag.UnicodeSets == 1 << 8 42 | 43 | assert str(JSRegExpFlag.Global) == "g" 44 | assert str(JSRegExpFlag.UnicodeSets) == "v" 45 | 46 | assert str(JSRegExpFlag.Global | JSRegExpFlag.UnicodeSets) == "gv" 47 | 48 | assert str(JSRegExpFlag(0b111111111)) == "dgilmsuvy" 49 | 50 | assert JSRegExpFlag.IgnoreCase.as_python_flags() == RegexFlag.IGNORECASE 51 | assert JSRegExpFlag.Multiline.as_python_flags() == RegexFlag.MULTILINE 52 | 53 | assert (JSRegExpFlag.IgnoreCase | JSRegExpFlag.Multiline).as_python_flags() == ( 54 | RegexFlag.IGNORECASE | RegexFlag.MULTILINE 55 | ) 56 | assert JSRegExpFlag.Global.as_python_flags() == RegexFlag.NOFLAG 57 | 58 | # Linear has no equivalent in Python, so its presence invalidates tags its with 59 | assert JSRegExpFlag.Linear.as_python_flags(throw=False) is None 60 | assert (JSRegExpFlag.Multiline | JSRegExpFlag.Linear).as_python_flags( 61 | throw=False 62 | ) is None 63 | assert ( 64 | JSRegExpFlag.Multiline | JSRegExpFlag.Linear | JSRegExpFlag.IgnoreCase 65 | ).as_python_flags(throw=False) is None 66 | 67 | with pytest.raises( 68 | JSRegExpV8SerializeError, 69 | match=r"No equivalent Python flags exist for JSRegExp\.Linear", 70 | # ValueError, match=r"No equivalent Python flags exist for JSRegExp\.Linear" 71 | ): 72 | assert (JSRegExpFlag.Multiline | JSRegExpFlag.Linear).as_python_flags() 73 | 74 | 75 | def test_RegExpFlag__canonical() -> None: 76 | for f in JSRegExpFlag: 77 | assert f.canonical == f 78 | 79 | all: JSRegExpFlag = reduce(operator.or_, JSRegExpFlag) # type: ignore[assignment] 80 | assert all.canonical == all 81 | non_canonical = JSRegExpFlag(0xFFF) 82 | assert list(non_canonical) == list(all) 83 | assert non_canonical != all 84 | assert non_canonical.canonical == all 85 | 86 | assert JSRegExpFlag(0).canonical == 0 87 | 88 | 89 | def test_RegExpFlag__from_python_flags() -> None: 90 | assert JSRegExpFlag.from_python_flags(RegexFlag.NOFLAG) == (JSRegExpFlag.NoFlag) 91 | 92 | assert JSRegExpFlag.from_python_flags(RegexFlag.MULTILINE | RegexFlag.DOTALL) == ( 93 | JSRegExpFlag.Multiline | JSRegExpFlag.DotAll 94 | ) 95 | 96 | with pytest.raises( 97 | JSRegExpV8SerializeError, 98 | match=r"No equivalent JavaScript RegExp flags exist for RegexFlag\.VERBOSE", 99 | ): 100 | assert JSRegExpFlag.from_python_flags(RegexFlag.MULTILINE | RegexFlag.VERBOSE) 101 | 102 | 103 | def test_ErrorTag() -> None: 104 | assert SerializationErrorTag.EvalErrorPrototype == ord("E") 105 | 106 | 107 | def test_SerializationFeature() -> None: 108 | assert SerializationFeature.CircularErrorCause.first_v8_version == Version( 109 | "12.1.109" 110 | ) 111 | assert ( 112 | SerializationFeature.CircularErrorCause.first_v8_version 113 | > SerializationFeature.MaxCompatibility.first_v8_version 114 | ) 115 | 116 | 117 | def test_SerializationFeature__for_name() -> None: 118 | assert ( 119 | SerializationFeature.for_name("CircularErrorCause") 120 | is SerializationFeature.CircularErrorCause 121 | ) 122 | 123 | with pytest.raises(LookupError, match=r"^Frob$"): 124 | SerializationFeature.for_name("Frob") 125 | 126 | 127 | @pytest.mark.parametrize( 128 | "v8_version, features", 129 | [ 130 | (Version("0"), None), 131 | (Version("10.0.28"), None), 132 | (Version("10.0.28"), None), 133 | (Version("10.0.29"), SerializationFeature.MaxCompatibility), 134 | (Version("10.6.0"), SerializationFeature.MaxCompatibility), 135 | (Version("10.7.123"), SerializationFeature.RegExpUnicodeSets), 136 | ("10.7.123", SerializationFeature.RegExpUnicodeSets), 137 | (Version("13.1.201"), ~SerializationFeature.MaxCompatibility), 138 | (SymbolicVersion.Unreleased, ~SerializationFeature.MaxCompatibility), 139 | ], 140 | ) 141 | def test_SerializationFeature__supported_by( 142 | v8_version: Version, features: SerializationFeature | None 143 | ) -> None: 144 | if features is None: 145 | with pytest.raises(LookupError, match=r"V8 version .+ is earlier than"): 146 | SerializationFeature.supported_by(v8_version=v8_version) 147 | else: 148 | assert SerializationFeature.supported_by(v8_version=v8_version) == features 149 | 150 | 151 | def test_JSErrorName() -> None: 152 | assert str(JSErrorName.Error) == "Error" 153 | assert str(JSErrorName.Error) in JSErrorName 154 | assert ( 155 | JSErrorName.SyntaxError.error_tag == SerializationErrorTag.SyntaxErrorPrototype 156 | ) 157 | 158 | 159 | def test_SymbolicVersion() -> None: 160 | assert SymbolicVersion.Unreleased > Version("0.0.0") 161 | assert Version("0.0.0") < SymbolicVersion.Unreleased 162 | assert SymbolicVersion.Unreleased > Version("99999999999.0.0") 163 | assert Version("99999999999.0.0") < SymbolicVersion.Unreleased 164 | -------------------------------------------------------------------------------- /test/test_errors.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | from v8serialize._errors import ( 6 | NormalizedKeyError, 7 | UnhandledTagDecodeV8SerializeError, 8 | V8SerializeError, 9 | ) 10 | from v8serialize.constants import SerializationTag 11 | 12 | 13 | @dataclass(init=False) 14 | class ExampleV8CodecError(V8SerializeError): 15 | level: int 16 | limit: float 17 | 18 | def __init__(self, message: str, *, level: int, limit: float) -> None: 19 | super().__init__(message) 20 | self.level = level 21 | self.limit = limit 22 | 23 | 24 | def test_v8codecerror_str_with_fields() -> None: 25 | assert ( 26 | str(ExampleV8CodecError("Level too high", level=3, limit=2.123)) 27 | == "Level too high: level=3, limit=2.123" 28 | ) 29 | 30 | 31 | @dataclass(init=False) 32 | class RecursiveV8CodecError(V8SerializeError): 33 | obj: object 34 | 35 | def __init__(self, message: str, *, obj: object) -> None: 36 | super().__init__(message) 37 | self.obj = obj 38 | 39 | 40 | def test_v8codecerror_str_with_recursive_dataclass_field() -> None: 41 | @dataclass(repr=False, init=False) 42 | class RecursiveThing: 43 | obj: object 44 | 45 | def __init__(self) -> None: 46 | self.obj = self 47 | 48 | def __repr__(self) -> str: 49 | return "RecursiveThing()" 50 | 51 | assert ( 52 | str(RecursiveV8CodecError("Example", obj=RecursiveThing())) 53 | == "Example: obj=RecursiveThing()" 54 | ) 55 | 56 | 57 | def test_v8codecerror_str_without_fields() -> None: 58 | assert str(V8SerializeError("Something went wrong")) == "Something went wrong" 59 | 60 | 61 | def test_NormalizedKeyError() -> None: 62 | nke = NormalizedKeyError(0, "0") 63 | 64 | assert nke.normalized_key == 0 65 | assert nke.raw_key == "0" 66 | assert repr(nke) == "NormalizedKeyError(normalized_key=0, raw_key='0')" 67 | # str being the repr of a str is kind of weird, but this is what all errors do 68 | assert str(nke) == repr("0 (normalized from '0')") 69 | 70 | 71 | def test_UnmappedTagDecodeV8CodecError() -> None: 72 | err = UnhandledTagDecodeV8SerializeError( 73 | "Msg", tag=SerializationTag.kArrayBuffer, position=2, data=b"foo" 74 | ) 75 | 76 | assert ( 77 | str(err) 78 | == "Msg: position=2, data=b'foo', tag=" 79 | ) 80 | -------------------------------------------------------------------------------- /test/test_extensions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from array import array 4 | from base64 import b64decode 5 | from typing import TYPE_CHECKING 6 | 7 | import pytest 8 | 9 | from v8serialize.constants import ArrayBufferViewTag 10 | from v8serialize.decode import ReadableTagStream 11 | from v8serialize.encode import WritableTagStream 12 | from v8serialize.extensions import ( 13 | NodeBufferFormat, 14 | NodeJsArrayBufferViewHostObjectHandler, 15 | ) 16 | from v8serialize.jstypes.jsbuffers import ( 17 | JSArrayBuffer, 18 | JSTypedArray, 19 | JSUint8Array, 20 | JSUint32Array, 21 | ) 22 | 23 | if TYPE_CHECKING: 24 | from typing_extensions import Buffer 25 | 26 | 27 | def test_NodeBufferFormat() -> None: 28 | assert NodeBufferFormat(0) is NodeBufferFormat.Int8Array 29 | 30 | assert not NodeBufferFormat.supports(ArrayBufferViewTag.kFloat16Array) 31 | assert NodeBufferFormat.supports(ArrayBufferViewTag.kDataView) 32 | assert NodeBufferFormat(ArrayBufferViewTag.kDataView) is NodeBufferFormat.DataView 33 | 34 | 35 | @pytest.mark.parametrize( 36 | "serialized,node_type,py_type,data", 37 | [ 38 | ( 39 | "/w9cBgj/////Fc1bBw==", 40 | NodeBufferFormat.Uint32Array, 41 | JSUint32Array, 42 | array("I", [2**32 - 1, 123456789]), 43 | ), 44 | ( 45 | "/w9cCgQBAgME", 46 | NodeBufferFormat.FastBuffer, 47 | JSUint8Array, 48 | array("B", [1, 2, 3, 4]), 49 | ), 50 | ], 51 | ) 52 | def test_NodeJsArrayBufferViewHostObjectHandler_deserialize_host_object( 53 | serialized: str, 54 | node_type: NodeBufferFormat, 55 | py_type: type[JSTypedArray], 56 | data: Buffer, 57 | ) -> None: 58 | stream = ReadableTagStream(b64decode(serialized)) 59 | 60 | assert stream.read_header() == 15 61 | assert NodeBufferFormat(stream.data[3]) is node_type 62 | view = stream.read_host_object( 63 | deserializer=NodeJsArrayBufferViewHostObjectHandler(), tag=True 64 | ) 65 | assert stream.eof 66 | 67 | assert view == py_type(JSArrayBuffer(data)) 68 | 69 | 70 | @pytest.mark.parametrize( 71 | "node_type,py_type,data", 72 | [ 73 | ( 74 | NodeBufferFormat.Uint32Array, 75 | JSUint32Array, 76 | array("I", [2**32 - 1, 123456789]), 77 | ), 78 | ( 79 | # Note that we write Uint8Arrays as Uint8Array, not FastBuffer. We 80 | # don't currently have type to map to FastBuffer. 81 | NodeBufferFormat.Uint8Array, 82 | JSUint8Array, 83 | array("B", [1, 2, 3, 4]), 84 | ), 85 | ], 86 | ) 87 | def test_NodeJsArrayBufferViewHostObjectHandler_serialize_host_object( 88 | node_type: NodeBufferFormat, 89 | py_type: type[JSTypedArray], 90 | data: Buffer, 91 | ) -> None: 92 | value = py_type(JSArrayBuffer(data)) 93 | 94 | stream = WritableTagStream() 95 | stream.write_header() 96 | stream.write_host_object(value, serializer=NodeJsArrayBufferViewHostObjectHandler()) 97 | 98 | rts = ReadableTagStream(stream.data) 99 | rts.read_header() 100 | result = rts.read_host_object( 101 | tag=True, deserializer=NodeJsArrayBufferViewHostObjectHandler() 102 | ) 103 | assert rts.eof 104 | assert NodeBufferFormat(rts.data[3]) == node_type 105 | assert result == value 106 | -------------------------------------------------------------------------------- /test/test_protocol_abc_inheritance.py: -------------------------------------------------------------------------------- 1 | """A simplified demo of the protocols defined in v8serialize.typing.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Sequence 6 | from typing import TYPE_CHECKING 7 | from typing_extensions import Protocol, TypeVar, overload, runtime_checkable 8 | 9 | T = TypeVar("T") 10 | 11 | if TYPE_CHECKING: 12 | 13 | class SpecialisedSequenceProtocol(Sequence[T]): 14 | def extra_method(self) -> None: ... 15 | 16 | else: 17 | 18 | @runtime_checkable 19 | class SpecialisedSequenceProtocol(Protocol[T]): 20 | def extra_method(self) -> None: ... 21 | 22 | Sequence.register(SpecialisedSequenceProtocol) 23 | 24 | 25 | class SpecialisedSequence(SpecialisedSequenceProtocol[T], Sequence[T]): 26 | pass 27 | 28 | 29 | class ConcreteSpecialisedSequence(SpecialisedSequence[T]): 30 | def extra_method(self) -> None: 31 | return None 32 | 33 | @overload 34 | def __getitem__(self, i: int, /) -> T: ... 35 | 36 | @overload 37 | def __getitem__(self, i: slice, /) -> Sequence[T]: ... 38 | 39 | def __getitem__(self, index: int | slice, /) -> T | Sequence[T]: 40 | raise NotImplementedError 41 | 42 | def __len__(self) -> int: 43 | raise NotImplementedError 44 | 45 | 46 | @SpecialisedSequence.register 47 | class OtherImpl(SpecialisedSequenceProtocol[T]): 48 | def extra_method(self) -> None: 49 | return 50 | 51 | @overload 52 | def __getitem__(self, i: int, /) -> T: ... 53 | 54 | @overload 55 | def __getitem__(self, i: slice, /) -> Sequence[T]: ... 56 | 57 | def __getitem__(self, index: int | slice, /) -> T | Sequence[T]: 58 | raise NotImplementedError 59 | 60 | def __len__(self) -> int: 61 | raise NotImplementedError 62 | 63 | 64 | class Unrelated: 65 | pass 66 | 67 | 68 | class CustomList(list[T]): 69 | def extra_method(self) -> None: 70 | raise NotImplementedError 71 | 72 | 73 | def test_other_impl() -> None: 74 | impl: SpecialisedSequenceProtocol[object] = OtherImpl() 75 | assert isinstance(impl, SpecialisedSequence) 76 | assert isinstance(impl, SpecialisedSequenceProtocol) 77 | 78 | seq: Sequence[object] = impl # noqa: F841 79 | seq2: Sequence[object] = OtherImpl() # noqa: F841 80 | 81 | 82 | def test_spec_seq() -> None: 83 | specs: SpecialisedSequenceProtocol[object] = ConcreteSpecialisedSequence() 84 | assert isinstance(specs, SpecialisedSequence) 85 | assert isinstance(specs, SpecialisedSequenceProtocol) 86 | 87 | seq: Sequence[object] = specs # noqa: F841 88 | seq2: Sequence[object] = ConcreteSpecialisedSequence() # noqa: F841 89 | 90 | 91 | def test_runtime_checkable() -> None: 92 | assert not isinstance(Unrelated(), SpecialisedSequenceProtocol) 93 | assert isinstance(CustomList(), SpecialisedSequenceProtocol) 94 | -------------------------------------------------------------------------------- /test/test_protocol_dataclass_interaction.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import TYPE_CHECKING, Protocol, runtime_checkable 5 | 6 | if TYPE_CHECKING: 7 | 8 | @runtime_checkable 9 | class Proto(Protocol): 10 | @property 11 | def thing(self) -> int: ... 12 | 13 | else: 14 | 15 | @runtime_checkable 16 | class Proto(Protocol): 17 | # Assigning a field is enough for @runtime_checkable to check its 18 | # presence, but without an annotation it won't affect @dataclass. 19 | thing = ... 20 | 21 | 22 | @dataclass 23 | class Thing(Proto): 24 | thing: int 25 | 26 | 27 | @dataclass 28 | class ImplicitThing: 29 | thing: int 30 | 31 | 32 | @dataclass 33 | class NonThing: 34 | thing2: int 35 | 36 | 37 | def test_runtime_checkable_dual_typing_runtime_protocol() -> None: 38 | assert isinstance(Thing(1), Proto) 39 | assert isinstance(ImplicitThing(1), Proto) 40 | assert not isinstance(NonThing(1), Proto) 41 | 42 | 43 | # Can also have one definition and override the descriptors immediately 44 | @runtime_checkable 45 | class Proto2(Protocol): 46 | if TYPE_CHECKING: 47 | 48 | @property 49 | def thing(self) -> int: ... 50 | 51 | else: 52 | thing = ... 53 | 54 | 55 | @dataclass 56 | class Thing2(Proto2): 57 | thing: int 58 | 59 | 60 | @dataclass 61 | class ImplicitThing2: 62 | thing: int 63 | 64 | 65 | @dataclass 66 | class NonThing2: 67 | thing2: int 68 | 69 | 70 | def test_runtime_checkable_removed_descriptor_runtime_protocol() -> None: 71 | # type check 72 | obj: Proto2 = Thing2(1) 73 | foo = ImplicitThing2(1) 74 | foo = NonThing2(1) # type: ignore[assignment] 75 | 76 | assert obj and foo # not unused 77 | 78 | assert isinstance(Thing2(1), Proto2) 79 | assert isinstance(ImplicitThing2(1), Proto2) 80 | assert not isinstance(NonThing2(1), Proto2) 81 | -------------------------------------------------------------------------------- /test/test_references.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from copy import copy 4 | 5 | import pytest 6 | 7 | from v8serialize._references import ( 8 | ObjectNotSerializedV8SerializeError, 9 | SerializedId, 10 | SerializedIdOutOfRangeV8SerializeError, 11 | SerializedObjectLog, 12 | ) 13 | 14 | 15 | def test_serialized_object_log__objects_receive_sequential_ids_from_0() -> None: 16 | objects = SerializedObjectLog() 17 | 18 | assert objects.record_reference(object()) == SerializedId(0) 19 | assert objects.record_reference(object()) == SerializedId(1) 20 | same_obj = set[object]() 21 | assert objects.record_reference(same_obj) == SerializedId(2) 22 | assert objects.record_reference(same_obj) == SerializedId(3) 23 | 24 | 25 | def test_serialized_object_log__can_be_retrieved_by_id() -> None: 26 | obj1, obj2, set1, set2 = object(), object(), set[object](), set[object]() 27 | objects = SerializedObjectLog() 28 | 29 | obj1_id = objects.record_reference(obj1) 30 | obj2_id = objects.record_reference(obj2) 31 | set1_id = objects.record_reference(set1) 32 | set2_id = objects.record_reference(set2) 33 | 34 | assert objects.get_object(obj1_id) is obj1 35 | assert objects.get_object(obj2_id) is obj2 36 | assert objects.get_object(set1_id) is set1 37 | assert objects.get_object(set2_id) is set2 38 | 39 | 40 | def test_serialized_object_log__id_can_be_retrieved_by_object() -> None: 41 | obj1, obj2, set1, set2 = object(), object(), set[object](), set[object]() 42 | objects = SerializedObjectLog() 43 | 44 | obj1_id = objects.record_reference(obj1) 45 | obj2_id = objects.record_reference(obj2) 46 | set1_id = objects.record_reference(set1) 47 | set2_id = objects.record_reference(set2) 48 | 49 | assert objects.get_serialized_id(obj1) == obj1_id 50 | assert objects.get_serialized_id(obj2) == obj2_id 51 | assert objects.get_serialized_id(set1) == set1_id 52 | assert objects.get_serialized_id(set2) == set2_id 53 | 54 | 55 | def test_serialized_object_log__contains_referenced_objects() -> None: 56 | obj1, obj2, set1, set2 = object(), object(), set[object](), set[object]() 57 | objects = SerializedObjectLog() 58 | 59 | objects.record_reference(obj1) 60 | objects.record_reference(obj2) 61 | objects.record_reference(set1) 62 | objects.record_reference(set2) 63 | 64 | assert obj1 in objects 65 | assert obj2 in objects 66 | assert set1 in objects 67 | assert set2 in objects 68 | 69 | 70 | def test_serialized_object_log__does_not_contain_unreferenced_objects() -> None: 71 | obj1, obj2, set1, set2 = object(), object(), set[object](), set[object]() 72 | objects = SerializedObjectLog() 73 | 74 | objects.record_reference(obj1) 75 | objects.record_reference(obj2) 76 | objects.record_reference(set1) 77 | objects.record_reference(set2) 78 | 79 | assert copy(obj1) not in objects 80 | assert copy(obj2) not in objects 81 | assert copy(set1) not in objects 82 | assert copy(set2) not in objects 83 | 84 | 85 | def test_serialized_object_log__getting_unrecorded_id_throws() -> None: 86 | objects = SerializedObjectLog() 87 | 88 | with pytest.raises(SerializedIdOutOfRangeV8SerializeError) as exc_info: 89 | objects.get_object(SerializedId(42)) 90 | 91 | assert exc_info.value.serialized_id == SerializedId(42) 92 | assert exc_info.value.message == "Serialized ID has not been recorded in the log" 93 | 94 | 95 | def test_serialized_object_log__getting_unrecorded_object_throws() -> None: 96 | objects = SerializedObjectLog() 97 | 98 | unrecorded = object() 99 | with pytest.raises(ObjectNotSerializedV8SerializeError) as exc_info: 100 | objects.get_serialized_id(unrecorded) 101 | 102 | assert exc_info.value.obj is unrecorded 103 | assert exc_info.value.message == "Object has not been recorded in the log" 104 | -------------------------------------------------------------------------------- /test/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TypeVar 4 | 5 | T = TypeVar("T") 6 | 7 | 8 | def typeval(x: T) -> tuple[type[T], T]: 9 | return type(x), x 10 | -------------------------------------------------------------------------------- /testing/smoketest/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/h4l/v8serialize/ad6b5782fb24396b3e7b1a8d7d51130913390dab/testing/smoketest/README.md -------------------------------------------------------------------------------- /testing/smoketest/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "smoketest" 3 | version = "0.1.0" 4 | description = "A package that depends on v8serialize with the oldest supported dependencies." 5 | authors = ["Your Name "] 6 | readme = "README.md" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.9" 10 | v8serialize = "*" 11 | packaging = "<=14.5" # require the oldest version supported by v8serialize 12 | 13 | [build-system] 14 | requires = ["poetry-core"] 15 | build-backend = "poetry.core.masonry.api" 16 | -------------------------------------------------------------------------------- /testing/smoketest/smoketest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from v8serialize import SerializationFeature, dumps, loads 4 | 5 | 6 | def main() -> None: 7 | msg = "Don't let the smoke out!" 8 | msg_out = loads( 9 | dumps( 10 | "Don't let the smoke out!", 11 | v8_version=SerializationFeature.MaxCompatibility.first_v8_version, 12 | ) 13 | ) 14 | if msg != msg_out: 15 | raise AssertionError("Smoke test failed") 16 | print(msg_out) 17 | 18 | 19 | if __name__ == "__main__": 20 | main() 21 | -------------------------------------------------------------------------------- /testing/v8serialize-echo/.dockerignore: -------------------------------------------------------------------------------- 1 | .* 2 | npm 3 | Dockerfile 4 | docker-bake.hcl 5 | -------------------------------------------------------------------------------- /testing/v8serialize-echo/.envrc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | version=$( 4 | grep -P --only-matching \ 5 | '(?<=ECHOSERVER_VERSION = ")(\d+\.\d+\.\d+[\w-]*)(?=")' \ 6 | main.ts 7 | ) 8 | 9 | if [[ ! ${version?} ]]; then 10 | echo "Error: Failed to read ECHOSERVER_VERSION constant from main.ts" >&2 11 | exit 1 12 | fi 13 | 14 | export ECHOSERVER_VERSION=${version:?} 15 | -------------------------------------------------------------------------------- /testing/v8serialize-echo/.gitignore: -------------------------------------------------------------------------------- 1 | npm 2 | -------------------------------------------------------------------------------- /testing/v8serialize-echo/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to v8serialize/echoserver will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 | and this project adheres to 7 | [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 8 | 9 | ## [Unreleased] 10 | 11 | Nothing yet. 12 | 13 | ## [0.3.0] - 2024-09-24 14 | 15 | ### Added 16 | 17 | - Container healthcheck — The container image now includes a HEALTHCHECK that 18 | passes when the server is listening. 19 | ([#4](https://github.com/h4l/v8serialize/pull/4)) 20 | 21 | ## [0.2.0] - 2024-09-24 22 | 23 | ### Added 24 | 25 | - Server introspection — `GET /` reports supported serialization features and 26 | software version numbers of the running server. 27 | 28 | ## [0.1.0] - 2024-08-28 29 | 30 | - The first stable release. 31 | 32 | [unreleased]: 33 | https://github.com/h4l/v8serialize/compare/echoserver-v0.3.0...HEAD 34 | [0.3.0]: https://github.com/h4l/v8serialize/releases/tag/echoserver-v0.3.0 35 | [0.2.0]: https://github.com/h4l/v8serialize/releases/tag/echoserver-v0.2.0 36 | [0.1.0]: https://github.com/h4l/v8serialize/releases/tag/echoserver-v0.1.0 37 | -------------------------------------------------------------------------------- /testing/v8serialize-echo/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG DENO_VERSION NODE_VERSION DNT_NODE_VERSION DNT_DENO_VERSION 2 | 3 | FROM denoland/deno:${DENO_VERSION:-alpine} AS echoserver-deno 4 | 5 | RUN apk add --no-cache curl 6 | COPY main.ts . 7 | USER deno 8 | 9 | ENV V8SERIALIZE_LOG_WITH_COLOR=true \ 10 | V8SERIALIZE_LOG_LISTEN=true \ 11 | V8SERIALIZE_LOG_RE_SERIALIZATION=text \ 12 | V8SERIALIZE_PORT=8000 \ 13 | V8SERIALIZE_HOSTNAME=0.0.0.0 14 | 15 | EXPOSE 8000 16 | 17 | CMD ["run", "--no-remote", "--allow-net", "--allow-env", "main.ts"] 18 | 19 | HEALTHCHECK --start-period=20s --start-interval=1s \ 20 | CMD curl --fail --silent --show-error -o /dev/null \ 21 | "http://localhost:${V8SERIALIZE_PORT:?}/?healthcheck" 22 | 23 | 24 | FROM denoland/deno:bin-${DNT_DENO_VERSION:-latest} AS dnt-deno 25 | 26 | 27 | FROM node:${DNT_NODE_VERSION:-latest} AS npm-package 28 | 29 | COPY --from=dnt-deno /deno /usr/local/bin/deno 30 | 31 | WORKDIR /build 32 | 33 | COPY . ./ 34 | RUN deno -A scripts/build_npm.ts 35 | RUN npm pack ./npm 36 | 37 | 38 | FROM node:${NODE_VERSION:-alpine} AS echoserver-node 39 | ARG ECHOSERVER_VERSION 40 | 41 | RUN apk add --no-cache tini curl 42 | WORKDIR /app 43 | 44 | RUN --mount=from=npm-package,source=/build/,target=/build/ \ 45 | npm install "/build/v8serialize-echoserver-${ECHOSERVER_VERSION:?}.tgz" 46 | 47 | ENV V8SERIALIZE_LOG_WITH_COLOR=true \ 48 | V8SERIALIZE_LOG_LISTEN=true \ 49 | V8SERIALIZE_LOG_RE_SERIALIZATION=text \ 50 | V8SERIALIZE_PORT=8000 \ 51 | V8SERIALIZE_HOSTNAME=0.0.0.0 52 | 53 | EXPOSE 8000 54 | USER node 55 | 56 | ENTRYPOINT ["/sbin/tini", "--", "docker-entrypoint.sh"] 57 | CMD ["-e", "require('v8serialize-echoserver').main()"] 58 | 59 | HEALTHCHECK --start-period=20s --start-interval=1s \ 60 | CMD curl --fail --silent --show-error -o /dev/null \ 61 | "http://localhost:${V8SERIALIZE_PORT:?}/?healthcheck" 62 | -------------------------------------------------------------------------------- /testing/v8serialize-echo/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "tasks": { 3 | "dev": "deno run --allow-net --watch main.ts" 4 | }, 5 | "imports": { 6 | "@deno/dnt": "jsr:@deno/dnt@^0.41.3" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /testing/v8serialize-echo/deno.lock: -------------------------------------------------------------------------------- 1 | { 2 | "version": "3", 3 | "packages": { 4 | "specifiers": { 5 | "jsr:@david/code-block-writer@^13.0.2": "jsr:@david/code-block-writer@13.0.2", 6 | "jsr:@deno/cache-dir@^0.10.3": "jsr:@deno/cache-dir@0.10.3", 7 | "jsr:@deno/dnt@^0.41.3": "jsr:@deno/dnt@0.41.3", 8 | "jsr:@deno/graph@^0.73.1": "jsr:@deno/graph@0.73.1", 9 | "jsr:@std/assert@^0.223.0": "jsr:@std/assert@0.223.0", 10 | "jsr:@std/assert@^0.226.0": "jsr:@std/assert@0.226.0", 11 | "jsr:@std/bytes@^0.223.0": "jsr:@std/bytes@0.223.0", 12 | "jsr:@std/encoding": "jsr:@std/encoding@1.0.3", 13 | "jsr:@std/fmt@1": "jsr:@std/fmt@1.0.1", 14 | "jsr:@std/fmt@^0.223": "jsr:@std/fmt@0.223.0", 15 | "jsr:@std/fs@1": "jsr:@std/fs@1.0.2", 16 | "jsr:@std/fs@^0.223": "jsr:@std/fs@0.223.0", 17 | "jsr:@std/fs@^0.229.3": "jsr:@std/fs@0.229.3", 18 | "jsr:@std/io@^0.223": "jsr:@std/io@0.223.0", 19 | "jsr:@std/path@1": "jsr:@std/path@1.0.3", 20 | "jsr:@std/path@1.0.0-rc.1": "jsr:@std/path@1.0.0-rc.1", 21 | "jsr:@std/path@^0.223": "jsr:@std/path@0.223.0", 22 | "jsr:@std/path@^0.225.2": "jsr:@std/path@0.225.2", 23 | "jsr:@std/path@^1.0.3": "jsr:@std/path@1.0.3", 24 | "jsr:@ts-morph/bootstrap@^0.24.0": "jsr:@ts-morph/bootstrap@0.24.0", 25 | "jsr:@ts-morph/common@^0.24.0": "jsr:@ts-morph/common@0.24.0", 26 | "npm:@types/node": "npm:@types/node@18.16.19" 27 | }, 28 | "jsr": { 29 | "@david/code-block-writer@13.0.2": { 30 | "integrity": "14dd3baaafa3a2dea8bf7dfbcddeccaa13e583da2d21d666c01dc6d681cd74ad" 31 | }, 32 | "@deno/cache-dir@0.10.3": { 33 | "integrity": "eb022f84ecc49c91d9d98131c6e6b118ff63a29e343624d058646b9d50404776", 34 | "dependencies": [ 35 | "jsr:@deno/graph@^0.73.1", 36 | "jsr:@std/fmt@^0.223", 37 | "jsr:@std/fs@^0.223", 38 | "jsr:@std/io@^0.223", 39 | "jsr:@std/path@^0.223" 40 | ] 41 | }, 42 | "@deno/dnt@0.41.3": { 43 | "integrity": "b2ef2c8a5111eef86cb5bfcae103d6a2938e8e649e2461634a7befb7fc59d6d2", 44 | "dependencies": [ 45 | "jsr:@david/code-block-writer@^13.0.2", 46 | "jsr:@deno/cache-dir@^0.10.3", 47 | "jsr:@std/fmt@1", 48 | "jsr:@std/fs@1", 49 | "jsr:@std/path@1", 50 | "jsr:@ts-morph/bootstrap@^0.24.0" 51 | ] 52 | }, 53 | "@deno/graph@0.73.1": { 54 | "integrity": "cd69639d2709d479037d5ce191a422eabe8d71bb68b0098344f6b07411c84d41" 55 | }, 56 | "@std/assert@0.223.0": { 57 | "integrity": "eb8d6d879d76e1cc431205bd346ed4d88dc051c6366365b1af47034b0670be24" 58 | }, 59 | "@std/assert@0.226.0": { 60 | "integrity": "0dfb5f7c7723c18cec118e080fec76ce15b4c31154b15ad2bd74822603ef75b3" 61 | }, 62 | "@std/bytes@0.223.0": { 63 | "integrity": "84b75052cd8680942c397c2631318772b295019098f40aac5c36cead4cba51a8" 64 | }, 65 | "@std/encoding@1.0.3": { 66 | "integrity": "5dbc2d7f5aa6062de7e19862ea856ac7a0dcce0b6fb46bb7b332d3bdcd4408b7" 67 | }, 68 | "@std/fmt@0.223.0": { 69 | "integrity": "6deb37794127dfc7d7bded2586b9fc6f5d50e62a8134846608baf71ffc1a5208" 70 | }, 71 | "@std/fmt@1.0.1": { 72 | "integrity": "ef76c37faa7720faa8c20fd8cc74583f9b1e356dfd630c8714baa716a45856ab" 73 | }, 74 | "@std/fs@0.223.0": { 75 | "integrity": "3b4b0550b2c524cbaaa5a9170c90e96cbb7354e837ad1bdaf15fc9df1ae9c31c" 76 | }, 77 | "@std/fs@0.229.3": { 78 | "integrity": "783bca21f24da92e04c3893c9e79653227ab016c48e96b3078377ebd5222e6eb", 79 | "dependencies": [ 80 | "jsr:@std/path@1.0.0-rc.1" 81 | ] 82 | }, 83 | "@std/fs@1.0.2": { 84 | "integrity": "af57555c7a224a6f147d5cced5404692974f7a628ced8eda67e0d22d92d474ec", 85 | "dependencies": [ 86 | "jsr:@std/path@^1.0.3" 87 | ] 88 | }, 89 | "@std/io@0.223.0": { 90 | "integrity": "2d8c3c2ab3a515619b90da2c6ff5ea7b75a94383259ef4d02116b228393f84f1", 91 | "dependencies": [ 92 | "jsr:@std/assert@^0.223.0", 93 | "jsr:@std/bytes@^0.223.0" 94 | ] 95 | }, 96 | "@std/path@0.223.0": { 97 | "integrity": "593963402d7e6597f5a6e620931661053572c982fc014000459edc1f93cc3989", 98 | "dependencies": [ 99 | "jsr:@std/assert@^0.223.0" 100 | ] 101 | }, 102 | "@std/path@0.225.2": { 103 | "integrity": "0f2db41d36b50ef048dcb0399aac720a5348638dd3cb5bf80685bf2a745aa506", 104 | "dependencies": [ 105 | "jsr:@std/assert@^0.226.0" 106 | ] 107 | }, 108 | "@std/path@1.0.0-rc.1": { 109 | "integrity": "b8c00ae2f19106a6bb7cbf1ab9be52aa70de1605daeb2dbdc4f87a7cbaf10ff6" 110 | }, 111 | "@std/path@1.0.3": { 112 | "integrity": "cd89d014ce7eb3742f2147b990f6753ee51d95276bfc211bc50c860c1bc7df6f" 113 | }, 114 | "@ts-morph/bootstrap@0.24.0": { 115 | "integrity": "a826a2ef7fa8a7c3f1042df2c034d20744d94da2ee32bf29275bcd4dffd3c060", 116 | "dependencies": [ 117 | "jsr:@ts-morph/common@^0.24.0" 118 | ] 119 | }, 120 | "@ts-morph/common@0.24.0": { 121 | "integrity": "12b625b8e562446ba658cdbe9ad77774b4bd96b992ae8bd34c60dbf24d06c1f3", 122 | "dependencies": [ 123 | "jsr:@std/fs@^0.229.3", 124 | "jsr:@std/path@^0.225.2" 125 | ] 126 | } 127 | }, 128 | "npm": { 129 | "@types/node@18.16.19": { 130 | "integrity": "sha512-IXl7o+R9iti9eBW4Wg2hx1xQDig183jj7YLn8F7udNceyfkbn1ZxmzZXuak20gR40D7pIkIY1kYGx5VIGbaHKA==", 131 | "dependencies": {} 132 | } 133 | } 134 | }, 135 | "remote": {}, 136 | "workspace": { 137 | "dependencies": [ 138 | "jsr:@deno/dnt@^0.41.3" 139 | ] 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /testing/v8serialize-echo/docker-bake.hcl: -------------------------------------------------------------------------------- 1 | DNT_NODE_VERSION = "22" 2 | DNT_DENO_VERSION = "1.46.1" 3 | 4 | // Set via .envrc 5 | variable "ECHOSERVER_VERSION" {} 6 | 7 | variable "CI" { default = "" } 8 | 9 | group "default" { 10 | targets = ["echoserver-deno", "echoserver-node"] 11 | } 12 | 13 | target "_base" { 14 | args = { 15 | DNT_NODE_VERSION = DNT_NODE_VERSION, 16 | DNT_DENO_VERSION = DNT_DENO_VERSION, 17 | ECHOSERVER_VERSION = ECHOSERVER_VERSION, 18 | } 19 | labels = { 20 | "org.opencontainers.image.title" = "v8serialize-echoserver" 21 | "org.opencontainers.image.version" = ECHOSERVER_VERSION 22 | } 23 | platforms = CI == "true" ? ["linux/amd64", "linux/arm64"] : ["local"] 24 | } 25 | 26 | target "echoserver-deno" { 27 | name = "echoserver-deno-${replace(version, ".", "-")}" 28 | matrix = { 29 | version = [ "1.46.1" ] 30 | } 31 | inherits = ["_base"] 32 | target = "echoserver-deno" 33 | args = { 34 | DENO_VERSION = "alpine-${version}", 35 | } 36 | tags = [ 37 | "ghcr.io/h4l/v8serialize/echoserver:deno-${version}", 38 | "ghcr.io/h4l/v8serialize/echoserver:${ECHOSERVER_VERSION}-deno-${version}", 39 | ] 40 | labels = { 41 | "org.opencontainers.image.title" = "v8serialize-echoserver-deno" 42 | "org.opencontainers.image.description" = "v8serialize-echoserver running on deno ${version}" 43 | } 44 | } 45 | 46 | target "echoserver-node" { 47 | name = "echoserver-node-${version}" 48 | matrix = { 49 | version = ["18", "22"] 50 | } 51 | inherits = ["_base"] 52 | target = "echoserver-node" 53 | args = { 54 | NODE_VERSION = "${version}-alpine", 55 | } 56 | tags = [ 57 | "ghcr.io/h4l/v8serialize/echoserver:node-${version}", 58 | "ghcr.io/h4l/v8serialize/echoserver:${ECHOSERVER_VERSION}-node-${version}", 59 | ] 60 | labels = { 61 | "org.opencontainers.image.title" = "v8serialize-echoserver-node" 62 | "org.opencontainers.image.description" = "v8serialize-echoserver running on NodeJS ${version}" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /testing/v8serialize-echo/scripts/build_npm.ts: -------------------------------------------------------------------------------- 1 | // ex. scripts/build_npm.ts 2 | import { build, emptyDir } from "@deno/dnt"; 3 | 4 | await emptyDir("./npm"); 5 | 6 | function readVersion(filename: string): string { 7 | const versionPattern = /ECHOSERVER_VERSION = "(\d+\.\d+\.\d+[\w-]*)"/g; 8 | const version = versionPattern.exec(Deno.readTextFileSync(filename))?.[1]; 9 | if (!version) { 10 | throw new Error( 11 | `No version found in '${filename}' matching pattern ${versionPattern}`, 12 | ); 13 | } 14 | return version; 15 | } 16 | 17 | await build({ 18 | entryPoints: ["./main.ts"], 19 | outDir: "./npm", 20 | shims: { 21 | // see JS docs for overview and more options 22 | deno: true, 23 | }, 24 | package: { 25 | // package.json properties 26 | name: "v8serialize-echoserver", 27 | version: readVersion("main.ts"), 28 | description: 29 | "A simple HTTP server that deserializes a V8-serialized payload and reserializes it in the response.", 30 | license: "MIT", 31 | repository: { 32 | type: "git", 33 | url: "git+https://github.com/h4l/v8serialize.git", 34 | }, 35 | bugs: { 36 | url: "https://github.com/h4l/v8serialize/issues", 37 | }, 38 | }, 39 | postBuild() { 40 | // steps to run after building and before running the tests 41 | }, 42 | }); 43 | --------------------------------------------------------------------------------