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()