diff --git a/.ci/appveyor.yml b/.ci/appveyor.yml deleted file mode 100644 index 9dcee4831..000000000 --- a/.ci/appveyor.yml +++ /dev/null @@ -1,53 +0,0 @@ -environment: - global: - S3_UPLOAD_USERNAME: oss-ci-bot - S3_UPLOAD_BUCKET: magicstack-oss-releases - S3_UPLOAD_ACCESSKEY: - secure: 1vmOqSXq5zDN8UdezZ3H4l0A9LUJiTr7Wuy9whCdffE= - S3_UPLOAD_SECRET: - secure: XudOvV6WtY9yRoqKahXMswFth8SF1UTnSXws4UBjeqzQUjOx2V2VRvIdpPfiqUKt - - matrix: - - PYTHON: "C:\\Python35\\python.exe" - - PYTHON: "C:\\Python35-x64\\python.exe" - - PYTHON: "C:\\Python36\\python.exe" - - PYTHON: "C:\\Python36-x64\\python.exe" - - PYTHON: "C:\\Python37\\python.exe" - - PYTHON: "C:\\Python37-x64\\python.exe" - -branches: - # Avoid building PR branches. - only: - - master - - ci - - releases - -install: - - "%PYTHON% -m pip install --upgrade pip wheel setuptools" - - "%PYTHON% -m pip install --upgrade -r .ci/requirements-win.txt" - -build_script: - - "%PYTHON% setup.py build_ext --inplace" - -test_script: - - "%PYTHON% setup.py test" - -after_test: - - "%PYTHON% setup.py bdist_wheel" - -artifacts: - - path: dist\* - -deploy_script: - - ps: | - if ($env:appveyor_repo_branch -eq 'releases') { - $PACKAGE_VERSION = & "$env:PYTHON" ".ci/package-version.py" - $PYPI_VERSION = & "$env:PYTHON" ".ci/pypi-check.py" "immutables" - - if ($PACKAGE_VERSION -eq $PYPI_VERSION) { - Write-Error "immutables-$PACKAGE_VERSION is already published on PyPI" - exit 1 - } - - & "$env:PYTHON" ".ci/s3-upload.py" dist\*.whl - } diff --git a/.ci/build-manylinux-wheels.sh b/.ci/build-manylinux-wheels.sh deleted file mode 100755 index ce5e7720f..000000000 --- a/.ci/build-manylinux-wheels.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/bin/bash - -set -e -x - -# Compile wheels -PYTHON="/opt/python/${PYTHON_VERSION}/bin/python" -PIP="/opt/python/${PYTHON_VERSION}/bin/pip" -${PIP} install --upgrade pip setuptools wheel~=0.31.1 -${PIP} install -r /io/.ci/requirements.txt -rm -rf /io/build -${PIP} wheel /io/ -w /io/dist/ - -# Bundle external shared libraries into the wheels. -for whl in /io/dist/*.whl; do - auditwheel repair $whl -w /io/dist/ - rm /io/dist/*-linux_*.whl -done - -# Grab docker host, where Postgres should be running. -export PGHOST=$(ip route | awk '/default/ { print $3 }' | uniq) -export PGUSER="postgres" - -PYTHON="/opt/python/${PYTHON_VERSION}/bin/python" -PIP="/opt/python/${PYTHON_VERSION}/bin/pip" -${PIP} install ${PYMODULE} --no-index -f file:///io/dist -rm -rf /io/tests/__pycache__ -"${PYTHON}" /io/tests/__init__.py -rm -rf /io/tests/__pycache__ diff --git a/.ci/package-version.py b/.ci/package-version.py deleted file mode 100755 index bbf2ff4ba..000000000 --- a/.ci/package-version.py +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env python3 - - -import os.path -import sys - - -def main(): - version_file = os.path.join( - os.path.dirname(os.path.dirname(__file__)), - 'immutables', '__init__.py') - - with open(version_file, 'r') as f: - for line in f: - if line.startswith('__version__ ='): - _, _, version = line.partition('=') - print(version.strip(" \n'\"")) - return 0 - - print('could not find package version in immutables/__init__.py', - file=sys.stderr) - return 1 - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/.ci/pypi-check.py b/.ci/pypi-check.py deleted file mode 100755 index 1b9c11c42..000000000 --- a/.ci/pypi-check.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env python3 - - -import argparse -import sys -import xmlrpc.client - - -def main(): - parser = argparse.ArgumentParser(description='PyPI package checker') - parser.add_argument('package_name', metavar='PACKAGE-NAME') - - parser.add_argument( - '--pypi-index-url', - help=('PyPI index URL.'), - default='https://pypi.python.org/pypi') - - args = parser.parse_args() - - pypi = xmlrpc.client.ServerProxy(args.pypi_index_url) - releases = pypi.package_releases(args.package_name) - - if releases: - print(next(iter(sorted(releases, reverse=True)))) - - return 0 - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/.ci/requirements-win.txt b/.ci/requirements-win.txt deleted file mode 100644 index 791da6b7f..000000000 --- a/.ci/requirements-win.txt +++ /dev/null @@ -1 +0,0 @@ -tinys3 diff --git a/.ci/requirements.txt b/.ci/requirements.txt deleted file mode 100644 index 403ef5962..000000000 --- a/.ci/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -tinys3 -twine diff --git a/.ci/s3-download-release.py b/.ci/s3-download-release.py deleted file mode 100755 index 223f7f170..000000000 --- a/.ci/s3-download-release.py +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env python3 - - -import argparse -import os -import os.path -import sys -import urllib.request - -import tinys3 - - -def main(): - parser = argparse.ArgumentParser(description='S3 File Uploader') - parser.add_argument( - '--s3-bucket', - help=('S3 bucket name (defaults to $S3_UPLOAD_BUCKET)'), - default=os.environ.get('S3_UPLOAD_BUCKET')) - parser.add_argument( - '--s3-region', - help=('S3 region (defaults to $S3_UPLOAD_REGION)'), - default=os.environ.get('S3_UPLOAD_REGION')) - parser.add_argument( - '--s3-username', - help=('S3 username (defaults to $S3_UPLOAD_USERNAME)'), - default=os.environ.get('S3_UPLOAD_USERNAME')) - parser.add_argument( - '--s3-key', - help=('S3 access key (defaults to $S3_UPLOAD_ACCESSKEY)'), - default=os.environ.get('S3_UPLOAD_ACCESSKEY')) - parser.add_argument( - '--s3-secret', - help=('S3 secret (defaults to $S3_UPLOAD_SECRET)'), - default=os.environ.get('S3_UPLOAD_SECRET')) - parser.add_argument( - '--destdir', - help='Destination directory.') - parser.add_argument( - 'package', metavar='PACKAGE', - help='Package name and version to download.') - - args = parser.parse_args() - - if args.s3_region: - endpoint = 's3-{}.amazonaws.com'.format(args.s3_region.lower()) - else: - endpoint = 's3.amazonaws.com' - - conn = tinys3.Connection( - access_key=args.s3_key, - secret_key=args.s3_secret, - default_bucket=args.s3_bucket, - tls=True, - endpoint=endpoint, - ) - - files = [] - - for entry in conn.list(args.package): - files.append(entry['key']) - - destdir = args.destdir or os.getpwd() - - for file in files: - print('Downloading {}...'.format(file)) - url = 'https://{}/{}/{}'.format(endpoint, args.s3_bucket, file) - target = os.path.join(destdir, file) - urllib.request.urlretrieve(url, target) - - return 0 - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/.ci/s3-upload.py b/.ci/s3-upload.py deleted file mode 100755 index 92479afec..000000000 --- a/.ci/s3-upload.py +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env python3 - - -import argparse -import glob -import os -import os.path -import sys - -import tinys3 - - -def main(): - parser = argparse.ArgumentParser(description='S3 File Uploader') - parser.add_argument( - '--s3-bucket', - help=('S3 bucket name (defaults to $S3_UPLOAD_BUCKET)'), - default=os.environ.get('S3_UPLOAD_BUCKET')) - parser.add_argument( - '--s3-region', - help=('S3 region (defaults to $S3_UPLOAD_REGION)'), - default=os.environ.get('S3_UPLOAD_REGION')) - parser.add_argument( - '--s3-username', - help=('S3 username (defaults to $S3_UPLOAD_USERNAME)'), - default=os.environ.get('S3_UPLOAD_USERNAME')) - parser.add_argument( - '--s3-key', - help=('S3 access key (defaults to $S3_UPLOAD_ACCESSKEY)'), - default=os.environ.get('S3_UPLOAD_ACCESSKEY')) - parser.add_argument( - '--s3-secret', - help=('S3 secret (defaults to $S3_UPLOAD_SECRET)'), - default=os.environ.get('S3_UPLOAD_SECRET')) - parser.add_argument( - 'files', nargs='+', metavar='FILE', help='Files to upload') - - args = parser.parse_args() - - if args.s3_region: - endpoint = 's3-{}.amazonaws.com'.format(args.s3_region.lower()) - else: - endpoint = 's3.amazonaws.com' - - conn = tinys3.Connection( - access_key=args.s3_key, - secret_key=args.s3_secret, - default_bucket=args.s3_bucket, - tls=True, - endpoint=endpoint, - ) - - for pattern in args.files: - for fn in glob.iglob(pattern): - with open(fn, 'rb') as f: - conn.upload(os.path.basename(fn), f) - - return 0 - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/.ci/travis-before-install.sh b/.ci/travis-before-install.sh deleted file mode 100755 index 13aaf3257..000000000 --- a/.ci/travis-before-install.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash - -set -e -x - -if [ "${TRAVIS_OS_NAME}" == "osx" ]; then - brew update >/dev/null - brew upgrade pyenv - eval "$(pyenv init -)" - - if ! (pyenv versions | grep "${PYTHON_VERSION}$"); then - pyenv install ${PYTHON_VERSION} - fi - - pyenv global ${PYTHON_VERSION} - pyenv rehash -fi diff --git a/.ci/travis-build-wheels.sh b/.ci/travis-build-wheels.sh deleted file mode 100755 index a36e281a8..000000000 --- a/.ci/travis-build-wheels.sh +++ /dev/null @@ -1,74 +0,0 @@ -#!/bin/bash - -set -e -x - - -if [[ "${TRAVIS_BRANCH}" != "releases" || "${BUILD}" != *wheels* ]]; then - # Not a release - exit 0 -fi - - -if [ "${TRAVIS_OS_NAME}" == "osx" ]; then - PYENV_ROOT="$HOME/.pyenv" - PATH="$PYENV_ROOT/bin:$PATH" - eval "$(pyenv init -)" -fi - -PACKAGE_VERSION=$(python ".ci/package-version.py") -PYPI_VERSION=$(python ".ci/pypi-check.py" "${PYMODULE}") - -if [ "${PACKAGE_VERSION}" == "${PYPI_VERSION}" ]; then - echo "${PYMODULE}-${PACKAGE_VERSION} is already published on PyPI" - exit 1 -fi - - -pushd $(dirname $0) > /dev/null -_root=$(dirname $(pwd -P)) -popd > /dev/null - - -_upload_wheels() { - python "${_root}/.ci/s3-upload.py" "${_root}/dist"/*.whl - sudo rm -rf "${_root}/dist"/*.whl -} - - -if [ "${TRAVIS_OS_NAME}" == "linux" ]; then - for pyver in ${RELEASE_PYTHON_VERSIONS}; do - ML_PYTHON_VERSION=$(python3 -c \ - "print('cp{maj}{min}-cp{maj}{min}{s}'.format( \ - maj='${pyver}'.split('.')[0], \ - min='${pyver}'.split('.')[1], \ - s='m' if tuple('${pyver}'.split('.')) < ('3', '8') \ - else ''))") - - for arch in x86_64 i686; do - ML_IMAGE="quay.io/pypa/manylinux1_${arch}" - docker pull "${ML_IMAGE}" - docker run --rm \ - -v "${_root}":/io \ - -e "PYMODULE=${PYMODULE}" \ - -e "PYTHON_VERSION=${ML_PYTHON_VERSION}" \ - "${ML_IMAGE}" /io/.ci/build-manylinux-wheels.sh - - _upload_wheels - done - done - -elif [ "${TRAVIS_OS_NAME}" == "osx" ]; then - export PGINSTALLATION="/usr/local/opt/postgresql@${PGVERSION}/bin" - - pip wheel "${_root}" -w "${_root}/dist/" - - pip install ${PYMODULE} --no-index -f "file:///${_root}/dist" - pushd / >/dev/null - python "${_root}/tests/__init__.py" - popd >/dev/null - - _upload_wheels - -else - echo "Cannot build on ${TRAVIS_OS_NAME}." -fi diff --git a/.ci/travis-install.sh b/.ci/travis-install.sh deleted file mode 100755 index e9715eed9..000000000 --- a/.ci/travis-install.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -set -e -x - -if [ "${TRAVIS_OS_NAME}" == "osx" ]; then - PYENV_ROOT="$HOME/.pyenv" - PATH="$PYENV_ROOT/bin:$PATH" - eval "$(pyenv init -)" -fi - -pip install --upgrade pip wheel -pip install --upgrade setuptools -pip install --upgrade -r .ci/requirements.txt diff --git a/.ci/travis-release.sh b/.ci/travis-release.sh deleted file mode 100755 index 664708b0d..000000000 --- a/.ci/travis-release.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/bin/bash - -set -e -x - - -if [ -z "${TRAVIS_TAG}" ]; then - # Not a release - exit 0 -fi - - -PACKAGE_VERSION=$(python ".ci/package-version.py") -PYPI_VERSION=$(python ".ci/pypi-check.py" "${PYMODULE}") - -if [ "${PACKAGE_VERSION}" == "${PYPI_VERSION}" ]; then - echo "${PYMODULE}-${PACKAGE_VERSION} is already published on PyPI" - exit 0 -fi - -# Check if all expected wheels have been built and uploaded. -release_platforms=( - "macosx_10_??_x86_64" - "manylinux1_i686" - "manylinux1_x86_64" - "win32" - "win_amd64" -) - -P="${PYMODULE}-${PACKAGE_VERSION}" -expected_wheels=() - -for pyver in ${RELEASE_PYTHON_VERSIONS}; do - abitag=$(python -c \ - "print('cp{maj}{min}-cp{maj}{min}{s}'.format( \ - maj='${pyver}'.split('.')[0], \ - min='${pyver}'.split('.')[1], - s='m' if tuple('${pyver}'.split('.')) < ('3', '8') else ''))") - for plat in "${release_platforms[@]}"; do - expected_wheels+=("${P}-${abitag}-${plat}.whl") - done -done - -rm -rf dist/*.whl dist/*.tar.* -python setup.py sdist -python ".ci/s3-download-release.py" --destdir=dist/ "${P}" - -_file_exists() { [[ -f $1 ]]; } - -for distfile in "${expected_wheels[@]}"; do - if ! _file_exists dist/${distfile}; then - echo "Expected wheel ${distfile} not found." - exit 1 - fi -done - -python -m twine upload dist/*.whl dist/*.tar.* diff --git a/.ci/travis-tests.sh b/.ci/travis-tests.sh deleted file mode 100755 index bf8f5adc6..000000000 --- a/.ci/travis-tests.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash - -set -e -x - -if [[ "${BUILD}" != *tests* ]]; then - echo "Skipping tests." - exit 0 -fi - -if [ "${TRAVIS_OS_NAME}" == "osx" ]; then - PYENV_ROOT="$HOME/.pyenv" - PATH="$PYENV_ROOT/bin:$PATH" - eval "$(pyenv init -)" -fi - -python setup.py test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..102c31639 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,179 @@ +name: Release + +on: + pull_request: + branches: + - "master" + - "ci" + - "[0-9]+.[0-9x]+*" + paths: + - "immutables/_version.py" + +jobs: + validate-release-request: + runs-on: ubuntu-latest + steps: + - name: Validate release PR + uses: edgedb/action-release/validate-pr@master + id: checkver + with: + github_token: ${{ secrets.RELEASE_BOT_GITHUB_TOKEN }} + version_file: immutables/_version.py + require_team: Release Managers + require_approval: no + version_line_pattern: | + __version__\s*=\s*(?:['"])([[:PEP440:]])(?:['"]) + + - name: Stop if not approved + if: steps.checkver.outputs.approved != 'true' + run: | + echo ::error::PR is not approved yet. + exit 1 + + - name: Store release version for later use + env: + VERSION: ${{ steps.checkver.outputs.version }} + run: | + mkdir -p dist/ + echo "${VERSION}" > dist/VERSION + + - uses: actions/upload-artifact@v4 + with: + name: dist-version + path: dist/ + + build-sdist: + needs: validate-release-request + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 50 + submodules: true + + - uses: actions/setup-python@v5 + + - name: Build source distribution + run: | + python -m pip install -U setuptools wheel pip + python setup.py sdist + + - uses: actions/upload-artifact@v4 + with: + name: dist-sdist + path: dist/*.tar.* + + build-wheels-matrix: + needs: validate-release-request + runs-on: ubuntu-latest + outputs: + include: ${{ steps.set-matrix.outputs.include }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + - run: pip install cibuildwheel==2.21.3 + - id: set-matrix + run: | + MATRIX_INCLUDE=$( + { + cibuildwheel --print-build-identifiers --platform linux --arch x86_64,aarch64 | grep cp | jq -nRc '{"only": inputs, "os": "ubuntu-latest"}' \ + && cibuildwheel --print-build-identifiers --platform macos --arch x86_64,arm64 | grep cp | jq -nRc '{"only": inputs, "os": "macos-latest"}' \ + && cibuildwheel --print-build-identifiers --platform windows --arch x86,AMD64 | grep cp | jq -nRc '{"only": inputs, "os": "windows-latest"}' + } | jq -sc + ) + echo "include=$MATRIX_INCLUDE" >> $GITHUB_OUTPUT + + build-wheels: + needs: build-wheels-matrix + runs-on: ${{ matrix.os }} + name: Build ${{ matrix.only }} + + strategy: + matrix: + include: ${{ fromJson(needs.build-wheels-matrix.outputs.include) }} + + defaults: + run: + shell: bash + + env: + PIP_DISABLE_PIP_VERSION_CHECK: 1 + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 50 + submodules: true + + - name: Set up QEMU + if: runner.os == 'Linux' + uses: docker/setup-qemu-action@v2 + + - uses: pypa/cibuildwheel@7940a4c0e76eb2030e473a5f864f291f63ee879b # v2.21.3 + with: + only: ${{ matrix.only }} + env: + CIBW_BUILD_VERBOSITY: 1 + CIBW_ENVIRONMENT: "IMMU_SKIP_MYPY_TESTS=1" + + - uses: actions/upload-artifact@v4 + with: + name: dist-${{ matrix.only }} + path: wheelhouse/*.whl + + publish: + needs: [build-sdist, build-wheels] + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 5 + submodules: false + + - uses: actions/download-artifact@v4 + with: + pattern: dist-* + merge-multiple: true + path: dist/ + + - name: Extract Release Version + id: relver + run: | + set -e + echo ::set-output name=version::$(cat dist/VERSION) + rm dist/VERSION + + - name: Merge and tag the PR + uses: edgedb/action-release/merge@master + with: + github_token: ${{ secrets.RELEASE_BOT_GITHUB_TOKEN }} + ssh_key: ${{ secrets.RELEASE_BOT_SSH_KEY }} + gpg_key: ${{ secrets.RELEASE_BOT_GPG_KEY }} + gpg_key_id: "5C468778062D87BF!" + tag_name: v${{ steps.relver.outputs.version }} + + - name: Publish Github Release + uses: elprans/gh-action-create-release@master + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: v${{ steps.relver.outputs.version }} + release_name: v${{ steps.relver.outputs.version }} + target: ${{ github.event.pull_request.base.ref }} + body: ${{ github.event.pull_request.body }} + draft: false + + - run: | + ls -al dist/ + + - name: Upload to PyPI + uses: pypa/gh-action-pypi-publish@master + with: + user: __token__ + password: ${{ secrets.PYPI_TOKEN }} + # password: ${{ secrets.TEST_PYPI_TOKEN }} + # repository_url: https://test.pypi.org/legacy/ diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 000000000..66cecbb0b --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,78 @@ +name: Tests + +on: + push: + branches: + - master + - ci + pull_request: + branches: + - master + +jobs: + tests: + runs-on: ${{ matrix.os }} + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + os: [windows-latest, ubuntu-latest, macos-latest] + arch: [x64, x86] + exclude: + # 32-bit Python is only available on Windows + - os: ubuntu-latest + arch: x86 + - os: macos-latest + arch: x86 + # https://github.com/actions/setup-python/issues/948 + - os: macos-latest + arch: x64 + python-version: 3.8 + - os: macos-latest + arch: x64 + python-version: 3.9 + - os: macos-latest + arch: x64 + python-version: 3.10 + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 50 + submodules: true + + - name: Check if release PR. + uses: edgedb/action-release/validate-pr@master + id: release + with: + github_token: ${{ secrets.RELEASE_BOT_GITHUB_TOKEN }} + missing_version_ok: yes + version_file: immutables/_version.py + version_line_pattern: | + __version__\s*=\s*(?:['"])([[:PEP440:]])(?:['"]) + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + if: steps.release.outputs.version == 0 + env: + PIP_DISABLE_PIP_VERSION_CHECK: 1 + with: + python-version: ${{ matrix.python-version }} + architecture: ${{ matrix.arch }} + + - name: Test + if: steps.release.outputs.version == 0 + run: | + python -m pip install -U pip setuptools + python -m pip install --verbose -e .[test] + flake8 immutables/ tests/ + mypy immutables/ + python -m pytest -v + + # This job exists solely to act as the test job aggregate to be + # targeted by branch policies. + test-conclusion: + name: "Test Conclusion" + needs: [tests] + runs-on: ubuntu-latest + steps: + - run: echo OK diff --git a/.gitignore b/.gitignore index 4b713b291..e44155791 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,6 @@ __pycache__/ /.cache /.pytest_cache /.coverage - +/.mypy_cache +/.venv* +/wheelhouse diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 7b81f2986..000000000 --- a/.travis.yml +++ /dev/null @@ -1,83 +0,0 @@ -language: generic - -env: - global: - - PYMODULE=immutables - - RELEASE_PYTHON_VERSIONS="3.5 3.6 3.7 3.8" - - - S3_UPLOAD_USERNAME=oss-ci-bot - - S3_UPLOAD_BUCKET=magicstack-oss-releases - - TWINE_USERNAME=magicstack-ci - -branches: - # Avoid building PR branches. - only: - - master - - ci - - releases - - /^v\d+(\.\d+)*$/ - -matrix: - fast_finish: - true - - include: - - os: linux - dist: trusty - sudo: false - language: python - python: "3.5" - env: BUILD=tests - - - os: linux - dist: xenial - sudo: true - language: python - python: "3.7" - env: BUILD=tests - - - os: linux - dist: xenial - language: python - python: "3.8" - env: BUILD=tests - - - os: linux - dist: trusty - sudo: required - language: python - python: "3.6" - env: BUILD=tests,wheels,release - services: [docker] - - - os: osx - env: BUILD=tests,wheels PYTHON_VERSION=3.5.5 - - - os: osx - env: BUILD=tests,wheels PYTHON_VERSION=3.6.5 - - - os: osx - env: BUILD=tests,wheels PYTHON_VERSION=3.7.0 - - - os: osx - env: BUILD=tests,wheels PYTHON_VERSION=3.8.0 - -cache: - pip - -before_install: - - .ci/travis-before-install.sh - -install: - - .ci/travis-install.sh - -script: - - .ci/travis-tests.sh - - .ci/travis-build-wheels.sh - -deploy: - provider: script - script: .ci/travis-release.sh - on: - tags: true - condition: '"${BUILD}" == *release*' diff --git a/LICENSE b/LICENSE index 5ce98d7b4..71e41c930 100644 --- a/LICENSE +++ b/LICENSE @@ -1,203 +1,7 @@ -Copyright (C) 2017-present MagicStack Inc. +The Immutables project is provided under the Apache 2.0 license. See +LICENSE-APACHE for the full text of the license. - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ +Additionally, this software contains the following code distributed a +different license (refer to the specific files for details): - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. + immutables/pythoncapi_compat.h (0BSD License) diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 000000000..bf911cf86 --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,200 @@ + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/MANIFEST.in b/MANIFEST.in index 26fa57e07..11526d29c 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,4 @@ -recursive-include tests *.py +recursive-include tests *.py test-data/* recursive-include immutables *.py *.c *.h *.pyi -include LICENSE README.rst +include LICENSE* NOTICE README.rst bench.png include immutables/py.typed diff --git a/Makefile b/Makefile index a625f5cdc..481d190a5 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,20 @@ -.PHONY: rtest build test clean +.PHONY: rtest build test clean all +PYTHON ?= python +ROOT = $(dir $(realpath $(firstword $(MAKEFILE_LIST)))) + + +all: build + build: - python setup.py build_ext --inplace + $(PYTHON) setup.py build_ext --inplace + +debug: + DEBUG_IMMUTABLES=1 $(PYTHON) setup.py build_ext --inplace test: - python setup.py test -v + $(PYTHON) -m pytest -v rtest: ~/dev/venvs/36-debug/bin/python setup.py build_ext --inplace @@ -14,8 +23,11 @@ rtest: ~/dev/venvs/36-debug/bin/python -m test.regrtest -R3:3 --testdir tests/ clean: - find . -name '*.pyc' | xargs rm - find . -name '*.so' | xargs rm + find . -name '*.pyc' | xargs rm -f + find . -name '*.so' | xargs rm -f rm -rf ./build rm -rf ./dist rm -rf ./*.egg-info + +testinstalled: + cd /tmp && $(PYTHON) $(ROOT)/tests/__init__.py diff --git a/NOTICE b/NOTICE new file mode 100644 index 000000000..8a796ab2c --- /dev/null +++ b/NOTICE @@ -0,0 +1 @@ +Copyright 2018-present Contributors to the immutables project. diff --git a/README.rst b/README.rst index 35845d61f..e14d3210f 100644 --- a/README.rst +++ b/README.rst @@ -1,11 +1,8 @@ immutables ========== -.. image:: https://travis-ci.org/MagicStack/immutables.svg?branch=master - :target: https://travis-ci.org/MagicStack/immutables - -.. image:: https://ci.appveyor.com/api/projects/status/tgbc6tq56u63qqhf?svg=true - :target: https://ci.appveyor.com/project/MagicStack/immutables +.. image:: https://github.com/MagicStack/immutables/workflows/Tests/badge.svg?branch=master + :target: https://github.com/MagicStack/immutables/actions?query=workflow%3ATests+branch%3Amaster+event%3Apush .. image:: https://img.shields.io/pypi/v/immutables.svg :target: https://pypi.python.org/pypi/immutables @@ -15,7 +12,8 @@ An immutable mapping type for Python. The underlying datastructure is a Hash Array Mapped Trie (HAMT) used in Clojure, Scala, Haskell, and other functional languages. This implementation is used in CPython 3.7 in the ``contextvars`` -module (see PEP 550 and PEP 567 for more details). +module (see `PEP 550 `_ and +`PEP 567 `_ for more details). Immutable mappings based on HAMT have O(log N) performance for both ``set()`` and ``get()`` operations, which is essentially O(1) for @@ -32,7 +30,7 @@ copy-on-write approach (the benchmark code is available Installation ------------ -``immutables`` requires Python 3.5+ and is available on PyPI:: +``immutables`` requires Python 3.6+ and is available on PyPI:: $ pip install immutables diff --git a/immutables/__init__.py b/immutables/__init__.py index 655d318cd..b8565b0c3 100644 --- a/immutables/__init__.py +++ b/immutables/__init__.py @@ -1,11 +1,25 @@ -try: +# flake8: noqa + +import sys + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: from ._map import Map -except ImportError: - from .map import Map else: - import collections.abc as _abc - _abc.Mapping.register(Map) + try: + from ._map import Map + except ImportError: + from .map import Map + else: + import collections.abc as _abc + _abc.Mapping.register(Map) + +from ._protocols import MapKeys as MapKeys +from ._protocols import MapValues as MapValues +from ._protocols import MapItems as MapItems +from ._protocols import MapMutation as MapMutation +from ._version import __version__ __all__ = 'Map', -__version__ = '0.12' diff --git a/immutables/_map.c b/immutables/_map.c index 7e6356222..256f46775 100644 --- a/immutables/_map.c +++ b/immutables/_map.c @@ -1,4 +1,5 @@ #include /* For offsetof */ +#include "pythoncapi_compat.h" #include "_map.h" @@ -393,12 +394,13 @@ static MapObject * map_update(uint64_t mutid, MapObject *o, PyObject *src); -#ifdef NDEBUG +#if !defined(NDEBUG) static void _map_node_array_validate(void *o) { assert(IS_ARRAY_NODE(o)); MapNode_Array *node = (MapNode_Array*)(o); + assert(node->a_count <= HAMT_ARRAY_NODE_SIZE); Py_ssize_t i = 0, count = 0; for (; i < HAMT_ARRAY_NODE_SIZE; i++) { if (node->a_array[i] != NULL) { @@ -527,10 +529,10 @@ _map_dump_format(_PyUnicodeWriter *writer, const char *format, ...) int ret; va_list vargs; -#ifdef HAVE_STDARG_PROTOTYPES - va_start(vargs, format); -#else +#if PY_VERSION_HEX < 0x030C00A1 && !defined(HAVE_STDARG_PROTOTYPES) va_start(vargs); +#else + va_start(vargs, format); #endif msg = PyUnicode_FromFormatV(format, vargs); va_end(vargs); @@ -570,7 +572,7 @@ map_node_bitmap_new(Py_ssize_t size, uint64_t mutid) return NULL; } - Py_SIZE(node) = size; + Py_SET_SIZE(node, size); for (i = 0; i < size; i++) { node->b_array[i] = NULL; @@ -1109,7 +1111,7 @@ map_node_bitmap_without(MapNode_Bitmap *self, } } -#ifdef NDEBUG +#if !defined(NDEBUG) /* Ensure that Collision.without implementation converts to Bitmap nodes itself. */ @@ -1245,7 +1247,7 @@ map_node_bitmap_dealloc(MapNode_Bitmap *self) Py_ssize_t i; PyObject_GC_UnTrack(self); - Py_TRASHCAN_SAFE_BEGIN(self) + Py_TRASHCAN_BEGIN(self, map_node_bitmap_dealloc) if (len > 0) { i = len; @@ -1255,7 +1257,7 @@ map_node_bitmap_dealloc(MapNode_Bitmap *self) } Py_TYPE(self)->tp_free((PyObject *)self); - Py_TRASHCAN_SAFE_END(self) + Py_TRASHCAN_END } static int @@ -1282,7 +1284,7 @@ map_node_bitmap_dump(MapNode_Bitmap *node, if (tmp1 == NULL) { goto error; } - tmp2 = _PyLong_Format(tmp1, 2); + tmp2 = PyNumber_ToBase(tmp1, 2); Py_DECREF(tmp1); if (tmp2 == NULL) { goto error; @@ -1355,7 +1357,7 @@ map_node_collision_new(int32_t hash, Py_ssize_t size, uint64_t mutid) node->c_array[i] = NULL; } - Py_SIZE(node) = size; + Py_SET_SIZE(node, size); node->c_hash = hash; node->c_mutid = mutid; @@ -1662,7 +1664,7 @@ map_node_collision_dealloc(MapNode_Collision *self) Py_ssize_t len = Py_SIZE(self); PyObject_GC_UnTrack(self); - Py_TRASHCAN_SAFE_BEGIN(self) + Py_TRASHCAN_BEGIN(self, map_node_collision_dealloc) if (len > 0) { @@ -1672,7 +1674,7 @@ map_node_collision_dealloc(MapNode_Collision *self) } Py_TYPE(self)->tp_free((PyObject *)self); - Py_TRASHCAN_SAFE_END(self) + Py_TRASHCAN_END } static int @@ -1744,6 +1746,7 @@ map_node_array_clone(MapNode_Array *node, uint64_t mutid) Py_ssize_t i; VALIDATE_ARRAY_NODE(node) + assert(node->a_count <= HAMT_ARRAY_NODE_SIZE); /* Create a new Array node. */ clone = (MapNode_Array *)map_node_array_new(node->a_count, mutid); @@ -1806,6 +1809,7 @@ map_node_array_assoc(MapNode_Array *self, if (mutid != 0 && self->a_mutid == mutid) { new_node = self; + self->a_count++; Py_INCREF(self); } else { @@ -1940,9 +1944,9 @@ map_node_array_without(MapNode_Array *self, if (target == NULL) { return W_ERROR; } - target->a_count = new_count; } + target->a_count = new_count; Py_CLEAR(target->a_array[idx]); *new_node = (MapNode*)target; /* borrow */ @@ -2006,7 +2010,7 @@ map_node_array_without(MapNode_Array *self, } else { -#ifdef NDEBUG +#if !defined(NDEBUG) if (IS_COLLISION_NODE(node)) { assert( (map_node_collision_count( @@ -2079,14 +2083,14 @@ map_node_array_dealloc(MapNode_Array *self) Py_ssize_t i; PyObject_GC_UnTrack(self); - Py_TRASHCAN_SAFE_BEGIN(self) + Py_TRASHCAN_BEGIN(self, map_node_array_dealloc) for (i = 0; i < HAMT_ARRAY_NODE_SIZE; i++) { Py_XDECREF(self->a_array[i]); } Py_TYPE(self)->tp_free((PyObject *)self); - Py_TRASHCAN_SAFE_END(self) + Py_TRASHCAN_END } static int @@ -2101,7 +2105,9 @@ map_node_array_dump(MapNode_Array *node, goto error; } - if (_map_dump_format(writer, "ArrayNode(id=%p):\n", node)) { + if (_map_dump_format(writer, "ArrayNode(id=%p count=%zd):\n", + node, node->a_count) + ) { goto error; } @@ -2298,7 +2304,7 @@ map_iterator_bitmap_next(MapIteratorState *iter, Py_ssize_t pos = iter->i_pos[level]; if (pos + 1 >= Py_SIZE(node)) { -#ifdef NDEBUG +#if !defined(NDEBUG) assert(iter->i_level >= 0); iter->i_nodes[iter->i_level] = NULL; #endif @@ -2335,7 +2341,7 @@ map_iterator_collision_next(MapIteratorState *iter, Py_ssize_t pos = iter->i_pos[level]; if (pos + 1 >= Py_SIZE(node)) { -#ifdef NDEBUG +#if !defined(NDEBUG) assert(iter->i_level >= 0); iter->i_nodes[iter->i_level] = NULL; #endif @@ -2359,7 +2365,7 @@ map_iterator_array_next(MapIteratorState *iter, Py_ssize_t pos = iter->i_pos[level]; if (pos >= HAMT_ARRAY_NODE_SIZE) { -#ifdef NDEBUG +#if !defined(NDEBUG) assert(iter->i_level >= 0); iter->i_nodes[iter->i_level] = NULL; #endif @@ -2381,7 +2387,7 @@ map_iterator_array_next(MapIteratorState *iter, } } -#ifdef NDEBUG +#if !defined(NDEBUG) assert(iter->i_level >= 0); iter->i_nodes[iter->i_level] = NULL; #endif @@ -2804,9 +2810,23 @@ map_new_items_view(MapObject *o) /////////////////////////////////// _MapKeys_Type +static int +map_tp_contains(BaseMapObject *self, PyObject *key); + +static int +_map_keys_tp_contains(MapView *self, PyObject *key) +{ + return map_tp_contains((BaseMapObject *)self->mv_obj, key); +} + +static PySequenceMethods _MapKeys_as_sequence = { + .sq_contains = (objobjproc)_map_keys_tp_contains, +}; + PyTypeObject _MapKeys_Type = { PyVarObject_HEAD_INIT(NULL, 0) "keys", + .tp_as_sequence = &_MapKeys_as_sequence, VIEW_TYPE_SHARED_SLOTS }; @@ -3188,14 +3208,14 @@ map_py_repr(BaseMapObject *m) if (MapMutation_Check(m)) { if (_PyUnicodeWriter_WriteASCIIString( - &writer, "", m); - if (addr == NULL) { - goto error; - } - if (_PyUnicodeWriter_WriteStr(&writer, addr) < 0) { - Py_DECREF(addr); - goto error; - } - Py_DECREF(addr); - Py_ReprLeave((PyObject *)m); return _PyUnicodeWriter_Finish(&writer); @@ -3363,12 +3373,14 @@ map_reduce(MapObject *self) return tup; } +#if PY_VERSION_HEX < 0x030900A6 static PyObject * map_py_class_getitem(PyObject *type, PyObject *item) { Py_INCREF(type); return type; } +#endif static PyMethodDef Map_methods[] = { {"set", (PyCFunction)map_py_set, METH_VARARGS, NULL}, @@ -3383,9 +3395,13 @@ static PyMethodDef Map_methods[] = { {"__dump__", (PyCFunction)map_py_dump, METH_NOARGS, NULL}, { "__class_getitem__", +#if PY_VERSION_HEX < 0x030900A6 (PyCFunction)map_py_class_getitem, +#else + Py_GenericAlias, +#endif METH_O|METH_CLASS, - NULL + "See PEP 585" }, {NULL, NULL} }; @@ -3418,7 +3434,11 @@ PyTypeObject _Map_Type = { .tp_iter = (getiterfunc)map_tp_iter, .tp_dealloc = (destructor)map_tp_dealloc, .tp_getattro = PyObject_GenericGetAttr, - .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_HAVE_GC, + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_HAVE_GC + #ifdef Py_TPFLAGS_MAPPING + | Py_TPFLAGS_MAPPING + #endif + , .tp_richcompare = map_tp_richcompare, .tp_traverse = (traverseproc)map_tp_traverse, .tp_clear = (inquiry)map_tp_clear, diff --git a/immutables/_map.h b/immutables/_map.h index dd12af9a0..37b02a4cb 100644 --- a/immutables/_map.h +++ b/immutables/_map.h @@ -4,7 +4,18 @@ #include #include "Python.h" -#define _Py_HAMT_MAX_TREE_DEPTH 7 +/* +HAMT tree is shaped by hashes of keys. Every group of 5 bits of a hash denotes +the exact position of the key in one level of the tree. Since we're using +32 bit hashes, we can have at most 7 such levels. Although if there are +two distinct keys with equal hashes, they will have to occupy the same +cell in the 7th level of the tree -- so we'd put them in a "collision" node. +Which brings the total possible tree depth to 8. Read more about the actual +layout of the HAMT tree in `_map.c`. + +This constant is used to define a datastucture for storing iteration state. +*/ +#define _Py_HAMT_MAX_TREE_DEPTH 8 #define Map_Check(o) (Py_TYPE(o) == &_Map_Type) diff --git a/immutables/_map.pyi b/immutables/_map.pyi index 863d911f4..039be94af 100644 --- a/immutables/_map.pyi +++ b/immutables/_map.pyi @@ -1,87 +1,80 @@ +import sys from typing import Any +from typing import Dict from typing import Generic -from typing import Hashable from typing import Iterable from typing import Iterator -from typing import Literal from typing import Mapping -from typing import MutableMapping -from typing import NoReturn +from typing import Optional from typing import Tuple from typing import Type -from typing import TypeVar from typing import Union - - -K = TypeVar('K', bound=Hashable) -V = TypeVar('V', bound=Any) -D = TypeVar('D', bound=Any) - - -class BitmapNode: ... - - -class MapKeys(Generic[K]): - def __init__(self, c: int, m: BitmapNode) -> None: ... - def __len__(self) -> int: ... - def __iter__(self) -> Iterator[K]: ... - - -class MapValues(Generic[V]): - def __init__(self, c: int, m: BitmapNode) -> None: ... - def __len__(self) -> int: ... - def __iter__(self) -> Iterator[V]: ... - - -class MapItems(Generic[K, V]): - def __init__(self, c: int, m: BitmapNode) -> None: ... - def __len__(self) -> int: ... - def __iter__(self) -> Iterator[Tuple[K, V]]: ... - - -class Map(Mapping[K, V]): +from typing import overload + +if sys.version_info >= (3, 9): + from types import GenericAlias + +from ._protocols import IterableItems +from ._protocols import MapItems +from ._protocols import MapKeys +from ._protocols import MapMutation +from ._protocols import MapValues +from ._protocols import HT +from ._protocols import KT +from ._protocols import T +from ._protocols import VT_co + + +class Map(Mapping[KT, VT_co]): + @overload + def __init__(self) -> None: ... + @overload + def __init__(self: Map[str, VT_co], **kw: VT_co) -> None: ... + @overload + def __init__( + self, __col: Union[IterableItems[KT, VT_co], Iterable[Tuple[KT, VT_co]]] + ) -> None: ... + @overload def __init__( - self, col: Union[Mapping[K, V], Iterable[Tuple[K, V]]] = ..., **kw: V - ): ... - def __reduce__(self) -> NoReturn: ... + self: Map[Union[KT, str], VT_co], + __col: Union[IterableItems[KT, VT_co], Iterable[Tuple[KT, VT_co]]], + **kw: VT_co + ) -> None: ... + def __reduce__(self) -> Tuple[Type[Map[KT, VT_co]], Tuple[Dict[KT, VT_co]]]: ... def __len__(self) -> int: ... def __eq__(self, other: Any) -> bool: ... + @overload + def update( + self, + __col: Union[IterableItems[KT, VT_co], Iterable[Tuple[KT, VT_co]]] + ) -> Map[KT, VT_co]: ... + @overload + def update( + self: Map[Union[HT, str], Any], + __col: Union[IterableItems[KT, VT_co], Iterable[Tuple[KT, VT_co]]], + **kw: VT_co # type: ignore[misc] + ) -> Map[KT, VT_co]: ... + @overload def update( - self, col: Union[Mapping[K, V], Iterable[Tuple[K, V]]] = ..., **kw: V - ) -> Map[K, V]: ... - def mutate(self) -> MapMutation[K, V]: ... - def set(self, key: K, val: V) -> Map[K, V]: ... - def delete(self, key: K) -> Map[K, V]: ... - def get(self, key: K, default: D = ...) -> Union[V, D]: ... - def __getitem__(self, key: K) -> V: ... - def __contains__(self, key: object) -> bool: ... - def __iter__(self) -> Iterator[K]: ... - def keys(self) -> MapKeys[K]: ... - def values(self) -> MapValues[V]: ... - def items(self) -> MapItems[K, V]: ... + self: Map[Union[HT, str], Any], + **kw: VT_co # type: ignore[misc] + ) -> Map[KT, VT_co]: ... + def mutate(self) -> MapMutation[KT, VT_co]: ... + def set(self, key: KT, val: VT_co) -> Map[KT, VT_co]: ... # type: ignore[misc] + def delete(self, key: KT) -> Map[KT, VT_co]: ... + @overload + def get(self, key: KT) -> Optional[VT_co]: ... + @overload + def get(self, key: KT, default: Union[VT_co, T]) -> Union[VT_co, T]: ... + def __getitem__(self, key: KT) -> VT_co: ... + def __contains__(self, key: Any) -> bool: ... + def __iter__(self) -> Iterator[KT]: ... + def keys(self) -> MapKeys[KT]: ... # type: ignore[override] + def values(self) -> MapValues[VT_co]: ... # type: ignore[override] + def items(self) -> MapItems[KT, VT_co]: ... # type: ignore[override] def __hash__(self) -> int: ... def __dump__(self) -> str: ... - def __class_getitem__(cls, item: Any) -> Type[Map]: ... - - -S = TypeVar('S', bound='MapMutation') - - -class MapMutation(MutableMapping[K, V]): - def __init__(self, count: int, root: BitmapNode) -> None: ... - def set(self, key: K, val: V) -> None: ... - def __enter__(self: S) -> S: ... - def __exit__(self, *exc: Any) -> Literal[False]: ... - def __iter__(self) -> NoReturn: ... - def __delitem__(self, key: K) -> None: ... - def __setitem__(self, key: K, val: V) -> None: ... - def pop(self, __key: K, __default: D = ...) -> Union[V, D]: ... - def get(self, key: K, default: D = ...) -> Union[V, D]: ... - def __getitem__(self, key: K) -> V: ... - def __contains__(self, key: Any) -> bool: ... - def update( - self, col: Union[Mapping[K, V], Iterable[Tuple[K, V]]] = ..., **kw: V - ): ... - def finish(self) -> Map[K, V]: ... - def __len__(self) -> int: ... - def __eq__(self, other: Any) -> bool: ... + if sys.version_info >= (3, 9): + def __class_getitem__(cls, item: Any) -> GenericAlias: ... + else: + def __class_getitem__(cls, item: Any) -> Type[Map[Any, Any]]: ... diff --git a/immutables/_protocols.py b/immutables/_protocols.py new file mode 100644 index 000000000..49d8ee1e7 --- /dev/null +++ b/immutables/_protocols.py @@ -0,0 +1,86 @@ +import sys +from typing import Any +from typing import Hashable +from typing import Iterable +from typing import Iterator +from typing import NoReturn +from typing import Optional +from typing import Tuple +from typing import TypeVar +from typing import Union +from typing import overload + +if sys.version_info >= (3, 8): + from typing import Protocol + from typing import TYPE_CHECKING +else: + from typing_extensions import Protocol + from typing_extensions import TYPE_CHECKING + +if TYPE_CHECKING: + from ._map import Map + +HT = TypeVar('HT', bound=Hashable) +KT = TypeVar('KT', bound=Hashable) +KT_co = TypeVar('KT_co', covariant=True) +MM = TypeVar('MM', bound='MapMutation[Any, Any]') +T = TypeVar('T') +VT = TypeVar('VT') +VT_co = TypeVar('VT_co', covariant=True) + + +class MapKeys(Protocol[KT_co]): + def __len__(self) -> int: ... + def __iter__(self) -> Iterator[KT_co]: ... + def __contains__(self, __key: object) -> bool: ... + + +class MapValues(Protocol[VT_co]): + def __len__(self) -> int: ... + def __iter__(self) -> Iterator[VT_co]: ... + + +class MapItems(Protocol[KT_co, VT_co]): + def __len__(self) -> int: ... + def __iter__(self) -> Iterator[Tuple[KT_co, VT_co]]: ... + + +class IterableItems(Protocol[KT_co, VT_co]): + def items(self) -> Iterable[Tuple[KT_co, VT_co]]: ... + + +class MapMutation(Protocol[KT, VT]): + def set(self, key: KT, val: VT) -> None: ... + def __enter__(self: MM) -> MM: ... + def __exit__(self, *exc: Any) -> bool: ... + def __iter__(self) -> NoReturn: ... + def __delitem__(self, key: KT) -> None: ... + def __setitem__(self, key: KT, val: VT) -> None: ... + @overload + def pop(self, __key: KT) -> VT: ... + @overload + def pop(self, __key: KT, __default: T) -> Union[VT, T]: ... + @overload + def get(self, key: KT) -> Optional[VT]: ... + @overload + def get(self, key: KT, default: Union[VT, T]) -> Union[VT, T]: ... + def __getitem__(self, key: KT) -> VT: ... + def __contains__(self, key: object) -> bool: ... + + @overload + def update( + self, + __col: Union[IterableItems[KT, VT], Iterable[Tuple[KT, VT]]] + ) -> None: ... + + @overload + def update( + self: 'MapMutation[Union[HT, str], Any]', + __col: Union[IterableItems[KT, VT], Iterable[Tuple[KT, VT]]], + **kw: VT + ) -> None: ... + @overload + def update(self: 'MapMutation[Union[HT, str], Any]', **kw: VT) -> None: ... + def finish(self) -> 'Map[KT, VT]': ... + def __len__(self) -> int: ... + def __eq__(self, other: Any) -> bool: ... diff --git a/immutables/_testutils.py b/immutables/_testutils.py new file mode 100644 index 000000000..3f174b27e --- /dev/null +++ b/immutables/_testutils.py @@ -0,0 +1,80 @@ +class HashKey: + _crasher = None + + def __init__(self, hash, name, *, error_on_eq_to=None): + assert hash != -1 + self.name = name + self.hash = hash + self.error_on_eq_to = error_on_eq_to + + def __repr__(self): + if self._crasher is not None and self._crasher.error_on_repr: + raise ReprError + return ''.format(self.name, self.hash) + + def __hash__(self): + if self._crasher is not None and self._crasher.error_on_hash: + raise HashingError + + return self.hash + + def __eq__(self, other): + if not isinstance(other, HashKey): + return NotImplemented + + if self._crasher is not None and self._crasher.error_on_eq: + raise EqError + + if self.error_on_eq_to is not None and self.error_on_eq_to is other: + raise ValueError('cannot compare {!r} to {!r}'.format(self, other)) + if other.error_on_eq_to is not None and other.error_on_eq_to is self: + raise ValueError('cannot compare {!r} to {!r}'.format(other, self)) + + return (self.name, self.hash) == (other.name, other.hash) + + +class KeyStr(str): + + def __hash__(self): + if HashKey._crasher is not None and HashKey._crasher.error_on_hash: + raise HashingError + return super().__hash__() + + def __eq__(self, other): + if HashKey._crasher is not None and HashKey._crasher.error_on_eq: + raise EqError + return super().__eq__(other) + + def __repr__(self, other): + if HashKey._crasher is not None and HashKey._crasher.error_on_repr: + raise ReprError + return super().__eq__(other) + + +class HashKeyCrasher: + + def __init__(self, *, error_on_hash=False, error_on_eq=False, + error_on_repr=False): + self.error_on_hash = error_on_hash + self.error_on_eq = error_on_eq + self.error_on_repr = error_on_repr + + def __enter__(self): + if HashKey._crasher is not None: + raise RuntimeError('cannot nest crashers') + HashKey._crasher = self + + def __exit__(self, *exc): + HashKey._crasher = None + + +class HashingError(Exception): + pass + + +class EqError(Exception): + pass + + +class ReprError(Exception): + pass diff --git a/immutables/_version.py b/immutables/_version.py new file mode 100644 index 000000000..e5c270cbe --- /dev/null +++ b/immutables/_version.py @@ -0,0 +1,13 @@ +# This file MUST NOT contain anything but the __version__ assignment. +# +# When making a release, change the value of __version__ +# to an appropriate value, and open a pull request against +# the correct branch (master if making a new feature release). +# The commit message MUST contain a properly formatted release +# log, and the commit must be signed. +# +# The release automation will: build and test the packages for the +# supported platforms, publish the packages on PyPI, merge the PR +# to the target branch, create a Git tag pointing to the commit. + +__version__ = '0.21' diff --git a/immutables/map.py b/immutables/map.py index 3ea4656b3..462273578 100644 --- a/immutables/map.py +++ b/immutables/map.py @@ -2,6 +2,7 @@ import itertools import reprlib import sys +import types __all__ = ('Map',) @@ -19,7 +20,10 @@ def map_hash(o): x = hash(o) - return (x & 0xffffffff) ^ ((x >> 32) & 0xffffffff) + if sys.hash_info.width > 32: + return (x & 0xffffffff) ^ ((x >> 32) & 0xffffffff) + else: + return x def map_mask(hash, shift): @@ -47,6 +51,15 @@ def map_bitindex(bitmap, bit): void = object() +class _Unhashable: + __slots__ = () + __hash__ = None + + +_NULL = _Unhashable() +del _Unhashable + + class BitmapNode: def __init__(self, size, bitmap, array, mutid): @@ -70,7 +83,7 @@ def assoc(self, shift, hash, key, val, mutid): key_or_null = self.array[key_idx] val_or_node = self.array[val_idx] - if key_or_null is None: + if key_or_null is _NULL: sub_node, added = val_or_node.assoc( shift + 5, hash, key, val, mutid) if val_or_node is sub_node: @@ -111,12 +124,12 @@ def assoc(self, shift, hash, key, val, mutid): mutid) if mutid and mutid == self.mutid: - self.array[key_idx] = None + self.array[key_idx] = _NULL self.array[val_idx] = sub_node return self, True else: ret = self.clone(mutid) - ret.array[key_idx] = None + ret.array[key_idx] = _NULL ret.array[val_idx] = sub_node return ret, True @@ -153,7 +166,7 @@ def find(self, shift, hash, key): key_or_null = self.array[key_idx] val_or_node = self.array[val_idx] - if key_or_null is None: + if key_or_null is _NULL: return val_or_node.find(shift + 5, hash, key) if key == key_or_null: @@ -173,7 +186,7 @@ def without(self, shift, hash, key, mutid): key_or_null = self.array[key_idx] val_or_node = self.array[val_idx] - if key_or_null is None: + if key_or_null is _NULL: res, sub_node = val_or_node.without(shift + 5, hash, key, mutid) if res is W_EMPTY: @@ -182,7 +195,7 @@ def without(self, shift, hash, key, mutid): elif res is W_NEWNODE: if (type(sub_node) is BitmapNode and sub_node.size == 2 and - sub_node.array[0] is not None): + sub_node.array[0] is not _NULL): if mutid and mutid == self.mutid: self.array[key_idx] = sub_node.array[0] @@ -231,7 +244,7 @@ def keys(self): for i in range(0, self.size, 2): key_or_null = self.array[i] - if key_or_null is None: + if key_or_null is _NULL: val_or_node = self.array[i + 1] yield from val_or_node.keys() else: @@ -242,7 +255,7 @@ def values(self): key_or_null = self.array[i] val_or_node = self.array[i + 1] - if key_or_null is None: + if key_or_null is _NULL: yield from val_or_node.values() else: yield val_or_node @@ -252,7 +265,7 @@ def items(self): key_or_null = self.array[i] val_or_node = self.array[i + 1] - if key_or_null is None: + if key_or_null is _NULL: yield from val_or_node.items() else: yield key_or_null, val_or_node @@ -269,8 +282,8 @@ def dump(self, buf, level): # pragma: no cover pad = ' ' * (level + 2) - if key_or_null is None: - buf.append(pad + 'None:') + if key_or_null is _NULL: + buf.append(pad + 'NULL:') val_or_node.dump(buf, level + 2) else: buf.append(pad + '{!r}: {!r}'.format(key_or_null, val_or_node)) @@ -328,7 +341,7 @@ def assoc(self, shift, hash, key, val, mutid): else: new_node = BitmapNode( - 2, map_bitpos(self.hash, shift), [None, self], mutid) + 2, map_bitpos(self.hash, shift), [_NULL, self], mutid) return new_node.assoc(shift, hash, key, val, mutid) def without(self, shift, hash, key, mutid): @@ -433,7 +446,17 @@ def __iter__(self): class Map: - def __init__(self, col=None, **kw): + def __init__(self, *args, **kw): + if not args: + col = None + elif len(args) == 1: + col = args[0] + else: + raise TypeError( + "immutables.Map expected at most 1 arguments, " + "got {}".format(len(args)) + ) + self.__count = 0 self.__root = BitmapNode(0, 0, [], 0) self.__hash = -1 @@ -483,8 +506,18 @@ def __eq__(self, other): return True - def update(self, col=None, **kw): + def update(self, *args, **kw): + if not args: + col = None + elif len(args) == 1: + col = args[0] + else: + raise TypeError( + "update expected at most 1 arguments, got {}".format(len(args)) + ) + it = None + if col is not None: if hasattr(col, 'items'): it = iter(col.items()) @@ -622,16 +655,18 @@ def __repr__(self): items = [] for key, val in self.items(): items.append("{!r}: {!r}".format(key, val)) - return ''.format( - ', '.join(items), id(self)) + return 'immutables.Map({{{}}})'.format(', '.join(items)) def __dump__(self): # pragma: no cover buf = [] self.__root.dump(buf, 0) return '\n'.join(buf) - def __class_getitem__(cls, item): - return cls + if sys.version_info >= (3, 9): + __class_getitem__ = classmethod(types.GenericAlias) + else: + def __class_getitem__(cls, item): + return cls class MapMutation: @@ -721,7 +756,16 @@ def __contains__(self, key): else: return True - def update(self, col=None, **kw): + def update(self, *args, **kw): + if not args: + col = None + elif len(args) == 1: + col = args[0] + else: + raise TypeError( + "update expected at most 1 arguments, got {}".format(len(args)) + ) + if self.__mutid == 0: raise ValueError('mutation {!r} has been finished'.format(self)) @@ -740,8 +784,7 @@ def update(self, col=None, **kw): it = iter(kw.items()) if it is None: - - return self + return root = self.__root count = self.__count @@ -783,8 +826,7 @@ def __repr__(self): items = [] for key, val in self.__root.items(): items.append("{!r}: {!r}".format(key, val)) - return ''.format( - ', '.join(items), id(self)) + return 'immutables.MapMutation({{{}}})'.format(', '.join(items)) def __len__(self): return self.__count diff --git a/immutables/pythoncapi_compat.h b/immutables/pythoncapi_compat.h new file mode 100644 index 000000000..2a87a55aa --- /dev/null +++ b/immutables/pythoncapi_compat.h @@ -0,0 +1,852 @@ +// Header file providing new C API functions to old Python versions. +// +// File distributed under the Zero Clause BSD (0BSD) license. +// Copyright Contributors to the pythoncapi_compat project. +// +// Homepage: +// https://github.com/python/pythoncapi_compat +// +// Latest version: +// https://raw.githubusercontent.com/python/pythoncapi_compat/master/pythoncapi_compat.h +// +// SPDX-License-Identifier: 0BSD + +#ifndef PYTHONCAPI_COMPAT +#define PYTHONCAPI_COMPAT + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include "frameobject.h" // PyFrameObject, PyFrame_GetBack() + + +// Compatibility with Visual Studio 2013 and older which don't support +// the inline keyword in C (only in C++): use __inline instead. +#if (defined(_MSC_VER) && _MSC_VER < 1900 \ + && !defined(__cplusplus) && !defined(inline)) +# define PYCAPI_COMPAT_STATIC_INLINE(TYPE) static __inline TYPE +#else +# define PYCAPI_COMPAT_STATIC_INLINE(TYPE) static inline TYPE +#endif + + +#ifndef _Py_CAST +# define _Py_CAST(type, expr) ((type)(expr)) +#endif + +// On C++11 and newer, _Py_NULL is defined as nullptr on C++11, +// otherwise it is defined as NULL. +#ifndef _Py_NULL +# if defined(__cplusplus) && __cplusplus >= 201103 +# define _Py_NULL nullptr +# else +# define _Py_NULL NULL +# endif +#endif + +// Cast argument to PyObject* type. +#ifndef _PyObject_CAST +# define _PyObject_CAST(op) _Py_CAST(PyObject*, op) +#endif + + +// bpo-42262 added Py_NewRef() to Python 3.10.0a3 +#if PY_VERSION_HEX < 0x030A00A3 && !defined(Py_NewRef) +PYCAPI_COMPAT_STATIC_INLINE(PyObject*) +_Py_NewRef(PyObject *obj) +{ + Py_INCREF(obj); + return obj; +} +#define Py_NewRef(obj) _Py_NewRef(_PyObject_CAST(obj)) +#endif + + +// bpo-42262 added Py_XNewRef() to Python 3.10.0a3 +#if PY_VERSION_HEX < 0x030A00A3 && !defined(Py_XNewRef) +PYCAPI_COMPAT_STATIC_INLINE(PyObject*) +_Py_XNewRef(PyObject *obj) +{ + Py_XINCREF(obj); + return obj; +} +#define Py_XNewRef(obj) _Py_XNewRef(_PyObject_CAST(obj)) +#endif + + +// bpo-39573 added Py_SET_REFCNT() to Python 3.9.0a4 +#if PY_VERSION_HEX < 0x030900A4 && !defined(Py_SET_REFCNT) +PYCAPI_COMPAT_STATIC_INLINE(void) +_Py_SET_REFCNT(PyObject *ob, Py_ssize_t refcnt) +{ + ob->ob_refcnt = refcnt; +} +#define Py_SET_REFCNT(ob, refcnt) _Py_SET_REFCNT(_PyObject_CAST(ob), refcnt) +#endif + + +// Py_SETREF() and Py_XSETREF() were added to Python 3.5.2. +// It is excluded from the limited C API. +#if (PY_VERSION_HEX < 0x03050200 && !defined(Py_SETREF)) && !defined(Py_LIMITED_API) +#define Py_SETREF(dst, src) \ + do { \ + PyObject **_tmp_dst_ptr = _Py_CAST(PyObject**, &(dst)); \ + PyObject *_tmp_dst = (*_tmp_dst_ptr); \ + *_tmp_dst_ptr = _PyObject_CAST(src); \ + Py_DECREF(_tmp_dst); \ + } while (0) + +#define Py_XSETREF(dst, src) \ + do { \ + PyObject **_tmp_dst_ptr = _Py_CAST(PyObject**, &(dst)); \ + PyObject *_tmp_dst = (*_tmp_dst_ptr); \ + *_tmp_dst_ptr = _PyObject_CAST(src); \ + Py_XDECREF(_tmp_dst); \ + } while (0) +#endif + + +// bpo-43753 added Py_Is(), Py_IsNone(), Py_IsTrue() and Py_IsFalse() +// to Python 3.10.0b1. +#if PY_VERSION_HEX < 0x030A00B1 && !defined(Py_Is) +# define Py_Is(x, y) ((x) == (y)) +#endif +#if PY_VERSION_HEX < 0x030A00B1 && !defined(Py_IsNone) +# define Py_IsNone(x) Py_Is(x, Py_None) +#endif +#if PY_VERSION_HEX < 0x030A00B1 && !defined(Py_IsTrue) +# define Py_IsTrue(x) Py_Is(x, Py_True) +#endif +#if PY_VERSION_HEX < 0x030A00B1 && !defined(Py_IsFalse) +# define Py_IsFalse(x) Py_Is(x, Py_False) +#endif + + +// bpo-39573 added Py_SET_TYPE() to Python 3.9.0a4 +#if PY_VERSION_HEX < 0x030900A4 && !defined(Py_SET_TYPE) +PYCAPI_COMPAT_STATIC_INLINE(void) +_Py_SET_TYPE(PyObject *ob, PyTypeObject *type) +{ + ob->ob_type = type; +} +#define Py_SET_TYPE(ob, type) _Py_SET_TYPE(_PyObject_CAST(ob), type) +#endif + + +// bpo-39573 added Py_SET_SIZE() to Python 3.9.0a4 +#if PY_VERSION_HEX < 0x030900A4 && !defined(Py_SET_SIZE) +PYCAPI_COMPAT_STATIC_INLINE(void) +_Py_SET_SIZE(PyVarObject *ob, Py_ssize_t size) +{ + ob->ob_size = size; +} +#define Py_SET_SIZE(ob, size) _Py_SET_SIZE((PyVarObject*)(ob), size) +#endif + + +// bpo-40421 added PyFrame_GetCode() to Python 3.9.0b1 +#if PY_VERSION_HEX < 0x030900B1 || defined(PYPY_VERSION) +PYCAPI_COMPAT_STATIC_INLINE(PyCodeObject*) +PyFrame_GetCode(PyFrameObject *frame) +{ + assert(frame != _Py_NULL); + assert(frame->f_code != _Py_NULL); + return _Py_CAST(PyCodeObject*, Py_NewRef(frame->f_code)); +} +#endif + +PYCAPI_COMPAT_STATIC_INLINE(PyCodeObject*) +_PyFrame_GetCodeBorrow(PyFrameObject *frame) +{ + PyCodeObject *code = PyFrame_GetCode(frame); + Py_DECREF(code); + return code; +} + + +// bpo-40421 added PyFrame_GetBack() to Python 3.9.0b1 +#if PY_VERSION_HEX < 0x030900B1 && !defined(PYPY_VERSION) +PYCAPI_COMPAT_STATIC_INLINE(PyFrameObject*) +PyFrame_GetBack(PyFrameObject *frame) +{ + assert(frame != _Py_NULL); + return _Py_CAST(PyFrameObject*, Py_XNewRef(frame->f_back)); +} +#endif + +#if !defined(PYPY_VERSION) +PYCAPI_COMPAT_STATIC_INLINE(PyFrameObject*) +_PyFrame_GetBackBorrow(PyFrameObject *frame) +{ + PyFrameObject *back = PyFrame_GetBack(frame); + Py_XDECREF(back); + return back; +} +#endif + + +// bpo-40421 added PyFrame_GetLocals() to Python 3.11.0a7 +#if PY_VERSION_HEX < 0x030B00A7 && !defined(PYPY_VERSION) +PYCAPI_COMPAT_STATIC_INLINE(PyObject*) +PyFrame_GetLocals(PyFrameObject *frame) +{ +#if PY_VERSION_HEX >= 0x030400B1 + if (PyFrame_FastToLocalsWithError(frame) < 0) { + return NULL; + } +#else + PyFrame_FastToLocals(frame); +#endif + return Py_NewRef(frame->f_locals); +} +#endif + + +// bpo-40421 added PyFrame_GetGlobals() to Python 3.11.0a7 +#if PY_VERSION_HEX < 0x030B00A7 && !defined(PYPY_VERSION) +PYCAPI_COMPAT_STATIC_INLINE(PyObject*) +PyFrame_GetGlobals(PyFrameObject *frame) +{ + return Py_NewRef(frame->f_globals); +} +#endif + + +// bpo-40421 added PyFrame_GetBuiltins() to Python 3.11.0a7 +#if PY_VERSION_HEX < 0x030B00A7 && !defined(PYPY_VERSION) +PYCAPI_COMPAT_STATIC_INLINE(PyObject*) +PyFrame_GetBuiltins(PyFrameObject *frame) +{ + return Py_NewRef(frame->f_builtins); +} +#endif + + +// bpo-40421 added PyFrame_GetLasti() to Python 3.11.0b1 +#if PY_VERSION_HEX < 0x030B00B1 && !defined(PYPY_VERSION) +PYCAPI_COMPAT_STATIC_INLINE(int) +PyFrame_GetLasti(PyFrameObject *frame) +{ +#if PY_VERSION_HEX >= 0x030A00A7 + // bpo-27129: Since Python 3.10.0a7, f_lasti is an instruction offset, + // not a bytes offset anymore. Python uses 16-bit "wordcode" (2 bytes) + // instructions. + if (frame->f_lasti < 0) { + return -1; + } + return frame->f_lasti * 2; +#else + return frame->f_lasti; +#endif +} +#endif + + +// gh-91248 added PyFrame_GetVar() to Python 3.12.0a2 +#if PY_VERSION_HEX < 0x030C00A2 && !defined(PYPY_VERSION) +PYCAPI_COMPAT_STATIC_INLINE(PyObject*) +PyFrame_GetVar(PyFrameObject *frame, PyObject *name) +{ + PyObject *locals, *value; + + locals = PyFrame_GetLocals(frame); + if (locals == NULL) { + return NULL; + } +#if PY_VERSION_HEX >= 0x03000000 + value = PyDict_GetItemWithError(locals, name); +#else + value = _PyDict_GetItemWithError(locals, name); +#endif + Py_DECREF(locals); + + if (value == NULL) { + if (PyErr_Occurred()) { + return NULL; + } +#if PY_VERSION_HEX >= 0x03000000 + PyErr_Format(PyExc_NameError, "variable %R does not exist", name); +#else + PyErr_SetString(PyExc_NameError, "variable does not exist"); +#endif + return NULL; + } + return Py_NewRef(value); +} +#endif + + +// gh-91248 added PyFrame_GetVarString() to Python 3.12.0a2 +#if PY_VERSION_HEX < 0x030C00A2 && !defined(PYPY_VERSION) +PYCAPI_COMPAT_STATIC_INLINE(PyObject*) +PyFrame_GetVarString(PyFrameObject *frame, const char *name) +{ + PyObject *name_obj, *value; +#if PY_VERSION_HEX >= 0x03000000 + name_obj = PyUnicode_FromString(name); +#else + name_obj = PyString_FromString(name); +#endif + if (name_obj == NULL) { + return NULL; + } + value = PyFrame_GetVar(frame, name_obj); + Py_DECREF(name_obj); + return value; +} +#endif + + +// bpo-39947 added PyThreadState_GetInterpreter() to Python 3.9.0a5 +#if PY_VERSION_HEX < 0x030900A5 || defined(PYPY_VERSION) +PYCAPI_COMPAT_STATIC_INLINE(PyInterpreterState *) +PyThreadState_GetInterpreter(PyThreadState *tstate) +{ + assert(tstate != _Py_NULL); + return tstate->interp; +} +#endif + + +// bpo-40429 added PyThreadState_GetFrame() to Python 3.9.0b1 +#if PY_VERSION_HEX < 0x030900B1 && !defined(PYPY_VERSION) +PYCAPI_COMPAT_STATIC_INLINE(PyFrameObject*) +PyThreadState_GetFrame(PyThreadState *tstate) +{ + assert(tstate != _Py_NULL); + return _Py_CAST(PyFrameObject *, Py_XNewRef(tstate->frame)); +} +#endif + +#if !defined(PYPY_VERSION) +PYCAPI_COMPAT_STATIC_INLINE(PyFrameObject*) +_PyThreadState_GetFrameBorrow(PyThreadState *tstate) +{ + PyFrameObject *frame = PyThreadState_GetFrame(tstate); + Py_XDECREF(frame); + return frame; +} +#endif + + +// bpo-39947 added PyInterpreterState_Get() to Python 3.9.0a5 +#if PY_VERSION_HEX < 0x030900A5 || defined(PYPY_VERSION) +PYCAPI_COMPAT_STATIC_INLINE(PyInterpreterState*) +PyInterpreterState_Get(void) +{ + PyThreadState *tstate; + PyInterpreterState *interp; + + tstate = PyThreadState_GET(); + if (tstate == _Py_NULL) { + Py_FatalError("GIL released (tstate is NULL)"); + } + interp = tstate->interp; + if (interp == _Py_NULL) { + Py_FatalError("no current interpreter"); + } + return interp; +} +#endif + + +// bpo-39947 added PyInterpreterState_Get() to Python 3.9.0a6 +#if 0x030700A1 <= PY_VERSION_HEX && PY_VERSION_HEX < 0x030900A6 && !defined(PYPY_VERSION) +PYCAPI_COMPAT_STATIC_INLINE(uint64_t) +PyThreadState_GetID(PyThreadState *tstate) +{ + assert(tstate != _Py_NULL); + return tstate->id; +} +#endif + +// bpo-43760 added PyThreadState_EnterTracing() to Python 3.11.0a2 +#if PY_VERSION_HEX < 0x030B00A2 && !defined(PYPY_VERSION) +PYCAPI_COMPAT_STATIC_INLINE(void) +PyThreadState_EnterTracing(PyThreadState *tstate) +{ + tstate->tracing++; +#if PY_VERSION_HEX >= 0x030A00A1 + tstate->cframe->use_tracing = 0; +#else + tstate->use_tracing = 0; +#endif +} +#endif + +// bpo-43760 added PyThreadState_LeaveTracing() to Python 3.11.0a2 +#if PY_VERSION_HEX < 0x030B00A2 && !defined(PYPY_VERSION) +PYCAPI_COMPAT_STATIC_INLINE(void) +PyThreadState_LeaveTracing(PyThreadState *tstate) +{ + int use_tracing = (tstate->c_tracefunc != _Py_NULL + || tstate->c_profilefunc != _Py_NULL); + tstate->tracing--; +#if PY_VERSION_HEX >= 0x030A00A1 + tstate->cframe->use_tracing = use_tracing; +#else + tstate->use_tracing = use_tracing; +#endif +} +#endif + + +// bpo-37194 added PyObject_CallNoArgs() to Python 3.9.0a1 +// PyObject_CallNoArgs() added to PyPy 3.9.16-v7.3.11 +#if !defined(PyObject_CallNoArgs) && PY_VERSION_HEX < 0x030900A1 +PYCAPI_COMPAT_STATIC_INLINE(PyObject*) +PyObject_CallNoArgs(PyObject *func) +{ + return PyObject_CallFunctionObjArgs(func, NULL); +} +#endif + + +// bpo-39245 made PyObject_CallOneArg() public (previously called +// _PyObject_CallOneArg) in Python 3.9.0a4 +// PyObject_CallOneArg() added to PyPy 3.9.16-v7.3.11 +#if !defined(PyObject_CallOneArg) && PY_VERSION_HEX < 0x030900A4 +PYCAPI_COMPAT_STATIC_INLINE(PyObject*) +PyObject_CallOneArg(PyObject *func, PyObject *arg) +{ + return PyObject_CallFunctionObjArgs(func, arg, NULL); +} +#endif + + +// bpo-1635741 added PyModule_AddObjectRef() to Python 3.10.0a3 +#if PY_VERSION_HEX < 0x030A00A3 +PYCAPI_COMPAT_STATIC_INLINE(int) +PyModule_AddObjectRef(PyObject *module, const char *name, PyObject *value) +{ + int res; + + if (!value && !PyErr_Occurred()) { + // PyModule_AddObject() raises TypeError in this case + PyErr_SetString(PyExc_SystemError, + "PyModule_AddObjectRef() must be called " + "with an exception raised if value is NULL"); + return -1; + } + + Py_XINCREF(value); + res = PyModule_AddObject(module, name, value); + if (res < 0) { + Py_XDECREF(value); + } + return res; +} +#endif + + +// bpo-40024 added PyModule_AddType() to Python 3.9.0a5 +#if PY_VERSION_HEX < 0x030900A5 +PYCAPI_COMPAT_STATIC_INLINE(int) +PyModule_AddType(PyObject *module, PyTypeObject *type) +{ + const char *name, *dot; + + if (PyType_Ready(type) < 0) { + return -1; + } + + // inline _PyType_Name() + name = type->tp_name; + assert(name != _Py_NULL); + dot = strrchr(name, '.'); + if (dot != _Py_NULL) { + name = dot + 1; + } + + return PyModule_AddObjectRef(module, name, _PyObject_CAST(type)); +} +#endif + + +// bpo-40241 added PyObject_GC_IsTracked() to Python 3.9.0a6. +// bpo-4688 added _PyObject_GC_IS_TRACKED() to Python 2.7.0a2. +#if PY_VERSION_HEX < 0x030900A6 && !defined(PYPY_VERSION) +PYCAPI_COMPAT_STATIC_INLINE(int) +PyObject_GC_IsTracked(PyObject* obj) +{ + return (PyObject_IS_GC(obj) && _PyObject_GC_IS_TRACKED(obj)); +} +#endif + +// bpo-40241 added PyObject_GC_IsFinalized() to Python 3.9.0a6. +// bpo-18112 added _PyGCHead_FINALIZED() to Python 3.4.0 final. +#if PY_VERSION_HEX < 0x030900A6 && PY_VERSION_HEX >= 0x030400F0 && !defined(PYPY_VERSION) +PYCAPI_COMPAT_STATIC_INLINE(int) +PyObject_GC_IsFinalized(PyObject *obj) +{ + PyGC_Head *gc = _Py_CAST(PyGC_Head*, obj) - 1; + return (PyObject_IS_GC(obj) && _PyGCHead_FINALIZED(gc)); +} +#endif + + +// bpo-39573 added Py_IS_TYPE() to Python 3.9.0a4 +#if PY_VERSION_HEX < 0x030900A4 && !defined(Py_IS_TYPE) +PYCAPI_COMPAT_STATIC_INLINE(int) +_Py_IS_TYPE(PyObject *ob, PyTypeObject *type) { + return Py_TYPE(ob) == type; +} +#define Py_IS_TYPE(ob, type) _Py_IS_TYPE(_PyObject_CAST(ob), type) +#endif + + +// bpo-46906 added PyFloat_Pack2() and PyFloat_Unpack2() to Python 3.11a7. +// bpo-11734 added _PyFloat_Pack2() and _PyFloat_Unpack2() to Python 3.6.0b1. +// Python 3.11a2 moved _PyFloat_Pack2() and _PyFloat_Unpack2() to the internal +// C API: Python 3.11a2-3.11a6 versions are not supported. +#if 0x030600B1 <= PY_VERSION_HEX && PY_VERSION_HEX <= 0x030B00A1 && !defined(PYPY_VERSION) +PYCAPI_COMPAT_STATIC_INLINE(int) +PyFloat_Pack2(double x, char *p, int le) +{ return _PyFloat_Pack2(x, (unsigned char*)p, le); } + +PYCAPI_COMPAT_STATIC_INLINE(double) +PyFloat_Unpack2(const char *p, int le) +{ return _PyFloat_Unpack2((const unsigned char *)p, le); } +#endif + + +// bpo-46906 added PyFloat_Pack4(), PyFloat_Pack8(), PyFloat_Unpack4() and +// PyFloat_Unpack8() to Python 3.11a7. +// Python 3.11a2 moved _PyFloat_Pack4(), _PyFloat_Pack8(), _PyFloat_Unpack4() +// and _PyFloat_Unpack8() to the internal C API: Python 3.11a2-3.11a6 versions +// are not supported. +#if PY_VERSION_HEX <= 0x030B00A1 && !defined(PYPY_VERSION) +PYCAPI_COMPAT_STATIC_INLINE(int) +PyFloat_Pack4(double x, char *p, int le) +{ return _PyFloat_Pack4(x, (unsigned char*)p, le); } + +PYCAPI_COMPAT_STATIC_INLINE(int) +PyFloat_Pack8(double x, char *p, int le) +{ return _PyFloat_Pack8(x, (unsigned char*)p, le); } + +PYCAPI_COMPAT_STATIC_INLINE(double) +PyFloat_Unpack4(const char *p, int le) +{ return _PyFloat_Unpack4((const unsigned char *)p, le); } + +PYCAPI_COMPAT_STATIC_INLINE(double) +PyFloat_Unpack8(const char *p, int le) +{ return _PyFloat_Unpack8((const unsigned char *)p, le); } +#endif + + +// gh-92154 added PyCode_GetCode() to Python 3.11.0b1 +#if PY_VERSION_HEX < 0x030B00B1 && !defined(PYPY_VERSION) +PYCAPI_COMPAT_STATIC_INLINE(PyObject*) +PyCode_GetCode(PyCodeObject *code) +{ + return Py_NewRef(code->co_code); +} +#endif + + +// gh-95008 added PyCode_GetVarnames() to Python 3.11.0rc1 +#if PY_VERSION_HEX < 0x030B00C1 && !defined(PYPY_VERSION) +PYCAPI_COMPAT_STATIC_INLINE(PyObject*) +PyCode_GetVarnames(PyCodeObject *code) +{ + return Py_NewRef(code->co_varnames); +} +#endif + +// gh-95008 added PyCode_GetFreevars() to Python 3.11.0rc1 +#if PY_VERSION_HEX < 0x030B00C1 && !defined(PYPY_VERSION) +PYCAPI_COMPAT_STATIC_INLINE(PyObject*) +PyCode_GetFreevars(PyCodeObject *code) +{ + return Py_NewRef(code->co_freevars); +} +#endif + +// gh-95008 added PyCode_GetCellvars() to Python 3.11.0rc1 +#if PY_VERSION_HEX < 0x030B00C1 && !defined(PYPY_VERSION) +PYCAPI_COMPAT_STATIC_INLINE(PyObject*) +PyCode_GetCellvars(PyCodeObject *code) +{ + return Py_NewRef(code->co_cellvars); +} +#endif + + +// Py_UNUSED() was added to Python 3.4.0b2. +#if PY_VERSION_HEX < 0x030400B2 && !defined(Py_UNUSED) +# if defined(__GNUC__) || defined(__clang__) +# define Py_UNUSED(name) _unused_ ## name __attribute__((unused)) +# else +# define Py_UNUSED(name) _unused_ ## name +# endif +#endif + + +// gh-105922 added PyImport_AddModuleRef() to Python 3.13.0a1 +#if PY_VERSION_HEX < 0x030D00A0 +PYCAPI_COMPAT_STATIC_INLINE(PyObject*) +PyImport_AddModuleRef(const char *name) +{ + return Py_XNewRef(PyImport_AddModule(name)); +} +#endif + + +// gh-105927 added PyWeakref_GetRef() to Python 3.13.0a1 +#if PY_VERSION_HEX < 0x030D0000 +PYCAPI_COMPAT_STATIC_INLINE(int) +PyWeakref_GetRef(PyObject *ref, PyObject **pobj) +{ + PyObject *obj; + if (ref != NULL && !PyWeakref_Check(ref)) { + *pobj = NULL; + PyErr_SetString(PyExc_TypeError, "expected a weakref"); + return -1; + } + obj = PyWeakref_GetObject(ref); + if (obj == NULL) { + // SystemError if ref is NULL + *pobj = NULL; + return -1; + } + if (obj == Py_None) { + *pobj = NULL; + return 0; + } + *pobj = Py_NewRef(obj); + return (*pobj != NULL); +} +#endif + + +// bpo-36974 added PY_VECTORCALL_ARGUMENTS_OFFSET to Python 3.8b1 +#ifndef PY_VECTORCALL_ARGUMENTS_OFFSET +# define PY_VECTORCALL_ARGUMENTS_OFFSET (_Py_CAST(size_t, 1) << (8 * sizeof(size_t) - 1)) +#endif + +// bpo-36974 added PyVectorcall_NARGS() to Python 3.8b1 +#if PY_VERSION_HEX < 0x030800B1 +static inline Py_ssize_t +PyVectorcall_NARGS(size_t n) +{ + return n & ~PY_VECTORCALL_ARGUMENTS_OFFSET; +} +#endif + + +// gh-105922 added PyObject_Vectorcall() to Python 3.9.0a4 +#if PY_VERSION_HEX < 0x030900A4 +PYCAPI_COMPAT_STATIC_INLINE(PyObject*) +PyObject_Vectorcall(PyObject *callable, PyObject *const *args, + size_t nargsf, PyObject *kwnames) +{ +#if PY_VERSION_HEX >= 0x030800B1 && !defined(PYPY_VERSION) + // bpo-36974 added _PyObject_Vectorcall() to Python 3.8.0b1 + return _PyObject_Vectorcall(callable, args, nargsf, kwnames); +#else + PyObject *posargs = NULL, *kwargs = NULL; + PyObject *res; + Py_ssize_t nposargs, nkwargs, i; + + if (nargsf != 0 && args == NULL) { + PyErr_BadInternalCall(); + goto error; + } + if (kwnames != NULL && !PyTuple_Check(kwnames)) { + PyErr_BadInternalCall(); + goto error; + } + + nposargs = (Py_ssize_t)PyVectorcall_NARGS(nargsf); + if (kwnames) { + nkwargs = PyTuple_GET_SIZE(kwnames); + } + else { + nkwargs = 0; + } + + posargs = PyTuple_New(nposargs); + if (posargs == NULL) { + goto error; + } + if (nposargs) { + for (i=0; i < nposargs; i++) { + PyTuple_SET_ITEM(posargs, i, Py_NewRef(*args)); + args++; + } + } + + if (nkwargs) { + kwargs = PyDict_New(); + if (kwargs == NULL) { + goto error; + } + + for (i = 0; i < nkwargs; i++) { + PyObject *key = PyTuple_GET_ITEM(kwnames, i); + PyObject *value = *args; + args++; + if (PyDict_SetItem(kwargs, key, value) < 0) { + goto error; + } + } + } + else { + kwargs = NULL; + } + + res = PyObject_Call(callable, posargs, kwargs); + Py_DECREF(posargs); + Py_XDECREF(kwargs); + return res; + +error: + Py_DECREF(posargs); + Py_XDECREF(kwargs); + return NULL; +#endif +} +#endif + + +// gh-106521 added PyObject_GetOptionalAttr() to Python 3.13.0a1 +#if PY_VERSION_HEX < 0x030D00A1 +PYCAPI_COMPAT_STATIC_INLINE(int) +PyObject_GetOptionalAttr(PyObject *obj, PyObject *name, PyObject **result) +{ + // bpo-32571 added _PyObject_LookupAttr() to Python 3.7.0b1 +#if PY_VERSION_HEX >= 0x030700B1 && !defined(PYPY_VERSION) + return _PyObject_LookupAttr(obj, name, result); +#else + *result = PyObject_GetAttr(obj, name); + if (*result != NULL) { + return 1; + } + if (!PyErr_Occurred()) { + return 0; + } + if (PyErr_ExceptionMatches(PyExc_AttributeError)) { + PyErr_Clear(); + return 0; + } + return -1; +#endif +} + +PYCAPI_COMPAT_STATIC_INLINE(int) +PyObject_GetOptionalAttrString(PyObject *obj, const char *name, PyObject **result) +{ + PyObject *name_obj; + int rc; +#if PY_VERSION_HEX >= 0x03000000 + name_obj = PyUnicode_FromString(name); +#else + name_obj = PyString_FromString(name); +#endif + if (name_obj == NULL) { + return -1; + } + rc = PyObject_GetOptionalAttr(obj, name_obj, result); + Py_DECREF(name_obj); + return rc; +} +#endif + + +// gh-106307 added PyObject_GetOptionalAttr() to Python 3.13.0a1 +#if PY_VERSION_HEX < 0x030D00A1 +PYCAPI_COMPAT_STATIC_INLINE(int) +PyMapping_GetOptionalItem(PyObject *obj, PyObject *key, PyObject **result) +{ + *result = PyObject_GetItem(obj, key); + if (*result) { + return 1; + } + if (!PyErr_ExceptionMatches(PyExc_KeyError)) { + return -1; + } + PyErr_Clear(); + return 0; +} + +PYCAPI_COMPAT_STATIC_INLINE(int) +PyMapping_GetOptionalItemString(PyObject *obj, const char *key, PyObject **result) +{ + PyObject *key_obj; + int rc; +#if PY_VERSION_HEX >= 0x03000000 + key_obj = PyUnicode_FromString(key); +#else + key_obj = PyString_FromString(key); +#endif + if (key_obj == NULL) { + return -1; + } + rc = PyMapping_GetOptionalItem(obj, key_obj, result); + Py_DECREF(key_obj); + return rc; +} +#endif + + +// gh-106004 added PyDict_GetItemRef() and PyDict_GetItemStringRef() +// to Python 3.13.0a1 +#if PY_VERSION_HEX < 0x030D00A1 +PYCAPI_COMPAT_STATIC_INLINE(int) +PyDict_GetItemRef(PyObject *mp, PyObject *key, PyObject **result) +{ +#if PY_VERSION_HEX >= 0x03000000 + PyObject *item = PyDict_GetItemWithError(mp, key); +#else + PyObject *item = _PyDict_GetItemWithError(mp, key); +#endif + if (item != NULL) { + *result = Py_NewRef(item); + return 1; // found + } + if (!PyErr_Occurred()) { + *result = NULL; + return 0; // not found + } + *result = NULL; + return -1; +} + +PYCAPI_COMPAT_STATIC_INLINE(int) +PyDict_GetItemStringRef(PyObject *mp, const char *key, PyObject **result) +{ + int res; +#if PY_VERSION_HEX >= 0x03000000 + PyObject *key_obj = PyUnicode_FromString(key); +#else + PyObject *key_obj = PyString_FromString(key); +#endif + if (key_obj == NULL) { + *result = NULL; + return -1; + } + res = PyDict_GetItemRef(mp, key_obj, result); + Py_DECREF(key_obj); + return res; +} +#endif + + +// gh-106307 added PyModule_Add() to Python 3.13.0a1 +#if PY_VERSION_HEX < 0x030D00A1 +PYCAPI_COMPAT_STATIC_INLINE(int) +PyModule_Add(PyObject *mod, const char *name, PyObject *value) +{ + int res = PyModule_AddObjectRef(mod, name, value); + Py_XDECREF(value); + return res; +} +#endif + + +#ifdef __cplusplus +} +#endif +#endif // PYTHONCAPI_COMPAT diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..3baa285d4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,92 @@ +[project] +name = "immutables" +description = "Immutable Collections" +authors = [{name = "MagicStack Inc", email = "hello@magic.io"}] +requires-python = '>=3.8.0' +readme = "README.rst" +license = {text = "Apache License, Version 2.0"} +dynamic = ["version"] +keywords = [ + "collections", + "immutable", + "hamt", +] +classifiers=[ + "License :: OSI Approved :: Apache Software License", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: Implementation :: CPython", + "Operating System :: POSIX", + "Operating System :: MacOS :: MacOS X", + "Operating System :: Microsoft :: Windows", + "Topic :: Software Development :: Libraries", +] +dependencies = [] + +[project.urls] +github = "https://github.com/MagicStack/immutables" + +[project.optional-dependencies] +# Minimal dependencies required to test immutables. +# pycodestyle is a dependency of flake8, but it must be frozen because +# their combination breaks too often +# (example breakage: https://gitlab.com/pycqa/flake8/issues/427) +test = [ + 'flake8~=5.0', + 'pycodestyle~=2.9', + 'mypy~=1.4', + 'pytest~=7.4', +] + +[build-system] +requires = ["setuptools>=42", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +zip-safe = false + +[tool.setuptools.packages.find] +include = ["immutables", "immutables.*"] + +[tool.setuptools.package-data] +immutables = ["py.typed", "*.pyi"] + +[tool.setuptools.exclude-package-data] +"*" = ["*.c", "*.h"] + +[tool.pytest.ini_options] +minversion = "6.0" +addopts = "--capture=no --assert=plain --strict-markers --tb=native --import-mode=importlib" +testpaths = "tests" +filterwarnings = "default" + +[tool.mypy] +files = "immutables" +incremental = true +strict = true + +[[tool.mypy.overrides]] +module = "immutables.map" +ignore_errors = true + +[[tool.mypy.overrides]] +module = "immutables._testutils" +ignore_errors = true + +[tool.cibuildwheel] +build-frontend = "build" +test-extras = "test" + +[tool.cibuildwheel.macos] +test-command = "python {project}/tests/__init__.py" + +[tool.cibuildwheel.windows] +test-command = "python {project}\\tests\\__init__.py" + +[tool.cibuildwheel.linux] +test-command = "python {project}/tests/__init__.py" diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 4603e347a..000000000 --- a/pytest.ini +++ /dev/null @@ -1,4 +0,0 @@ -[pytest] -addopts = --capture=no --assert=plain --strict --tb native -testpaths = tests -filterwarnings = default diff --git a/setup.py b/setup.py index e27586ce3..8cfc9c978 100644 --- a/setup.py +++ b/setup.py @@ -1,16 +1,22 @@ -import os.path +import os import platform import setuptools +system = platform.uname().system CFLAGS = ['-O2'] -if platform.uname().system != 'Windows': + +if system == 'AIX': + CFLAGS.extend(['-qlanglvl=stdc99', '-qchars']) +elif system == 'SUNOS': + CFLAGS.extend(['-xc99']) # -xchar=s is the default +elif system != 'Windows': CFLAGS.extend(['-std=c99', '-fsigned-char', '-Wall', '-Wsign-compare', '-Wconversion']) with open(os.path.join( - os.path.dirname(__file__), 'immutables', '__init__.py')) as f: + os.path.dirname(__file__), 'immutables', '_version.py')) as f: for line in f: if line.startswith('__version__ ='): _, _, version = line.partition('=') @@ -18,49 +24,30 @@ break else: raise RuntimeError( - 'unable to read the version from immutables/__init__.py') + 'unable to read the version from immutables/_version.py') if platform.python_implementation() == 'CPython': + if os.environ.get("DEBUG_IMMUTABLES") == '1': + define_macros = [] + undef_macros = ['NDEBUG'] + else: + define_macros = [('NDEBUG', '1')] + undef_macros = [] + ext_modules = [ setuptools.Extension( "immutables._map", ["immutables/_map.c"], - extra_compile_args=CFLAGS) + extra_compile_args=CFLAGS, + define_macros=define_macros, + undef_macros=undef_macros) ] else: ext_modules = [] -with open(os.path.join(os.path.dirname(__file__), 'README.rst')) as f: - readme = f.read() - - setuptools.setup( - name='immutables', version=VERSION, - description='Immutable Collections', - long_description=readme, - classifiers=[ - 'License :: OSI Approved :: Apache Software License', - 'Intended Audience :: Developers', - 'Programming Language :: Python :: 3 :: Only', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Operating System :: POSIX', - 'Operating System :: MacOS :: MacOS X', - 'Operating System :: Microsoft :: Windows', - ], - author='MagicStack Inc', - author_email='hello@magic.io', - url='https://github.com/MagicStack/immutables', - license='Apache License, Version 2.0', - packages=['immutables'], - package_data={"immutables": ["py.typed", "*.pyi"]}, - provides=['immutables'], - include_package_data=True, ext_modules=ext_modules, - test_suite='tests.suite', ) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..210f55b2b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,17 @@ +import sys +# We need the mypy pytest plugin to do the test collection for our +# typing tests. + +# mypy demands that its test-data be present for mypy.test.config to be +# imported, so thwart that check. mypy PR #10919 fixes this. +import unittest.mock +with unittest.mock.patch('os.path.isdir') as isdir: + isdir.return_value = True + import mypy.test.config # noqa + +pytest_plugins = [ + 'mypy.test.data', +] + +if sys.version_info < (3, 10): + collect_ignore = ["test_pattern_matching.py"] diff --git a/tests/test-data/check-immu.test b/tests/test-data/check-immu.test new file mode 100644 index 000000000..2ee32f17a --- /dev/null +++ b/tests/test-data/check-immu.test @@ -0,0 +1,73 @@ +[case testMypyImmu] +# cmd: mypy test.py +[file test.py] +from immutables import Map +from typing import Dict, Union, Any, cast + +def init() -> None: + def thing(m: Map[str, Union[str, int]]) -> None: + ... + + thing(Map(foo=1)) + thing(Map(foo='bar', baz=1)) + thing(Map([('foo', 'bar'), ('bar', 1)])) + thing(Map(Map(foo=1), bar='foo')) + m = Map({1: 2}) + thing(m) # E: Argument 1 to "thing" has incompatible type "Map[int, int]"; expected "Map[str, Union[str, int]]" + +def assignments() -> None: + m_int__str = Map[int, str]() + m_str__str = Map[str, str]() + m_int_str__str = Map[Union[int, str], str]() + m_str__int_str = Map[str, Union[int, str]]() + + m_int__str = m_str__str # E: Incompatible types in assignment (expression has type "Map[str, str]", variable has type "Map[int, str]") + m_int__str = m_int_str__str # E: Incompatible types in assignment (expression has type "Map[Union[int, str], str]", variable has type "Map[int, str]") + m_int__str = m_str__int_str # E: Incompatible types in assignment (expression has type "Map[str, Union[int, str]]", variable has type "Map[int, str]") + + m_str__str = m_int__str # E: Incompatible types in assignment (expression has type "Map[int, str]", variable has type "Map[str, str]") + m_str__str = m_int_str__str # E: Incompatible types in assignment (expression has type "Map[Union[int, str], str]", variable has type "Map[str, str]") + m_str__str = m_str__int_str # E: Incompatible types in assignment (expression has type "Map[str, Union[int, str]]", variable has type "Map[str, str]") + + m_int_str__str = m_int__str # E: Incompatible types in assignment (expression has type "Map[int, str]", variable has type "Map[Union[int, str], str]") + m_int_str__str = m_str__str # E: Incompatible types in assignment (expression has type "Map[str, str]", variable has type "Map[Union[int, str], str]") + m_int_str__str = m_str__int_str # E: Incompatible types in assignment (expression has type "Map[str, Union[int, str]]", variable has type "Map[Union[int, str], str]") + + m_str__int_str = m_int__str # E: Incompatible types in assignment (expression has type "Map[int, str]", variable has type "Map[str, Union[int, str]]") + m_str__int_str = m_int_str__str # E: Incompatible types in assignment (expression has type "Map[Union[int, str], str]", variable has type "Map[str, Union[int, str]]") + m_str__int_str = m_str__str + +def update() -> None: + m_int__str: Map[int, str] = Map() + m_str__str: Map[str, str] = Map() + m_int_str__str: Map[Union[int, str], str] = Map() + m_str__int_str: Map[str, Union[int, str]] = Map() + + m_int__str.update({1: '2'}) + m_int__str.update({1: '2'}, three='4') # E: Unexpected keyword argument "three" for "update" of "Map" + m_int__str.update({1: 2}) # E: Argument 1 to "update" of "Map" has incompatible type "Dict[int, int]"; expected "Union[IterableItems[int, str], Iterable[Tuple[int, str]]]" + + m_str__str.update({'1': '2'}) + m_str__str.update({'1': '2'}, three='4') + m_str__str.update({'1': 2}) # E: Argument 1 to "update" of "Map" has incompatible type "Dict[str, int]"; expected "Union[IterableItems[str, str], Iterable[Tuple[str, str]]]" + + m_int_str__str.update(cast(Dict[Union[int, str], str], {1: '2', '3': '4'})) + m_int_str__str.update({1: '2'}, three='4') + m_int_str__str.update({'1': 2}) # E: Argument 1 to "update" of "Map" has incompatible type "Dict[str, int]"; expected "Union[IterableItems[Union[int, str], str], Iterable[Tuple[Union[int, str], str]]]" + + m_str__int_str.update({'1': 2, '2': 3}) + m_str__int_str.update({'1': 2, '2': 3}, four='5') + m_str__int_str.update({1: 2}) # E: Argument 1 to "update" of "Map" has incompatible type "Dict[int, int]"; expected "Union[IterableItems[str, Union[int, str]], Iterable[Tuple[str, Union[int, str]]]]" + +def mutate() -> None: + m = Map[str, str]() + + with m.mutate() as mm: + mm[0] = '1' # E: Invalid index type "int" for "MapMutation[str, str]"; expected type "str" + mm['1'] = 0 # E: Incompatible types in assignment (expression has type "int", target has type "str") + mm['1'] = '2' + del mm['1'] + mm.set('3', '4') + m2 = mm.finish() + + reveal_type(m2) # N: Revealed type is "immutables._map.Map[builtins.str, builtins.str]" diff --git a/tests/test_issue24.py b/tests/test_issue24.py new file mode 100644 index 000000000..7d51e34f9 --- /dev/null +++ b/tests/test_issue24.py @@ -0,0 +1,156 @@ +import unittest + +from immutables.map import Map as PyMap, map_bitcount + + +class CollisionKey: + def __hash__(self): + return 0 + + +class Issue24Base: + Map = None + + def test_issue24(self): + keys = range(27) + new_entries = dict.fromkeys(keys, True) + m = self.Map(new_entries) + self.assertTrue(17 in m) + with m.mutate() as mm: + for i in keys: + del mm[i] + self.assertEqual(len(mm), 0) + + def dump_check_node_kind(self, header, kind): + header = header.strip() + self.assertTrue(header.strip().startswith(kind)) + + def dump_check_node_size(self, header, size): + node_size = header.split('size=', 1)[1] + node_size = int(node_size.split(maxsplit=1)[0]) + self.assertEqual(node_size, size) + + def dump_check_bitmap_count(self, header, count): + header = header.split('bitmap=')[1] + bitmap = int(header.split(maxsplit=1)[0], 0) + self.assertEqual(map_bitcount(bitmap), count) + + def dump_check_bitmap_node_count(self, header, count): + self.dump_check_node_kind(header, 'Bitmap') + self.dump_check_node_size(header, count * 2) + self.dump_check_bitmap_count(header, count) + + def dump_check_collision_node_count(self, header, count): + self.dump_check_node_kind(header, 'Collision') + self.dump_check_node_size(header, 2 * count) + + def test_bitmap_node_update_in_place_count(self): + keys = range(7) + new_entries = dict.fromkeys(keys, True) + m = self.Map(new_entries) + d = m.__dump__().splitlines() + self.assertTrue(d) + if d[0].startswith('HAMT'): + header = d[1] # skip _map.Map.__dump__() header + else: + header = d[0] + self.dump_check_bitmap_node_count(header, 7) + + def test_bitmap_node_delete_in_place_count(self): + keys = range(7) + new_entries = dict.fromkeys(keys, True) + m = self.Map(new_entries) + with m.mutate() as mm: + del mm[0], mm[2], mm[3] + m2 = mm.finish() + d = m2.__dump__().splitlines() + self.assertTrue(d) + if d[0].startswith('HAMT'): + header = d[1] # skip _map.Map.__dump__() header + else: + header = d[0] + self.dump_check_bitmap_node_count(header, 4) + + def test_collision_node_update_in_place_count(self): + keys = (CollisionKey() for i in range(7)) + new_entries = dict.fromkeys(keys, True) + m = self.Map(new_entries) + d = m.__dump__().splitlines() + self.assertTrue(len(d) > 3) + # get node headers + if d[0].startswith('HAMT'): + h1, h2 = d[1], d[3] # skip _map.Map.__dump__() header + else: + h1, h2 = d[0], d[2] + self.dump_check_node_kind(h1, 'Bitmap') + self.dump_check_collision_node_count(h2, 7) + + def test_collision_node_delete_in_place_count(self): + keys = [CollisionKey() for i in range(7)] + new_entries = dict.fromkeys(keys, True) + m = self.Map(new_entries) + with m.mutate() as mm: + del mm[keys[0]], mm[keys[2]], mm[keys[3]] + m2 = mm.finish() + d = m2.__dump__().splitlines() + self.assertTrue(len(d) > 3) + # get node headers + if d[0].startswith('HAMT'): + h1, h2 = d[1], d[3] # skip _map.Map.__dump__() header + else: + h1, h2 = d[0], d[2] + self.dump_check_node_kind(h1, 'Bitmap') + self.dump_check_collision_node_count(h2, 4) + + +try: + from immutables._map import Map as CMap +except ImportError: + CMap = None + + +class Issue24PyTest(Issue24Base, unittest.TestCase): + Map = PyMap + + +@unittest.skipIf(CMap is None, 'C Map is not available') +class Issue24CTest(Issue24Base, unittest.TestCase): + Map = CMap + + def hamt_dump_check_first_return_second(self, m): + d = m.__dump__().splitlines() + self.assertTrue(len(d) > 2) + self.assertTrue(d[0].startswith('HAMT')) + return d[1] + + def test_array_node_update_in_place_count(self): + keys = range(27) + new_entries = dict.fromkeys(keys, True) + m = self.Map(new_entries) + header = self.hamt_dump_check_first_return_second(m) + self.dump_check_node_kind(header, 'Array') + for i in range(2, 18): + m = m.delete(i) + header = self.hamt_dump_check_first_return_second(m) + self.dump_check_bitmap_node_count(header, 11) + + def test_array_node_delete_in_place_count(self): + keys = range(27) + new_entries = dict.fromkeys(keys, True) + m = self.Map(new_entries) + header = self.hamt_dump_check_first_return_second(m) + self.dump_check_node_kind(header, 'Array') + with m.mutate() as mm: + for i in range(5): + del mm[i] + m2 = mm.finish() + header = self.hamt_dump_check_first_return_second(m2) + self.dump_check_node_kind(header, 'Array') + for i in range(6, 17): + m2 = m2.delete(i) + header = self.hamt_dump_check_first_return_second(m2) + self.dump_check_bitmap_node_count(header, 11) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_map.py b/tests/test_map.py index 0b464cf27..6cecfe903 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -7,88 +7,12 @@ import weakref from immutables.map import Map as PyMap - - -class HashKey: - _crasher = None - - def __init__(self, hash, name, *, error_on_eq_to=None): - assert hash != -1 - self.name = name - self.hash = hash - self.error_on_eq_to = error_on_eq_to - - def __repr__(self): - if self._crasher is not None and self._crasher.error_on_repr: - raise ReprError - return ''.format(self.name, self.hash) - - def __hash__(self): - if self._crasher is not None and self._crasher.error_on_hash: - raise HashingError - - return self.hash - - def __eq__(self, other): - if not isinstance(other, HashKey): - return NotImplemented - - if self._crasher is not None and self._crasher.error_on_eq: - raise EqError - - if self.error_on_eq_to is not None and self.error_on_eq_to is other: - raise ValueError('cannot compare {!r} to {!r}'.format(self, other)) - if other.error_on_eq_to is not None and other.error_on_eq_to is self: - raise ValueError('cannot compare {!r} to {!r}'.format(other, self)) - - return (self.name, self.hash) == (other.name, other.hash) - - -class KeyStr(str): - - def __hash__(self): - if HashKey._crasher is not None and HashKey._crasher.error_on_hash: - raise HashingError - return super().__hash__() - - def __eq__(self, other): - if HashKey._crasher is not None and HashKey._crasher.error_on_eq: - raise EqError - return super().__eq__(other) - - def __repr__(self, other): - if HashKey._crasher is not None and HashKey._crasher.error_on_repr: - raise ReprError - return super().__eq__(other) - - -class HashKeyCrasher: - - def __init__(self, *, error_on_hash=False, error_on_eq=False, - error_on_repr=False): - self.error_on_hash = error_on_hash - self.error_on_eq = error_on_eq - self.error_on_repr = error_on_repr - - def __enter__(self): - if HashKey._crasher is not None: - raise RuntimeError('cannot nest crashers') - HashKey._crasher = self - - def __exit__(self, *exc): - HashKey._crasher = None - - -class HashingError(Exception): - pass - - -class EqError(Exception): - pass - - -class ReprError(Exception): - pass +from immutables._testutils import EqError +from immutables._testutils import HashKey +from immutables._testutils import HashKeyCrasher +from immutables._testutils import HashingError +from immutables._testutils import KeyStr +from immutables._testutils import ReprError class BaseMapTest: @@ -240,7 +164,7 @@ def test_map_collision_2(self): # : 'e' # : 'b' - def test_map_stress(self): + def test_map_stress_01(self): COLLECTION_SIZE = 7000 TEST_ITERS_EVERY = 647 CRASH_HASH_EVERY = 97 @@ -330,6 +254,112 @@ def test_map_stress(self): self.assertEqual(len(h), 0) self.assertEqual(list(h.items()), []) + def test_map_collision_3(self): + # Test that iteration works with the deepest tree possible. + + C = HashKey(0b10000000_00000000_00000000_00000000, 'C') + D = HashKey(0b10000000_00000000_00000000_00000000, 'D') + + E = HashKey(0b00000000_00000000_00000000_00000000, 'E') + + h = self.Map() + h = h.set(C, 'C') + h = h.set(D, 'D') + h = h.set(E, 'E') + + # BitmapNode(size=2 count=1 bitmap=0b1): + # NULL: + # BitmapNode(size=2 count=1 bitmap=0b1): + # NULL: + # BitmapNode(size=2 count=1 bitmap=0b1): + # NULL: + # BitmapNode(size=2 count=1 bitmap=0b1): + # NULL: + # BitmapNode(size=2 count=1 bitmap=0b1): + # NULL: + # BitmapNode(size=2 count=1 bitmap=0b1): + # NULL: + # BitmapNode(size=4 count=2 bitmap=0b101): + # : 'E' + # NULL: + # CollisionNode(size=4 id=0x107a24520): + # : 'C' + # : 'D' + + self.assertEqual({k.name for k in h.keys()}, {'C', 'D', 'E'}) + + def test_map_stress_02(self): + COLLECTION_SIZE = 20000 + TEST_ITERS_EVERY = 647 + CRASH_HASH_EVERY = 97 + DELETE_EVERY = 3 + CRASH_EQ_EVERY = 11 + + h = self.Map() + d = dict() + + for i in range(COLLECTION_SIZE // 2): + key = KeyStr(i) + + if not (i % CRASH_HASH_EVERY): + with HashKeyCrasher(error_on_hash=True): + with self.assertRaises(HashingError): + h.set(key, i) + + h = h.set(key, i) + + if not (i % CRASH_EQ_EVERY): + with HashKeyCrasher(error_on_eq=True): + with self.assertRaises(EqError): + h.get(KeyStr(i)) # really trigger __eq__ + + d[key] = i + self.assertEqual(len(d), len(h)) + + if not (i % TEST_ITERS_EVERY): + self.assertEqual(set(h.items()), set(d.items())) + self.assertEqual(len(h.items()), len(d.items())) + + with h.mutate() as m: + for i in range(COLLECTION_SIZE // 2, COLLECTION_SIZE): + key = KeyStr(i) + + if not (i % CRASH_HASH_EVERY): + with HashKeyCrasher(error_on_hash=True): + with self.assertRaises(HashingError): + m[key] = i + + m[key] = i + + if not (i % CRASH_EQ_EVERY): + with HashKeyCrasher(error_on_eq=True): + with self.assertRaises(EqError): + m[KeyStr(i)] + + d[key] = i + self.assertEqual(len(d), len(m)) + + if not (i % DELETE_EVERY): + del m[key] + del d[key] + + self.assertEqual(len(d), len(m)) + + h = m.finish() + + self.assertEqual(len(h), len(d)) + self.assertEqual(set(h.items()), set(d.items())) + + with h.mutate() as m: + for key in list(d): + del d[key] + del m[key] + self.assertEqual(len(m), len(d)) + h = m.finish() + + self.assertEqual(len(h), len(d)) + self.assertEqual(set(h.items()), set(d.items())) + def test_map_delete_1(self): A = HashKey(100, 'A') B = HashKey(101, 'B') @@ -852,11 +882,10 @@ def test_map_getitem_1(self): def test_repr_1(self): h = self.Map() - self.assertTrue(repr(h).startswith('= (3, 9): + with_args = self.Map[int, str] + self.assertIs(with_args.__origin__, self.Map) + self.assertEqual(with_args.__args__, (int, str)) + else: + self.assertIs(self.Map[int, str], self.Map) + + def test_kwarg_named_col(self): + self.assertEqual(dict(self.Map(col=0)), {"col": 0}) + self.assertEqual(dict(self.Map(a=0, col=1)), {"a": 0, "col": 1}) + self.assertEqual(dict(self.Map({"a": 0}, col=1)), {"a": 0, "col": 1}) + + def test_map_keys_contains(self): + m = self.Map(foo="bar") + self.assertTrue("foo" in m.keys()) class PyMapTest(BaseMapTest, unittest.TestCase): diff --git a/tests/test_mypy.py b/tests/test_mypy.py new file mode 100644 index 000000000..24d3a6bb9 --- /dev/null +++ b/tests/test_mypy.py @@ -0,0 +1,39 @@ +import os +import sys + +try: + import mypy.test.testcmdline + from mypy.test.helpers import normalize_error_messages +except (ImportError, AssertionError): + if os.environ.get('IMMU_SKIP_MYPY_TESTS'): + pass + else: + raise +else: + # I'm upset. There's no other way to deal with the little 'defined here' + # notes that mypy emits when passing an unexpected keyword argument + # and at no other time. + def renormalize_error_messages(messages): + messages = [x for x in messages if not x.endswith(' defined here')] + return normalize_error_messages(messages) + + mypy.test.testcmdline.normalize_error_messages = renormalize_error_messages + + this_file_dir = os.path.dirname(os.path.realpath(__file__)) + test_data_prefix = os.path.join(this_file_dir, 'test-data') + parent_dir = os.path.dirname(this_file_dir) + + mypy_path = os.environ.get("MYPYPATH") + if mypy_path: + mypy_path = parent_dir + os.pathsep + mypy_path + else: + mypy_path = parent_dir + + class ImmuMypyTest(mypy.test.testcmdline.PythonCmdlineSuite): + data_prefix = test_data_prefix + files = ['check-immu.test'] + + def run_case(self, testcase): + if sys.version_info >= (3, 7): + os.environ["MYPYPATH"] = mypy_path + super().run_case(testcase) diff --git a/tests/test_none_keys.py b/tests/test_none_keys.py new file mode 100644 index 000000000..84eca3199 --- /dev/null +++ b/tests/test_none_keys.py @@ -0,0 +1,515 @@ +import ctypes +import unittest + +from immutables.map import map_hash, map_mask, Map as PyMap +from immutables._testutils import HashKey + + +none_hash = map_hash(None) +assert none_hash != 1 +assert none_hash.bit_length() <= 32 + +none_hash_u = ctypes.c_size_t(none_hash).value +not_collision = 0xffffffff & (~none_hash_u) + +mask = 0x7ffffffff +none_collisions = [none_hash_u & (mask >> shift) + for shift in reversed(range(0, 32, 5))] +assert len(none_collisions) == 7 +none_collisions = [ + ctypes.c_ssize_t(h | (not_collision & (mask << shift))).value + for shift, h in zip(range(5, 37, 5), none_collisions) +] + + +class NoneCollision(HashKey): + def __init__(self, name, level): + if name is None: + raise ValueError("Can't have a NoneCollision with a None value") + super().__init__(none_collisions[level], name) + + def __eq__(self, other): + if other is None: + return False + return super().__eq__(other) + + __hash__ = HashKey.__hash__ + + +class BaseNoneTest: + Map = None + + def test_none_collisions(self): + collisions = [NoneCollision('a', level) for level in range(7)] + indices = [map_mask(none_hash, shift) for shift in range(0, 32, 5)] + + for i, c in enumerate(collisions[:-1], 1): + self.assertNotEqual(c, None) + c_hash = map_hash(c) + self.assertNotEqual(c_hash, none_hash) + for j, idx in enumerate(indices[:i]): + self.assertEqual(map_mask(c_hash, j*5), idx) + for j, idx in enumerate(indices[i:], i): + self.assertNotEqual(map_mask(c_hash, j*5), idx) + + c = collisions[-1] + self.assertNotEqual(c, None) + c_hash = map_hash(c) + self.assertEqual(c_hash, none_hash) + for i, idx in enumerate(indices): + self.assertEqual(map_mask(c_hash, i*5), idx) + + def test_none_as_key(self): + m = self.Map({None: 1}) + + self.assertEqual(len(m), 1) + self.assertTrue(None in m) + self.assertEqual(m[None], 1) + self.assertEqual(repr(m), 'immutables.Map({None: 1})') + + for level in range(7): + key = NoneCollision('a', level) + self.assertFalse(key in m) + with self.assertRaises(KeyError): + m.delete(key) + + m = m.delete(None) + self.assertEqual(len(m), 0) + self.assertFalse(None in m) + self.assertEqual(repr(m), 'immutables.Map({})') + + self.assertEqual(m, self.Map()) + + with self.assertRaises(KeyError): + m.delete(None) + + def test_none_set(self): + m = self.Map().set(None, 2) + + self.assertEqual(len(m), 1) + self.assertTrue(None in m) + self.assertEqual(m[None], 2) + + m = m.set(None, 1) + + self.assertEqual(len(m), 1) + self.assertTrue(None in m) + self.assertEqual(m[None], 1) + + m = m.delete(None) + + self.assertEqual(len(m), 0) + self.assertEqual(m, self.Map()) + self.assertFalse(None in m) + + with self.assertRaises(KeyError): + m.delete(None) + + def test_none_collision_1(self): + for level in range(7): + key = NoneCollision('a', level) + m = self.Map({None: 1, key: 2}) + + self.assertEqual(len(m), 2) + self.assertTrue(None in m) + self.assertEqual(m[None], 1) + self.assertTrue(key in m) + self.assertEqual(m[key], 2) + + m2 = m.delete(None) + self.assertEqual(len(m2), 1) + self.assertTrue(key in m2) + self.assertEqual(m2[key], 2) + self.assertFalse(None in m2) + with self.assertRaises(KeyError): + m2.delete(None) + + m3 = m2.delete(key) + self.assertEqual(len(m3), 0) + self.assertFalse(None in m3) + self.assertFalse(key in m3) + self.assertEqual(m3, self.Map()) + self.assertEqual(repr(m3), 'immutables.Map({})') + with self.assertRaises(KeyError): + m3.delete(None) + with self.assertRaises(KeyError): + m3.delete(key) + + m2 = m.delete(key) + self.assertEqual(len(m2), 1) + self.assertTrue(None in m2) + self.assertEqual(m2[None], 1) + self.assertFalse(key in m2) + with self.assertRaises(KeyError): + m2.delete(key) + + m4 = m2.delete(None) + self.assertEqual(len(m4), 0) + self.assertFalse(None in m4) + self.assertFalse(key in m4) + self.assertEqual(m4, self.Map()) + self.assertEqual(repr(m4), 'immutables.Map({})') + with self.assertRaises(KeyError): + m4.delete(None) + with self.assertRaises(KeyError): + m4.delete(key) + + self.assertEqual(m3, m4) + + def test_none_collision_2(self): + key = HashKey(not_collision, 'a') + m = self.Map().set(None, 1).set(key, 2) + + self.assertEqual(len(m), 2) + self.assertTrue(key in m) + self.assertTrue(None in m) + self.assertEqual(m[key], 2) + self.assertEqual + + m = m.set(None, 0) + self.assertEqual(len(m), 2) + self.assertTrue(key in m) + self.assertTrue(None in m) + + for level in range(7): + key2 = NoneCollision('b', level) + self.assertFalse(key2 in m) + m2 = m.set(key2, 1) + + self.assertEqual(len(m2), 3) + self.assertTrue(key in m2) + self.assertTrue(None in m2) + self.assertTrue(key2 in m2) + self.assertEqual(m2[key], 2) + self.assertEqual(m2[None], 0) + self.assertEqual(m2[key2], 1) + + m2 = m2.set(None, 1) + self.assertEqual(len(m2), 3) + self.assertTrue(key in m2) + self.assertTrue(None in m2) + self.assertTrue(key2 in m2) + self.assertEqual(m2[key], 2) + self.assertEqual(m2[None], 1) + self.assertEqual(m2[key2], 1) + + m2 = m2.set(None, 2) + self.assertEqual(len(m2), 3) + self.assertTrue(key in m2) + self.assertTrue(None in m2) + self.assertTrue(key2 in m2) + self.assertEqual(m2[key], 2) + self.assertEqual(m2[None], 2) + self.assertEqual(m2[key2], 1) + + m3 = m2.delete(key) + self.assertEqual(len(m3), 2) + self.assertTrue(None in m3) + self.assertTrue(key2 in m3) + self.assertFalse(key in m3) + self.assertEqual(m3[None], 2) + self.assertEqual(m3[key2], 1) + with self.assertRaises(KeyError): + m3.delete(key) + + m3 = m2.delete(key2) + self.assertEqual(len(m3), 2) + self.assertTrue(None in m3) + self.assertTrue(key in m3) + self.assertFalse(key2 in m3) + self.assertEqual(m3[None], 2) + self.assertEqual(m3[key], 2) + with self.assertRaises(KeyError): + m3.delete(key2) + + m3 = m2.delete(None) + self.assertEqual(len(m3), 2) + self.assertTrue(key in m3) + self.assertTrue(key2 in m3) + self.assertFalse(None in m3) + self.assertEqual(m3[key], 2) + self.assertEqual(m3[key2], 1) + with self.assertRaises(KeyError): + m3.delete(None) + + m2 = m.delete(None) + self.assertEqual(len(m2), 1) + self.assertFalse(None in m2) + self.assertTrue(key in m2) + self.assertEqual(m2[key], 2) + with self.assertRaises(KeyError): + m2.delete(None) + + m2 = m.delete(key) + self.assertEqual(len(m2), 1) + self.assertFalse(key in m2) + self.assertTrue(None in m2) + self.assertEqual(m2[None], 0) + with self.assertRaises(KeyError): + m2.delete(key) + + def test_none_collision_3(self): + for level in range(7): + key = NoneCollision('a', level) + m = self.Map({key: 2}) + + self.assertEqual(len(m), 1) + self.assertFalse(None in m) + self.assertTrue(key in m) + self.assertEqual(m[key], 2) + with self.assertRaises(KeyError): + m.delete(None) + + m = m.set(None, 1) + self.assertEqual(len(m), 2) + self.assertTrue(key in m) + self.assertEqual(m[key], 2) + self.assertTrue(None in m) + self.assertEqual(m[None], 1) + + m = m.set(None, 0) + self.assertEqual(len(m), 2) + self.assertTrue(key in m) + self.assertEqual(m[key], 2) + self.assertTrue(None in m) + self.assertEqual(m[None], 0) + + m2 = m.delete(key) + self.assertEqual(len(m2), 1) + self.assertTrue(None in m2) + self.assertEqual(m2[None], 0) + self.assertFalse(key in m2) + with self.assertRaises(KeyError): + m2.delete(key) + + m2 = m.delete(None) + self.assertEqual(len(m2), 1) + self.assertTrue(key in m2) + self.assertEqual(m2[key], 2) + self.assertFalse(None in m2) + with self.assertRaises(KeyError): + m2.delete(None) + + def test_collision_4(self): + key2 = NoneCollision('a', 2) + key4 = NoneCollision('b', 4) + m = self.Map({key2: 2, key4: 4}) + + self.assertEqual(len(m), 2) + self.assertTrue(key2 in m) + self.assertTrue(key4 in m) + self.assertEqual(m[key2], 2) + self.assertEqual(m[key4], 4) + self.assertFalse(None in m) + + m2 = m.set(None, 9) + + self.assertEqual(len(m2), 3) + self.assertTrue(key2 in m2) + self.assertTrue(key4 in m2) + self.assertTrue(None in m2) + self.assertEqual(m2[key2], 2) + self.assertEqual(m2[key4], 4) + self.assertEqual(m2[None], 9) + + m3 = m2.set(None, 0) + self.assertEqual(len(m3), 3) + self.assertTrue(key2 in m3) + self.assertTrue(key4 in m3) + self.assertTrue(None in m3) + self.assertEqual(m3[key2], 2) + self.assertEqual(m3[key4], 4) + self.assertEqual(m3[None], 0) + + m3 = m2.set(key2, 0) + self.assertEqual(len(m3), 3) + self.assertTrue(key2 in m3) + self.assertTrue(key4 in m3) + self.assertTrue(None in m3) + self.assertEqual(m3[key2], 0) + self.assertEqual(m3[key4], 4) + self.assertEqual(m3[None], 9) + + m3 = m2.set(key4, 0) + self.assertEqual(len(m3), 3) + self.assertTrue(key2 in m3) + self.assertTrue(key4 in m3) + self.assertTrue(None in m3) + self.assertEqual(m3[key2], 2) + self.assertEqual(m3[key4], 0) + self.assertEqual(m3[None], 9) + + m3 = m2.delete(None) + self.assertEqual(m3, m) + self.assertEqual(len(m3), 2) + self.assertTrue(key2 in m3) + self.assertTrue(key4 in m3) + self.assertEqual(m3[key2], 2) + self.assertEqual(m3[key4], 4) + self.assertFalse(None in m3) + with self.assertRaises(KeyError): + m3.delete(None) + + m3 = m2.delete(key2) + self.assertEqual(len(m3), 2) + self.assertTrue(None in m3) + self.assertTrue(key4 in m3) + self.assertEqual(m3[None], 9) + self.assertEqual(m3[key4], 4) + self.assertFalse(key2 in m3) + with self.assertRaises(KeyError): + m3.delete(key2) + + m3 = m2.delete(key4) + self.assertEqual(len(m3), 2) + self.assertTrue(None in m3) + self.assertTrue(key2 in m3) + self.assertEqual(m3[None], 9) + self.assertEqual(m3[key2], 2) + self.assertFalse(key4 in m3) + with self.assertRaises(KeyError): + m3.delete(key4) + + def test_none_mutation(self): + key2 = NoneCollision('a', 2) + key4 = NoneCollision('b', 4) + key = NoneCollision('c', -1) + m = self.Map({key: -1, key2: 2, key4: 4, None: 9}) + + with m.mutate() as mm: + self.assertEqual(len(mm), 4) + self.assertTrue(key in mm) + self.assertTrue(key2 in mm) + self.assertTrue(key4 in mm) + self.assertTrue(None in mm) + self.assertEqual(mm[key2], 2) + self.assertEqual(mm[key4], 4) + self.assertEqual(mm[key], -1) + self.assertEqual(mm[None], 9) + + for k in m: + mm[k] = -mm[k] + + self.assertEqual(len(mm), 4) + self.assertTrue(key in mm) + self.assertTrue(key2 in mm) + self.assertTrue(key4 in mm) + self.assertTrue(None in mm) + self.assertEqual(mm[key2], -2) + self.assertEqual(mm[key4], -4) + self.assertEqual(mm[key], 1) + self.assertEqual(mm[None], -9) + + for k in m: + del mm[k] + self.assertEqual(len(mm), 3) + self.assertFalse(k in mm) + for n in m: + if n != k: + self.assertTrue(n in mm) + self.assertEqual(mm[n], -m[n]) + with self.assertRaises(KeyError): + del mm[k] + mm[k] = -m[k] + self.assertEqual(len(mm), 4) + self.assertTrue(k in mm) + self.assertEqual(mm[k], -m[k]) + + for k in m: + mm[k] = -mm[k] + + self.assertEqual(len(mm), 4) + self.assertTrue(key in mm) + self.assertTrue(key2 in mm) + self.assertTrue(key4 in mm) + self.assertTrue(None in mm) + self.assertEqual(mm[key2], 2) + self.assertEqual(mm[key4], 4) + self.assertEqual(mm[key], -1) + self.assertEqual(mm[None], 9) + + for k in m: + mm[k] = -mm[k] + + self.assertEqual(len(mm), 4) + self.assertTrue(key in mm) + self.assertTrue(key2 in mm) + self.assertTrue(key4 in mm) + self.assertTrue(None in mm) + self.assertEqual(mm[key2], -2) + self.assertEqual(mm[key4], -4) + self.assertEqual(mm[key], 1) + self.assertEqual(mm[None], -9) + + m2 = mm.finish() + + self.assertEqual(set(m), set(m2)) + self.assertEqual(len(m2), 4) + self.assertTrue(key in m2) + self.assertTrue(key2 in m2) + self.assertTrue(key4 in m2) + self.assertTrue(None in m2) + self.assertEqual(m2[key2], -2) + self.assertEqual(m2[key4], -4) + self.assertEqual(m2[key], 1) + self.assertEqual(m2[None], -9) + + for k, v in m.items(): + self.assertTrue(k in m2) + self.assertEqual(m2[k], -v) + + def test_iterators(self): + key2 = NoneCollision('a', 2) + key4 = NoneCollision('b', 4) + key = NoneCollision('c', -1) + m = self.Map({key: -1, key2: 2, key4: 4, None: 9}) + + self.assertEqual(len(m), 4) + self.assertTrue(key in m) + self.assertTrue(key2 in m) + self.assertTrue(key4 in m) + self.assertTrue(None in m) + self.assertEqual(m[key2], 2) + self.assertEqual(m[key4], 4) + self.assertEqual(m[key], -1) + self.assertEqual(m[None], 9) + + s = set(m) + self.assertEqual(len(s), 4) + self.assertEqual(s, set([None, key, key2, key4])) + + sk = set(m.keys()) + self.assertEqual(s, sk) + + sv = set(m.values()) + self.assertEqual(len(sv), 4) + self.assertEqual(sv, set([-1, 2, 4, 9])) + + si = set(m.items()) + self.assertEqual(len(si), 4) + self.assertEqual(si, + set([(key, -1), (key2, 2), (key4, 4), (None, 9)])) + + d = {key: -1, key2: 2, key4: 4, None: 9} + self.assertEqual(dict(m.items()), d) + + +class PyMapNoneTest(BaseNoneTest, unittest.TestCase): + + Map = PyMap + + +try: + from immutables._map import Map as CMap +except ImportError: + CMap = None + + +@unittest.skipIf(CMap is None, 'C Map is not available') +class CMapNoneTest(BaseNoneTest, unittest.TestCase): + + Map = CMap + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_pattern_matching.py b/tests/test_pattern_matching.py new file mode 100644 index 000000000..2594d887b --- /dev/null +++ b/tests/test_pattern_matching.py @@ -0,0 +1,51 @@ +import sys +import textwrap +import unittest + +from immutables.map import Map as PyMap + + +class BaseMapTest: + + Map = None + + @unittest.skipIf( + sys.version_info < (3, 10), + "pattern matching is not supported in this Python version", + ) + def test_map_can_be_matched(self): + locals_ = dict(locals()) + exec( + textwrap.dedent("""\ + match self.Map(a=1, b=2): # noqa: E999 + case {"a": 1 as matched}: + matched = matched + case _: + assert False + + self.assertEqual(matched, 1) + """), + globals(), + locals_, + ) + + +class PyMapTest(BaseMapTest, unittest.TestCase): + + Map = PyMap + + +try: + from immutables._map import Map as CMap +except ImportError: + CMap = None + + +@unittest.skipIf(CMap is None, 'C Map is not available') +class CMapTest(BaseMapTest, unittest.TestCase): + + Map = CMap + + +if __name__ == "__main__": + unittest.main()