Repository: Korijn/pyopcode (language: python)

Issue:
pybind11 bindings and build process
Hi,

Not sure if you are still using this at CG, but I had an occasion to use it recently, but wanted compatibility with newer python versions. I decided it was a good occasion to give pybind11 a try, which seems to be preferred nowadays over boost.python; and indeed it was a relative breeze to get a compiling setup that (should) work across different python versions and OSes. Once I got it working once so far it has worked on the first try with any other config.

The only dangling bit here is the travis/docker stuff. I dont know if you still have an opinion about this at all; my suggestion would be to simplify the travis stuff to have some CI on the repo, and I will try and revive the conda-forge build; which should be a cookie-cutter recipe with the current approach, and rely on that for more comprehensive feedback on the robustness of the build. Building wheels is also supported btw.

If you are no longer invested and dont have time to review ill just maintain a fork on my github, and point the conda-forge recipe to that?


Discussion / hints:
Thanks for opening this PR! I've actually been itching to take a look at this myself. Pybind11 is pretty cool, huh? I'll have a good look later and review thoroughly.
> Thanks for opening this PR! I've actually been itching to take a look at this myself. Pybind11 is pretty cool, huh? I'll have a good look later and review thoroughly.

Yeah its pretty cool. I really appreciate that they have working examples under CI, and put in the effort to abstract away all the OS specific stuff. Its almost like this is the 21th century or something. This implementation has taken this repo as a starting point https://github.com/pybind/python_example, and it did a great job of 'just working', mostly.

That being said the documentation, while alright for a cpp project and covering the basics, is still mostly of the 'us strongly typed programmers need no silly documentation all information is there in the types what additional questions could you possibly have' kind. So if you want to know if reshaping an array is supported be prepared to crtl-f in the source. I wrote some utils for validating assumptions about an input array, and returning the content of a vector without making a copy and with correct memory management. Youd think that would come batteries included, but mostly the answer to such questions appears to be no. 

Still, I consider those minor complaints. Ive been trying to use cpp code from python for about 10 years now, and the ease with which I actually got something working, on all python versions, OSes and compilers, without writing any hacky and brittle nonsense myself, was definitely a quantum leap forward.
Ah even better yet; thought it was needed to pin the runtime python major/minor version, but it seems that as the provided example implies, you really dont need that. I can build using any python 3 and the tests work on python 2 even. They should advertise that more prominently in the pybind docs! Only tested on OSX so far though. EDIT: confirmed with people who seem to know what they are talking about; despite pybind calling back into the python process, it does something automagical thats effectively like dynamic linking to the shared python binaries, but not really.
My initial feedback for making this acceptable for us would be to drop the usage of conda. I propose:

* boost should be registered as a git submodule in the repo
* since pybind11 and numpy are available as wheels on pypi, they can be put in `pyproject.toml` (aren't they already?)

Pip/setuptools can install what it needs from that file and that takes care of all the build- and test-time dependencies.

In essence, I think this project doesn't need conda (or poetry (or pipenv)), just a `pyproject.toml` and `setup.py` should suffice, to be able to build wheels for all platforms.

Conda-forge can then contain a recipe to either build from scratch or repackage the wheel.
> My initial feedback for making this acceptable for us would be to drop the usage of conda. I propose:
> 
> * boost should be registered as a git submodule in the repo
> * since pybind11 and numpy are available as wheels on pypi, they can be put in `pyproject.toml` (aren't they already?)
> 
> Pip/setuptools can install what it needs from that file and that takes care of all the build- and test-time dependencies.
> 
> In essence, I think this project doesn't need conda (or poetry (or pipenv)), just a `pyproject.toml` and `setup.py` should suffice, to be able to build wheels for all platforms.
> 
> Conda-forge can then contain a recipe to either build from scratch or repackage the wheel.

Why not both, I wonder? I think having the conda recipe around is independently valuable, even if we get it to work on conda forge. If you want at any time to pick up development on the conda packaging side again, the conda forge recipe isnt the best place to start. I agree the environment.yml doesnt add much; though it does not cost much either.

Wrt to the building of wheels; I imagined it must be trivial since the `python -p pip install .` command seems to perform its install via an intermediate wheel; but I must say I know next to nothing about best practises of building wheels; the numpy runtime requirement isnt in there currently, it only has a build requirements section, where numpy does not belong. Perhaps I will get around to wrapping my head around all that sometime in the not too distant future; but if a well thought out wheel is a requirement for merging, perhaps it isnt in the cards.
I will try my hand at it sometime soon, it seems very simple since you already figured out all the hard parts.
I just got it to work (without conda) (on Windows) locally, I pushed a commit to your branch, updating `README.md` and `setup.py`. What are your thoughts?
> I just got it to work (without conda) (on Windows) locally, I pushed a commit to your branch, updating `README.md` and `setup.py`. What are your thoughts?

Nice!

Running ```pip wheel . -w ./dist --no-deps``` on my mac gives:

``` 
Created wheel for pyopcode: filename=pyopcode-0.4.0-cp38-cp38-macosx_10_14_x86_64.whl size=224665 sha256=75e20a9a3601e8edf6b0973cf16de2bd54f51fd2bfdebeeb89e94c23766bfc6d
  Stored in directory: /private/var/folders/pt/b_4mkhnd377945qjz8p23hbr0000gn/T/pip-ephem-wheel-cache-my6x295k/wheels/22/a5/66/19feb101496a4bc74eb6914ff1c0a47b52f397500e700d7915
```

Two problems with that: first the indicated path does not actually seem to exist. wtf?

Secondly, is it a problem that the .whl references '38' in its filename? Indeed I am using py38 to build it; but in conda this signals a python version specific package; which apparently is not necessary. Is this wheel meant to be python version independent; or do we need to signal that explicitly somehow?
I think such a tag doesn't exist outside of conda at the moment, I'll have to check whether we have to build it for each version or if you can repackage it somehow
> I think such a tag doesn't exist outside of conda at the moment, I'll have to check whether we have to build it for each version or if you can repackage it somehow

No, it does exist. I can indicate that the wheel is universal (py2/3 compatible pure wheel) in setup.cfg.

The docs also mention that universal wheels should be pure python, so this probably also makes the wheel OS agnostic, which is not what we want. We should be able to force a platform tag with the `--platform` option. I also have a trick up my sleeve (learned when working on pygfx/wgpu-py) to force it in setup.py.
Just a heads up for any onlookers; I'll be looking into this on our private repo first and will upstream the changes eventually here if I get everything to work.
I got everything to work on azure pipelines and our internal repository, except for the conda part. I'll open a PR on the conda forge recipe but I won't maintain/support it beyond initial creation.

Fix patch:
diff --git a/.dockerignore b/.dockerignore
deleted file mode 100644
index caaf46e..0000000
--- a/.dockerignore
+++ /dev/null
@@ -1,7 +0,0 @@
-.cache
-.pytest_cache
-.vscode
-boost_1_66_0/*
-build/*
-build_boost/*
-dist/*
\ No newline at end of file
diff --git a/.github/workflows/build_wheels.yml b/.github/workflows/build_wheels.yml
new file mode 100644
index 0000000..e7ee414
--- /dev/null
+++ b/.github/workflows/build_wheels.yml
@@ -0,0 +1,75 @@
+name: Build
+
+on:
+  pull_request:
+    branches:
+      - master
+  push:
+    branches:
+      - master
+    tags:
+      - v*
+
+jobs:
+  build_wheels:
+    name: Build wheels on ${{ matrix.os }}
+    runs-on: ${{ matrix.os }}
+    strategy:
+      matrix:
+        os: [ubuntu-20.04, windows-2019, macos-10.15]
+
+    steps:
+      - uses: actions/checkout@v2
+
+      - name: Build wheels
+        uses: pypa/cibuildwheel@v2.1.1
+        env:
+          CIBW_SKIP: pp* cp310*
+          CIBW_ARCHS_LINUX: auto64
+          CIBW_ARCHS_MACOS: auto64 arm64
+          CIBW_ARCHS_WINDOWS: auto64
+          CIBW_TEST_REQUIRES: pytest black
+          CIBW_TEST_COMMAND:  >
+            pytest {project}/tests &&
+            black --check {project}
+
+      - uses: actions/upload-artifact@v2
+        with:
+          path: ./wheelhouse/*.whl
+
+  build_sdist:
+    name: Build source distribution
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v2
+
+      - uses: actions/setup-python@v2
+        name: Install Python
+        with:
+          python-version: '3.8'
+
+      - name: Build sdist
+        run: >
+          python -m pip install -U setuptools wheel pybind11 &&
+          python setup.py sdist
+
+      - uses: actions/upload-artifact@v2
+        with:
+          path: dist/*.tar.gz
+
+  upload_pypi:
+    needs: [build_wheels, build_sdist]
+    runs-on: ubuntu-latest
+    # upload to PyPI on every tag starting with 'v'
+    if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags/v')
+    steps:
+      - uses: actions/download-artifact@v2
+        with:
+          name: artifact
+          path: dist
+
+      - uses: pypa/gh-action-pypi-publish@v1.4.2
+        with:
+          user: __token__
+          password: ${{ secrets.pypi }}
+          # To test: repository_url: https://test.pypi.org/legacy/
diff --git a/.gitignore b/.gitignore
index 31ac6f8..2568aaf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,4 @@
-boost_*
 build
-build_boost
 .vscode
 *.pyc
 __pycache__
@@ -10,4 +8,6 @@ dist
 *.so
 *.so.*
 .pytest_cache
-.cache
\ No newline at end of file
+.cache
+*.whl
+.venv
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 44759e9..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,11 +0,0 @@
-sudo: required
-
-language: python
-
-services:
-  - docker
-
-script:
-  - docker build -t pyopcode .
-  - mkdir dist
-  - docker run --rm pyopcode cat /io/dist/repaired/pyopcode-0.3.5-cp36-cp36m-manylinux1_x86_64.whl > dist/pyopcode-0.3.5-cp36-cp36m-manylinux1_x86_64.whl
diff --git a/Dockerfile b/Dockerfile
deleted file mode 100644
index d57bf5d..0000000
--- a/Dockerfile
+++ /dev/null
@@ -1,29 +0,0 @@
-FROM quay.io/pypa/manylinux1_x86_64
-
-WORKDIR /io
-
-ENV PATH=/opt/python/cp36-cp36m/bin:$PATH
-RUN pip install pipenv --upgrade
-
-COPY Pipfile .
-RUN pipenv install --dev --system --skip-lock
-
-RUN wget -qO- https://dl.bintray.com/boostorg/release/1.67.0/source/boost_1_67_0.tar.gz | tar xz
-COPY setup_utils.py .
-COPY build_boost.py .
-RUN python build_boost.py
-
-COPY vendor/opcode ./vendor/opcode
-COPY vendor/opcode/Ice ./vendor/opcode/Ice
-COPY pyopcode/__init__.py ./pyopcode
-COPY pyopcode/api.cpp ./pyopcode
-COPY version.py .
-COPY setup.py .
-RUN python setup.py build_ext -if
-
-COPY tests/ ./tests
-COPY setup.cfg .
-RUN flake8
-RUN PYTHONPATH=. pytest
-RUN python setup.py bdist_wheel
-RUN auditwheel repair ./dist/pyopcode* -w ./dist/repaired
diff --git a/Pipfile b/Pipfile
deleted file mode 100644
index f2fd53a..0000000
--- a/Pipfile
+++ /dev/null
@@ -1,18 +0,0 @@
-[[source]]
-url = "https://pypi.python.org/simple"
-verify_ssl = true
-name = "pypi"
-
-[packages]
-numpy = "~= 1.14"
-
-[dev-packages]
-wheel = "*"
-setuptools = "*"
-"flake8" = "*"
-pytest = "*"
-auditwheel = "*"
-twine = "*"
-
-[requires]
-python_version = "3.6"
diff --git a/Pipfile.lock b/Pipfile.lock
deleted file mode 100644
index 2df19cc..0000000
--- a/Pipfile.lock
+++ /dev/null
@@ -1,237 +0,0 @@
-{
-    "_meta": {
-        "hash": {
-            "sha256": "fd71a66e17ad320b06fe008de1b6e027963eeed9c9fe6cacfd389953bf9b7595"
-        },
-        "pipfile-spec": 6,
-        "requires": {
-            "python_version": "3.6"
-        },
-        "sources": [
-            {
-                "name": "pypi",
-                "url": "https://pypi.python.org/simple",
-                "verify_ssl": true
-            }
-        ]
-    },
-    "default": {
-        "numpy": {
-            "hashes": [
-                "sha256:0739146eaf4985962f07c62f7133aca89f3a600faac891ce6c7f3a1e2afe5272",
-                "sha256:07e21f14490324cc1160db101e9b6c1233c33985af4cb1d301dd02650fea1d7f",
-                "sha256:0f6a5ed0cd7ab1da11f5c07a8ecada73fc55a70ef7bb6311a4109891341d7277",
-                "sha256:0fd65cbbfdbf76bbf80c445d923b3accefea0fe2c2082049e0ce947c81fe1d3f",
-                "sha256:20cac3123d791e4bf8482a580d98d6b5969ba348b9d5364df791ba3a666b660d",
-                "sha256:528ce59ded2008f9e8543e0146acb3a98a9890da00adf8904b1e18c82099418b",
-                "sha256:56e392b7c738bd70e6f46cf48c8194d3d1dd4c5a59fae4b30c58bb6ef86e5233",
-                "sha256:675e0f23967ce71067d12b6944add505d5f0a251f819cfb44bdf8ee7072c090d",
-                "sha256:6be6b0ca705321c178c9858e5ad5611af664bbdfae1df1541f938a840a103888",
-                "sha256:719d914f564f35cce4dc103808f8297c807c9f0297ac183ed81ae8b5650e698e",
-                "sha256:768e777cc1ffdbf97c507f65975c8686ebafe0f3dc8925d02ac117acc4669ce9",
-                "sha256:7f76d406c6b998d6410198dcb82688dcdaec7d846aa87e263ccf52efdcfeba30",
-                "sha256:8c18ee4dddd5c6a811930c0a7c7947bf16387da3b394725f6063f1366311187d",
-                "sha256:99051e03b445117b26028623f1a487112ddf61a09a27e2d25e6bc07d37d94f25",
-                "sha256:a1413d06abfa942ca0553bf3bccaff5fdb36d55b84f2248e36228db871147dab",
-                "sha256:a7157c9ac6bddd2908c35ef099e4b643bc0e0ebb4d653deb54891d29258dd329",
-                "sha256:a958bf9d4834c72dee4f91a0476e7837b8a2966dc6fcfc42c421405f98d0da51",
-                "sha256:bb370120de6d26004358611441e07acda26840e41dfedc259d7f8cc613f96495",
-                "sha256:d0928076d9bd8a98de44e79b1abe50c1456e7abbb40af7ef58092086f1a6c729",
-                "sha256:d858423f5ed444d494b15c4cc90a206e1b8c31354c781ac7584da0d21c09c1c3",
-                "sha256:e6120d63b50e2248219f53302af7ec6fa2a42ed1f37e9cda2c76dbaca65036a7",
-                "sha256:f2b1378b63bdb581d5d7af2ec0373c8d40d651941d283a2afd7fc71184b3f570",
-                "sha256:facc6f925c3099ac01a1f03758100772560a0b020fb9d70f210404be08006bcb"
-            ],
-            "index": "pypi",
-            "version": "==1.14.2"
-        }
-    },
-    "develop": {
-        "attrs": {
-            "hashes": [
-                "sha256:1c7960ccfd6a005cd9f7ba884e6316b5e430a3f1a6c37c5f87d8b43f83b54ec9",
-                "sha256:a17a9573a6f475c99b551c0e0a812707ddda1ec9653bed04c13841404ed6f450"
-            ],
-            "version": "==17.4.0"
-        },
-        "auditwheel": {
-            "hashes": [
-                "sha256:63468bf62d82b27b1635ffe1c3f1c4e54ef67d0a6d38bedccc38ce6965d56db7",
-                "sha256:829ec26097f6ca9a239ebfb4d3c6dc093742141423268cb7ba1d8b15fb0c20a2"
-            ],
-            "index": "pypi",
-            "version": "==1.8.0"
-        },
-        "certifi": {
-            "hashes": [
-                "sha256:13e698f54293db9f89122b0581843a782ad0934a4fe0172d2a980ba77fc61bb7",
-                "sha256:9fa520c1bacfb634fa7af20a76bcbd3d5fb390481724c597da32c719a7dca4b0"
-            ],
-            "version": "==2018.4.16"
-        },
-        "chardet": {
-            "hashes": [
-                "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
-                "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
-            ],
-            "version": "==3.0.4"
-        },
-        "colorama": {
-            "hashes": [
-                "sha256:463f8483208e921368c9f306094eb6f725c6ca42b0f97e313cb5d5512459feda",
-                "sha256:48eb22f4f8461b1df5734a074b57042430fb06e1d61bd1e11b078c0fe6d7a1f1"
-            ],
-            "markers": "sys_platform == 'win32'",
-            "version": "==0.3.9"
-        },
-        "flake8": {
-            "hashes": [
-                "sha256:7253265f7abd8b313e3892944044a365e3f4ac3fcdcfb4298f55ee9ddf188ba0",
-                "sha256:c7841163e2b576d435799169b78703ad6ac1bbb0f199994fc05f700b2a90ea37"
-            ],
-            "index": "pypi",
-            "version": "==3.5.0"
-        },
-        "funcsigs": {
-            "hashes": [
-                "sha256:330cc27ccbf7f1e992e69fef78261dc7c6569012cf397db8d3de0234e6c937ca",
-                "sha256:a7bb0f2cf3a3fd1ab2732cb49eba4252c2af4240442415b4abce3b87022a8f50"
-            ],
-            "markers": "python_version < '3.0'",
-            "version": "==1.0.2"
-        },
-        "idna": {
-            "hashes": [
-                "sha256:2c6a5de3089009e3da7c5dde64a141dbc8551d5b7f6cf4ed7c2568d0cc520a8f",
-                "sha256:8c7309c718f94b3a625cb648ace320157ad16ff131ae0af362c9f21b80ef6ec4"
-            ],
-            "version": "==2.6"
-        },
-        "mccabe": {
-            "hashes": [
-                "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42",
-                "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"
-            ],
-            "version": "==0.6.1"
-        },
-        "more-itertools": {
-            "hashes": [
-                "sha256:0dd8f72eeab0d2c3bd489025bb2f6a1b8342f9b198f6fc37b52d15cfa4531fea",
-                "sha256:11a625025954c20145b37ff6309cd54e39ca94f72f6bb9576d1195db6fa2442e",
-                "sha256:c9ce7eccdcb901a2c75d326ea134e0886abfbea5f93e91cc95de9507c0816c44"
-            ],
-            "version": "==4.1.0"
-        },
-        "pkginfo": {
-            "hashes": [
-                "sha256:5878d542a4b3f237e359926384f1dde4e099c9f5525d236b1840cf704fa8d474",
-                "sha256:a39076cb3eb34c333a0dd390b568e9e1e881c7bf2cc0aee12120636816f55aee"
-            ],
-            "version": "==1.4.2"
-        },
-        "pluggy": {
-            "hashes": [
-                "sha256:7f8ae7f5bdf75671a718d2daf0a64b7885f74510bcd98b1a0bb420eb9a9d0cff",
-                "sha256:d345c8fe681115900d6da8d048ba67c25df42973bda370783cd58826442dcd7c",
-                "sha256:e160a7fcf25762bb60efc7e171d4497ff1d8d2d75a3d0df7a21b76821ecbf5c5"
-            ],
-            "version": "==0.6.0"
-        },
-        "py": {
-            "hashes": [
-                "sha256:29c9fab495d7528e80ba1e343b958684f4ace687327e6f789a94bf3d1915f881",
-                "sha256:983f77f3331356039fdd792e9220b7b8ee1aa6bd2b25f567a963ff1de5a64f6a"
-            ],
-            "version": "==1.5.3"
-        },
-        "pycodestyle": {
-            "hashes": [
-                "sha256:1ec08a51c901dfe44921576ed6e4c1f5b7ecbad403f871397feedb5eb8e4fa14",
-                "sha256:5ff2fbcbab997895ba9ead77e1b38b3ebc2e5c3b8a6194ef918666e4c790a00e",
-                "sha256:682256a5b318149ca0d2a9185d365d8864a768a28db66a84a2ea946bcc426766",
-                "sha256:6c4245ade1edfad79c3446fadfc96b0de2759662dc29d07d80a6f27ad1ca6ba9"
-            ],
-            "version": "==2.3.1"
-        },
-        "pyelftools": {
-            "hashes": [
-                "sha256:e9dd97d685a5b96b88a988dabadb88e5a539b64cd7d7927fac9a7368dc4c459c"
-            ],
-            "version": "==0.24"
-        },
-        "pyflakes": {
-            "hashes": [
-                "sha256:08bd6a50edf8cffa9fa09a463063c425ecaaf10d1eb0335a7e8b1401aef89e6f",
-                "sha256:8d616a382f243dbf19b54743f280b80198be0bca3a5396f1d2e1fca6223e8805"
-            ],
-            "version": "==1.6.0"
-        },
-        "pytest": {
-            "hashes": [
-                "sha256:6266f87ab64692112e5477eba395cfedda53b1933ccd29478e671e73b420c19c",
-                "sha256:fae491d1874f199537fd5872b5e1f0e74a009b979df9d53d1553fd03da1703e1"
-            ],
-            "index": "pypi",
-            "version": "==3.5.0"
-        },
-        "requests": {
-            "hashes": [
-                "sha256:6a1b267aa90cac58ac3a765d067950e7dbbf75b1da07e895d1f594193a40a38b",
-                "sha256:9c443e7324ba5b85070c4a818ade28bfabedf16ea10206da1132edaa6dda237e"
-            ],
-            "version": "==2.18.4"
-        },
-        "requests-toolbelt": {
-            "hashes": [
-                "sha256:42c9c170abc2cacb78b8ab23ac957945c7716249206f90874651971a4acff237",
-                "sha256:f6a531936c6fa4c6cfce1b9c10d5c4f498d16528d2a54a22ca00011205a187b5"
-            ],
-            "version": "==0.8.0"
-        },
-        "six": {
-            "hashes": [
-                "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9",
-                "sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb"
-            ],
-            "version": "==1.11.0"
-        },
-        "tqdm": {
-            "hashes": [
-                "sha256:597e7526c85df881d51e094360181a84533aede1cb3f5a1cada8bbd4de557efd",
-                "sha256:fe3d218d5b61993d415aa2a9db6dd64c0e4cefb90164ebb197ef3b1d99f531dc"
-            ],
-            "version": "==4.23.0"
-        },
-        "twine": {
-            "hashes": [
-                "sha256:08eb132bbaec40c6d25b358f546ec1dc96ebd2638a86eea68769d9e67fe2b129",
-                "sha256:2fd9a4d9ff0bcacf41fdc40c8cb0cfaef1f1859457c9653fd1b92237cc4e9f25"
-            ],
-            "index": "pypi",
-            "version": "==1.11.0"
-        },
-        "typing": {
-            "hashes": [
-                "sha256:3a887b021a77b292e151afb75323dea88a7bc1b3dfa92176cff8e44c8b68bddf",
-                "sha256:b2c689d54e1144bbcfd191b0832980a21c2dbcf7b5ff7a66248a60c90e951eb8",
-                "sha256:d400a9344254803a2368533e4533a4200d21eb7b6b729c173bc38201a74db3f2"
-            ],
-            "version": "==3.6.4"
-        },
-        "urllib3": {
-            "hashes": [
-                "sha256:06330f386d6e4b195fbfc736b297f58c5a892e4440e54d294d7004e3a9bbea1b",
-                "sha256:cc44da8e1145637334317feebd728bd869a35285b93cbb4cca2577da7e62db4f"
-            ],
-            "version": "==1.22"
-        },
-        "wheel": {
-            "hashes": [
-                "sha256:1ae8153bed701cb062913b72429bcf854ba824f973735427681882a688cb55ce",
-                "sha256:9cdc8ab2cc9c3c2e2727a4b67c22881dbb0e1c503d592992594c5e131c867107"
-            ],
-            "index": "pypi",
-            "version": "==0.31.0"
-        }
-    }
-}
diff --git a/README.md b/README.md
index a1219ec..0b27aa4 100644
--- a/README.md
+++ b/README.md
@@ -1,32 +1,142 @@
-[![PyPI](https://badge.fury.io/py/pyopcode.svg)](https://badge.fury.io/py/pyopcode) [![Anaconda](https://anaconda.org/conda-forge/pyopcode/badges/version.svg)](https://anaconda.org/conda-forge/pyopcode/) [![Travis](https://travis-ci.org/ClinicalGraphics/pyopcode.svg?branch=master)](https://travis-ci.org/ClinicalGraphics/pyopcode/)
-
 # pyopcode
 
-The one and only. To get started with development:
+Python bindings for the OPCODE collision detection library.
+
+Note that the bindings provide access to only a small portion of the full library.
+Pull requests to expose a larger API surface area are welcome.
+
+Current functionality includes:
+
+* Ray-querying triangle meshes
+* Colliding pairs of triangle meshes under affine transformations
+
+See the test suite for various usage examples.
+
+## Install
+
+`pip install pyopcode`
+
+64-bit wheels are on pypi for cpython 3.6+ on Windows, Linux and MacOS, including ARM builds.
+
+## API
+
+### pyopcode.Model(vertices, triangle)
+
+This class represents a triangle mesh and the constructor takes two arguments:
+
+* `vertices: ndarray float32 [n_vertices, 3])`
+* `triangles: ndarray int32 [n_triangles, 3])`
+
+The `vertices` array is expected to hold object space 3D coordinates
+and `triangles` indexes the `vertices` array.
+
+Instances of this class expose the method `ray_query`, and they can be passed
+to the `pyopcode.Collision` constructor to create a collider.
+
+### pyopcode.Model.ray_query(rays)
+
+This instance method takes a single argument:
+
+* `rays: ndarray float32 [n_rays, 2, 3]`
+
+And returns a single array:
+
+* `faces: ndarray int32 [n_rays]`
+
+The `rays` array is expected to hold a 3D origin coordinate and direction
+vector for each ray.
+
+The returned `faces` array holds the index of the first triangle that is hit
+by each ray, or `-1` in case of no hit.
+
+### pyopcode.Collision(mesh0, mesh1)
+
+This class represents a mesh collider and the constructor takes two arguments:
+
+* `mesh0: pyopcode.Model`
+* `mesh1: pyopcode.Model`
+
+Instances of this class expose the method `query`.
+
+### pyopcode.Collision.query(affine0, affine1)
+
+This instance method takes two arguments:
+
+* `affine0: ndarray float32 [4, 4]`
+* `affine1: ndarray float32 [4, 4]`
+
+And returns a single array:
+
+* `pairs: ndarray int32 [n_pairs, 2]`
+
+The `affine0` and `affine1` arrays are expected to hold 3D affine transformation
+matrices for each of the two mesh models that were passed to the constructor.
+These are applied to the models during the query collision.
+
+The returned `pairs` array holds index pairs of triangles that collided.
+
+## Collision example
+
+```python
+import numpy as np
+import pyopcode
+
+# create two simple triangle meshes to collide
+vertices_a = np.array([
+        [0, 0, 0],
+        [1, 0, 0],
+        [0, 1, 0],
+        [1, 1, 0],
+    ], dtype="f4")
+triangles_a = np.array([
+        [0, 1, 2],
+        [3, 2, 1],
+    ], dtype="i4")
+mesh0 = pyopcode.Model(vertices_a, triangles_a)
+
+vertices_b = (vertices_a.copy() + [0.6, 0.6, 0]).astype("f4")
+triangles_b = triangles_a.copy()
+mesh1 = pyopcode.Model(vertices_b, triangles_b)
+
+# create collider
+collider = pyopcode.Collision(mesh0, mesh1)
+
+# collide the models under affine transforms
+affine0 = np.identity(4, dtype="f4")
+affine1 = np.identity(4, dtype="f4")
+pairs = collider.query(affine0, affine1)
+```
 
-* Download a boost tarball and unpack into repo root, so you have a folder 'boost_1_67_0' in the root.
-* Run `pipenv install --dev`.
-* Run `pipenv run python build_boost.py`.
-* Run `pipenv run python setup.py build_ext -if`, and repeat this call whenever you change something in `api.cpp`.
+## Development and releasing
 
-Housekeeping:
+Build locally:
 
-* Run `pipenv run flake8`
-* Run `PYTHONPATH=. pipenv run pytest`
+* `python -m pip wheel -w wheelhouse --no-deps .`
 
-Distribution:
+Develop and test locally (replace `bin` with `Scripts` on windows):
 
-* Run `pipenv run python setup.py bdist_wheel`
+* `python -m venv .venv`
+* `.venv/bin/pip install -e .[dev]`
+* `.venv/bin/pytest tests`
+* `.venv/bin/black .`
 
-# Windows Caveats
+Release by pushing a tag that starts with `v`, for example `v0.1.0`.
 
-* `Pipfile.lock` was generated on Linux, so make sure to additionally pass `--skip-lock` to the `pipenv install` command.
-* Pipenv doesn't copy over the `libs` folder from the root env to the virtualenv. Do this manually after running the `pipenv install` command.
-* Make sure to have VS2017 build tools installed!
+Ensure you update the version number in `setup.py`.
 
-# manylinux1
+## Dependency management
 
-To get the manylinux1 stamp of approval, build using the provided Dockerfile.
+Sadly the virtual environment and dependency management tools
+aren't quite ready for libraries with C extensions just yet.
+So when adjusting dependencies, ensure you update all places
+they are configured:
 
-* Run `docker build . -t pyopcode`
-* Run `docker run --rm pyopcode cat /io/dist/repaired/pyopcode-0.3.5-cp36-cp36m-manylinux1_x86_64.whl > dist/pyopcode-0.3.5-cp36-cp36m-manylinux1_x86_64.whl`
+* `.github\workflows\build_wheels.yml`
+  * Check `CIBW_TEST_REQUIRES`
+  * Check `Build sdist`
+* `pyproject.toml`
+  * Check `build-system.requires`
+* `setup.py`
+  * Check `install_requires`
+  * Check `extras_require`
+  * Check `python_requires`
diff --git a/build_boost.py b/build_boost.py
deleted file mode 100644
index 3ab6f10..0000000
--- a/build_boost.py
+++ /dev/null
@@ -1,50 +0,0 @@
-import logging
-import subprocess
-from multiprocessing import cpu_count
-from os.path import abspath, exists
-
-from setup_utils import get_include_dir, get_vcvarsall, is_win, is_darwin
-
-
-def call(cmd, **kwargs):
-    cwd = f"{kwargs['cwd']}> " if 'cwd' in kwargs else ""
-    msg = f"spawning subprocess: {cwd}{cmd}"
-    logging.info(msg)
-    subprocess.run(cmd, shell=True, check=True, **kwargs)
-
-
-def main():
-    build_prefix = abspath("build_boost")
-    pkg_prefix = abspath("pyopcode")
-    toolset = "gcc"
-    sh_ext = "sh"
-    sh_prefix = "./"
-    if is_win:
-        call(f"\"{get_vcvarsall()}\" amd64")
-        toolset = "msvc"
-        sh_ext = "bat"
-        sh_prefix = ""
-    if is_darwin:
-        toolset = "clang"
-    if not exists(build_prefix):
-        call(f"{sh_prefix}bootstrap.{sh_ext}", cwd="boost_1_67_0")
-    call(f"{sh_prefix}b2"
-         f" toolset={toolset}"
-         " address-model=64"
-         " link=shared"
-         " variant=release"
-         " threading=multi"
-         " runtime-link=shared"
-         f" include={get_include_dir()}"
-         " --with-python"
-         f" -j{cpu_count()}"
-         " install"
-         f" --prefix=\"{build_prefix}\""
-         f" --libdir=\"{pkg_prefix}\"",
-         cwd="boost_1_67_0")
-
-
-if __name__ == "__main__":
-    logging.basicConfig(
-        level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
-    main()
diff --git a/pyopcode/__init__.py b/pyopcode/__init__.py
deleted file mode 100644
index 3efc97b..0000000
--- a/pyopcode/__init__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-__version__ = "0.3.5"
-
-# directly expose cpp api at root package module
-from .api import *  # noqa
diff --git a/pyopcode/api.cpp b/pyopcode/api.cpp
deleted file mode 100644
index 5a2bfcf..0000000
--- a/pyopcode/api.cpp
+++ /dev/null
@@ -1,228 +0,0 @@
-#include <iostream>
-#include <boost/cstdfloat.hpp>
-#include <boost/cstdint.hpp>
-#include <boost/python.hpp>
-#include <boost/python/numpy.hpp>
-#include "opcode/Opcode.h"
- 
-namespace p = boost::python;
-namespace np = boost::python::numpy;
-
-
-class ReleaseGIL {
-    /*
-    allocate this on the stack to get GIL release handling
-    */
-    public:
-        inline ReleaseGIL(){
-            save_state = PyEval_SaveThread();
-        }
-
-        inline ~ReleaseGIL(){
-            PyEval_RestoreThread(save_state);
-        }
-    private:
-        PyThreadState *save_state;
-};
-
-
-class MeshModel {
-    /*
-    wrapper class for vertices and triangles,
-    and corresponding opcode-model, or collision detection acceleration structure
-    */
-    public:
-        const np::ndarray vertices;  // <boost::float32_t, 2>    
-        const np::ndarray triangles;  // <boost::int32_t, 2>
-        const Opcode::MeshInterface interface;
-        const Opcode::Model model;
-
-        explicit MeshModel(const np::ndarray vertices, const np::ndarray triangles) :
-            vertices    (vertices),
-            triangles   (triangles),
-            interface   (init_interface()),
-            model       (init_model())
-        {}
-
-        np::ndarray ray_query(const np::ndarray rays) const {
-            // rays has shape [n_rays, 2*3]; note that contiguity in all axes is important
-            // returns first faceid or -1 in case of no hit
-            const np::dtype rays_dtype = np::dtype::get_builtin<boost::float32_t>();
-            if (rays.get_nd() != 2
-                || rays.shape(1) != 6
-                || !(rays.get_flags() & np::ndarray::C_CONTIGUOUS)
-                || rays.get_dtype().get_itemsize() != 4
-                || rays.get_dtype() != rays_dtype) {
-                PyErr_SetString(PyExc_ValueError, "rays must be a C-contiguous (n,6) array of dtype f4.");
-                p::throw_error_already_set();
-            }
-
-            const boost::int32_t n_rays = rays.shape(0);
-            // view input array as array of IceMaths::Ray
-            const IceMaths::Ray* ice_rays = reinterpret_cast<IceMaths::Ray *>(rays.get_data());
-            // result ndarray
-            const p::tuple shape = p::make_tuple(n_rays);
-            const np::dtype dtype = np::dtype::get_builtin<boost::int32_t>();
-            np::ndarray faces = np::empty(shape, dtype);
-
-            Opcode::RayCollider RC = Opcode::RayCollider();
-            RC.SetFirstContact(false);
-            RC.SetTemporalCoherence(true);
-            RC.SetClosestHit(true);
-            RC.SetCulling(false);
-
-            static udword Cache;
-            Opcode::CollisionFaces CF;
-            RC.SetDestination(&CF);
-
-            // write to the numpy buffer using raw pointer access for greater speed
-            boost::int32_t* faces_ptr = reinterpret_cast<boost::int32_t*>(faces.get_data());
-            for (boost::int32_t i=0; i < n_rays; i++) {
-                bool status(RC.Collide(ice_rays[i], model, 0, &Cache));
-                faces_ptr[i] = RC.GetNbIntersections() ? CF.GetFaces()[0].mFaceID : -1;
-            }
-            return faces;
-        }
-
-    private:
-        Opcode::MeshInterface init_interface() const {
-            Opcode::MeshInterface interface;
-
-            const np::dtype triangles_dtype = np::dtype::get_builtin<boost::int32_t>();
-            if (triangles.get_nd() != 2
-                || triangles.shape(1) != 3
-                || !(triangles.get_flags() & np::ndarray::C_CONTIGUOUS)
-                || triangles.get_dtype().get_itemsize() != 4
-                || triangles.get_dtype() != triangles_dtype) {
-                PyErr_SetString(PyExc_ValueError, "triangles must be a C-contiguous (n,3) array of dtype i4.");
-                p::throw_error_already_set();
-            }
-            const np::dtype vertices_dtype = np::dtype::get_builtin<boost::float32_t>();
-            if (vertices.get_nd() != 2
-                || vertices.shape(1) != 3
-                || !(vertices.get_flags() & np::ndarray::C_CONTIGUOUS)
-                || vertices.get_dtype().get_itemsize() != 4
-                || vertices.get_dtype() != vertices_dtype) {
-                PyErr_SetString(PyExc_ValueError, "vertices must be a C-contiguous (n,3) array of dtype f4.");
-                p::throw_error_already_set();
-            }
-
-            interface.SetNbTriangles(triangles.shape(0));
-            interface.SetNbVertices(vertices.shape(0));
-            interface.SetPointers(
-                reinterpret_cast<IceMaths::IndexedTriangle *>(triangles.get_data()),
-                reinterpret_cast<IceMaths::Point *>(vertices.get_data())
-            );
-            return interface;
-        }
-
-        Opcode::Model init_model() const {
-            //Tree building settings
-            Opcode::OPCODECREATE OPCC;
-            OPCC.mIMesh = const_cast<Opcode::MeshInterface*>(&interface);
-            OPCC.mNoLeaf = true;
-            OPCC.mQuantized = false;
-            OPCC.mKeepOriginal = false;
-
-            Opcode::Model model;
-            {
-                ReleaseGIL GIL;         // release GIL during heavy lifting without python calls
-                model.Build(OPCC);
-            }
-            return model;
-        }
-};
-
-
-class MeshCollision {
-    // typedef ndarray<real_t, 2> affine_t;
-
-    const MeshModel& mesh0;
-    const MeshModel& mesh1;
-
-    const Opcode::BVTCache cache;
-
-public:
-    explicit MeshCollision(const MeshModel& mesh0, const MeshModel& mesh1) :
-        mesh0(mesh0),
-        mesh1(mesh1),
-        cache(init_cache())
-    {}
-
-    Opcode::BVTCache init_cache() const {
-        Opcode::BVTCache cache;
-        cache.Model0 = &mesh0.model;
-        cache.Model1 = &mesh1.model;
-        return cache;
-    }
-
-    // [int, 2]
-    np::ndarray query(const np::ndarray affine0, const np::ndarray affine1) const {
-        const np::dtype affine_dtype = np::dtype::get_builtin<boost::float32_t>();
-        if (affine0.get_nd() != 2
-            || affine0.shape(0) != 4
-            || affine0.shape(1) != 4
-            || !(affine0.get_flags() & np::ndarray::C_CONTIGUOUS || affine0.get_flags() & np::ndarray::F_CONTIGUOUS)
-            || affine0.get_dtype().get_itemsize() != 4
-            || affine0.get_dtype() != affine_dtype) {
-            PyErr_SetString(PyExc_ValueError, "affine0 must be C or F-contiguous (4,4) array of dtype f4.");
-            p::throw_error_already_set();
-        }
-        if (affine1.get_nd() != 2
-            || affine1.shape(0) != 4
-            || affine1.shape(1) != 4
-            || !(affine1.get_flags() & np::ndarray::C_CONTIGUOUS || affine1.get_flags() & np::ndarray::F_CONTIGUOUS)
-            || affine1.get_dtype().get_itemsize() != 4
-            || affine1.get_dtype() != affine_dtype) {
-            PyErr_SetString(PyExc_ValueError, "affine1 must be C or F-contiguous (4,4) array of dtype f4.");
-            p::throw_error_already_set();
-        }
-
-        // Collision query
-        Opcode::AABBTreeCollider TC;
-        {
-            ReleaseGIL GIL;         // release GIL during heavy lifting without python calls
-            const bool IsOk(
-                TC.Collide(
-                    const_cast<Opcode::BVTCache&>(cache),       // const in this context, but not for TC
-                    reinterpret_cast<IceMaths::Matrix4x4*>(affine0.get_data()),
-                    reinterpret_cast<IceMaths::Matrix4x4*>(affine1.get_data())
-                )
-            );
-        }
-
-        // lets see how much hits we have
-        const boost::int32_t n_pairs = TC.GetContactStatus() ? TC.GetNbPairs() : 0;
-        const IceCore::Pair* ice_pairs = TC.GetContactStatus() ? TC.GetPairs() : (IceCore::Pair*)0;
-
-        // result ndarray
-        const p::tuple shape = p::make_tuple(n_pairs, 2);
-        const np::dtype dtype = np::dtype::get_builtin<boost::int32_t>();
-        np::ndarray pairs = np::empty(shape, dtype);
-
-        // write to the numpy result array using raw pointer access for greater speed
-        boost::int32_t* pairs_ptr = reinterpret_cast<boost::int32_t*>(pairs.get_data());
-        for (boost::int32_t i=0; i < n_pairs; i++) {
-            pairs_ptr[2*i] = ice_pairs[i].id0;
-            pairs_ptr[2*i+1] = ice_pairs[i].id1;
-        }
-        
-        return pairs;
-    }
-};
-
-
-BOOST_PYTHON_MODULE(api)
-{
-    PyEval_InitThreads(); // init Python GIL control
-    Py_Initialize(); // init Python
-    np::initialize(); // init Numpy
-
-    p::class_<MeshModel>("Model", p::init<np::ndarray, np::ndarray>())
-        .def("ray_query", &MeshModel::ray_query)
-        ;
-
-    p::class_<MeshCollision>("Collision", p::init<MeshModel&, MeshModel&>())
-        .def("query", &MeshCollision::query)
-        ;
-}
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..df95534
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,8 @@
+[build-system]
+requires = [
+    "setuptools>=42",
+    "wheel",
+    "pybind11>=2.7.0",
+]
+
+build-backend = "setuptools.build_meta"
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 7ff885c..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,7 +0,0 @@
-[flake8]
-max_line_length = 120
-ignore = E731
-exclude = .git,__pycache__,build,dist,build_boost,boost_1_67_0,.vscode,.pytest_cache,vendor
-
-[tool:pytest]
-testpaths = tests
diff --git a/setup.py b/setup.py
index 373f499..821447f 100644
--- a/setup.py
+++ b/setup.py
@@ -1,45 +1,60 @@
-# flake8: noqa
-from setuptools import Extension, find_packages, setup
+from setuptools import setup
 
 from glob import iglob
+import sys
 
-from setup_utils import get_data_files, get_package_data, is_win, is_darwin
-from version import __version__
+from pybind11.setup_helpers import Pybind11Extension, build_ext
 
 
-packages = find_packages()
-package_data = get_package_data(packages, exclude=('.py', '.pyc', '.cpp', '.h'))
-opcode_src = list(iglob("vendor/opcode/**/*.cpp", recursive=True))
+is_win = sys.platform == "win32"
+
+__version__ = "0.4.0"
+
+# The main interface is through Pybind11Extension.
+# * You can add cxx_std=11/14/17, and then build_ext can be removed.
+# * You can set include_pybind11=false to add the include directory yourself,
+#   say from a submodule.
+#
+# Note:
+#   Sort input source files if you glob sources to ensure bit-for-bit
+#   reproducible builds (https://github.com/pybind/python_example/pull/53)
+
+opcode_src = sorted(iglob("vendor/opcode/**/*.cpp", recursive=True))
+opcode_include = ["vendor", "vendor/opcode", "vendor/opcode/Ice"]
 
 compile_args = []
-link_args = []
-runtime_library_dirs = []
-libraries = []
 if is_win:
-    # see https://github.com/boostorg/python/issues/193 for /DBOOST_ALL_NO_LIB
-    compile_args = ["/DICE_NO_DLL", "/DBAN_OPCODE_AUTOLINK", "/DBOOST_ALL_NO_LIB"]
-    libraries = ["boost_python36-vc141-mt-x64-1_67", "boost_numpy36-vc141-mt-x64-1_67"]
-elif is_darwin:
-    link_args = ["-Wl,-rpath,@loader_path"]
-    libraries = ["boost_python36", "boost_numpy36"]
-else:
-    runtime_library_dirs = ["$ORIGIN"]
-    libraries = ["boost_python36", "boost_numpy36"]
+    compile_args.extend(["/DICE_NO_DLL", "/DBAN_OPCODE_AUTOLINK"])
+
+ext_modules = [
+    Pybind11Extension(
+        "pyopcode",
+        ["src/api.cpp"] + opcode_src,
+        include_dirs=opcode_include,
+        extra_compile_args=compile_args,
+    ),
+]
 
 setup(
-    name='pyopcode',
+    name="pyopcode",
     version=__version__,
-    packages=packages,
-    install_requires=['numpy ~= 1.14'],
-    ext_modules=[
-        Extension('pyopcode.api', ['pyopcode/api.cpp'] + opcode_src,
-                  include_dirs=['build_boost/include', 'build_boost/include/boost-1_67', 'vendor', 'vendor/opcode', 'vendor/opcode/Ice'],
-                  library_dirs=['pyopcode'],
-                  libraries=libraries,
-                  runtime_library_dirs=runtime_library_dirs,
-                  extra_compile_args=compile_args,
-                  extra_link_args=link_args),
+    author="Korijn van Golen",
+    author_email="korijn.vangolen@zimmerbiomet.com",
+    url="https://github.com/ClinicalGraphics/pyopcode/",
+    description="OPCODE python bindings",
+    ext_modules=ext_modules,
+    python_requires=">=3.6.0",
+    install_requires=[
+        "numpy>=1.15.0",
     ],
-    package_data=package_data,
-    include_package_data=True,
+    extras_require={
+        "dev": [
+            "pytest",
+            "black",
+        ],
+    },
+    # Currently, build_ext only provides an optional "highest supported C++
+    # level" feature, but in the future it may provide more features.
+    cmdclass={"build_ext": build_ext},
+    zip_safe=False,
 )
diff --git a/setup_utils.py b/setup_utils.py
deleted file mode 100644
index 8703a88..0000000
--- a/setup_utils.py
+++ /dev/null
@@ -1,213 +0,0 @@
-from itertools import chain
-from collections import defaultdict
-from os.path import isfile, relpath, dirname, splitext, join, exists, expandvars
-from setuptools.dist import Distribution
-from glob import iglob
-import inspect
-import sys
-from sysconfig import get_paths
-
-
-is_win = (sys.platform == 'win32')
-is_darwin = (sys.platform == 'darwin')
-
-
-class BinaryDistribution(Distribution):
-    """
-    Specialized setuptools `Distribution` class that forces a platform-specific package build.
-
-    In the scenario that you are not providing any Extensions to the ext_modules keyword argument of `setup()`, but
-    would still like to build a platform-specific wheel, you can provide this class to the `distclass` keyword
-    argument of `setup()`. Setuptools will then recognize your distribution as containing Extensions and build a
-    platform-specific wheel.
-
-    Examples
-    --------
-    >>> setup(distclass=BinaryDistribution)
-    """
-
-    def has_ext_modules(self):
-        return True
-
-    def __getattribute__(self, name):
-        if name == "ext_modules":
-            is_finalize_options_call = [frame for frame in inspect.stack()
-                                        if frame.filename.endswith('install.py') and
-                                        frame.function == 'finalize_options']
-            if is_finalize_options_call:
-                # this distutils function checks the self.ext_modules attribute directly for truthiness, instead
-                # of calling self.has_ext_modules()
-                return [True]  # any truthy list will do
-
-        return super().__getattribute__(name)
-
-
-def get_package_dir(package, package_dir=None):
-    """
-    Return the directory, relative to the top of the source distribution, where package 'package' should be found
-    (at least according to the 'package_dir' option, if any).
-
-    Notes
-    -----
-    Copied and modified source from distutils.command.build_py
-    """
-    if package_dir is None:
-        package_dir = {}
-
-    path = package.split('.')
-
-    if not package_dir:
-        if path:
-            return join(*path)
-        else:
-            return ''
-    else:
-        tail = []
-        while path:
-            try:
-                pdir = package_dir['.'.join(path)]
-            except KeyError:
-                tail.insert(0, path[-1])
-                del path[-1]
-            else:
-                tail.insert(0, pdir)
-                return join(*tail)
-        else:
-            # Oops, got all the way through 'path' without finding a
-            # match in package_dir.  If package_dir defines a directory
-            # for the root (nameless) package, then fallback on it;
-            # otherwise, we might as well have not consulted
-            # package_dir at all, as we just use the directory implied
-            # by 'tail' (which should be the same as the original value
-            # of 'path' at this point).
-            pdir = package_dir.get('')
-            if pdir is not None:
-                tail.insert(0, pdir)
-
-            if tail:
-                return join(*tail)
-            else:
-                return ''
-
-
-def get_package_data(packages, exclude=('.py', '.pyc'), package_dir=None):
-    """
-    Parameters
-    ----------
-    packages : iterable of str
-        List of packages to collect files for
-    exclude : iterable of str
-        List of file extensions to exclude
-    package_dir : mapping of str -> str
-        Same as `package_dir` argument to `setup()`
-
-    Returns
-    -------
-    mapping of str -> list of str
-        `package_data` argument to `setup()`
-    """
-    dir_filter = lambda path: isfile(path)
-    exclude_set = set(exclude)  # convert to set for more efficient lookup time
-    exclude_filter = lambda path: splitext(path)[-1] not in exclude_set
-
-    package_data = defaultdict(list)
-    for package in packages:
-        package_path = get_package_dir(package, package_dir=package_dir)
-        for filename in filter(exclude_filter, filter(dir_filter, iglob(f"{package_path}/*"))):
-            rel_filename = relpath(filename, package_path)
-            package_data[package].append(rel_filename)
-
-    return package_data
-
-
-def get_data_files(prefix, paths):
-    """
-    Parameters
-    ----------
-    prefix : str
-        All other parameters and returned values are relative to this path prefix
-    paths : list of str
-        Paths relative to `prefix` containing data files
-
-    Returns
-    -------
-    sequence of (directory, files) pairs.
-        `data_files` argument to `setup()`
-    """
-    dir_filter = lambda path: isfile(path)
-    files = chain.from_iterable([filter(dir_filter, iglob(f"{prefix}/{path}/**/*", recursive=True)) for path in paths])
-    data_files = defaultdict(list)
-    for filename in files:
-        target_dir = relpath(dirname(filename), prefix)
-        data_files[target_dir].append(filename)
-
-    return list(data_files.items())
-
-
-def get_python_lib():
-    """
-    Returns absolute path to libpythonX.YZ.so on linux, libpythonX.Y.dylib on macOS or pythonXY.lib on windows.
-    """
-    if is_win:
-        version_string = f"{sys.version_info[0]}{sys.version_info[1]}"
-        python_lib = expandvars(
-            f"%LOCALAPPDATA%\\Programs\\Python\\Python{version_string}\\libs\\python{version_string}.lib"
-        )
-    elif is_darwin:
-        version_string = f"{sys.version_info[0]}.{sys.version_info[1]}"
-        python_lib = f"{sys.prefix}/lib/x86_64-linux-gnu/libpython{version_string}.dylib"
-    else:
-        version_string = f"{sys.version_info[0]}.{sys.version_info[1]}{sys.abiflags}"
-        python_lib = f"/usr/lib/x86_64-linux-gnu/libpython{version_string}.so.1.0"
-    assert exists(python_lib)
-    return python_lib
-
-
-def get_python_include_dir():
-    if is_win:
-        include_dir = f"{sys.prefix}\\include"
-    else:
-        version_string = f"{sys.version_info[0]}.{sys.version_info[1]}{sys.abiflags}"
-        include_dir = f"{sys.prefix}/include/python{version_string}"
-
-    assert exists(include_dir)
-    return include_dir
-
-
-def get_lib_dir(relative=False):
-    """
-    Returns path to lib dir.
-    """
-    if is_win:
-        lib = f"Lib"
-    else:
-        lib = f"lib/python{sys.version_info[0]}.{sys.version_info[1]}"
-
-    if not relative:
-        lib = join(sys.prefix, lib)
-        assert exists(lib)
-
-    return lib
-
-
-def get_site_packages_dir():
-    """
-    Returns absolute path to site packages dir.
-    """
-    site_packages = join(get_lib_dir(), "site-packages")
-
-    assert exists(site_packages)
-    return site_packages
-
-
-def get_vcvarsall():
-    path = expandvars(
-        "%PROGRAMFILES(x86)%\\Microsoft Visual Studio\\2017\\BuildTools\\VC\\Auxiliary\\Build\\vcvarsall.bat"
-    )
-    assert exists(path)
-    return path
-
-
-def get_include_dir():
-    """Returns the path to the Python environment's include dir."""
-    return get_paths()['include']
diff --git a/src/api.cpp b/src/api.cpp
new file mode 100644
index 0000000..e2ba99d
--- /dev/null
+++ b/src/api.cpp
@@ -0,0 +1,298 @@
+#include <stdexcept>
+
+#include <pybind11/pybind11.h>
+#include <pybind11/numpy.h>
+
+#include "opcode/Opcode.h"
+
+
+namespace py = pybind11;
+
+
+typedef py::array_t<float, py::array::c_style> float_array;
+typedef py::array_t<int32_t, py::array::c_style> int_array;
+
+
+/**
+ * \brief constructs a set of strides for a c-style array, given its shape and itemsize
+ */
+template <typename I>
+std::vector<I> c_strides(std::vector<I>& shape, I itemsize){
+    std::vector<I> strides;
+    auto ndim = shape.size();
+    strides.resize(ndim);
+    strides[ndim-1] = itemsize;
+    for (auto i=ndim; i>1; i--){
+        strides[i-2] = strides[i-1] * shape[i-1];
+    }
+    return strides;
+}
+
+/**
+ * \brief compute number of bytes taken by a contiguous array, given its shape and itemsize
+ */
+template <typename A>
+size_t nbytes(A& shape, size_t itemsize){
+    int n = itemsize;
+    for (auto& s : shape) {
+        n *= s;
+    }
+    return n;
+}
+
+/**
+ * \brief Returns a view on the input c-style pyarray, with a new shape and dtype
+ */
+template<typename To, typename Ti>
+py::array_t<To, py::array::c_style>
+reinterpret_pyarray(
+    py::array_t<Ti, py::array::c_style> arr,
+    std::vector<size_t> shape
+){
+    auto info = arr.request();
+
+    if (nbytes(info.shape, sizeof(Ti)) != nbytes(shape, sizeof(To)))
+        throw std::runtime_error("Impossible contiguous reinterpret cast");
+
+    return py::array(
+        shape,
+        c_strides(shape, sizeof(To)),
+        reinterpret_cast<To*>(info.ptr),
+        arr
+    );
+}
+
+
+/**
+Validate the shape of a pybind array
+throw an exception is shape assumptions are not satisfied
+
+Parameters:
+    arr: pybind array to check
+    shape: std::array; negative values denote unconstrained axes
+**/
+template<typename A, size_t N, typename S>
+void validate_shape(const A& arr, std::array<int32_t, N> shape, S message){
+    auto arr_shape = arr.shape();
+    if (shape.size() != arr.ndim()) throw std::runtime_error(message);
+    for (auto i=0; i<arr.ndim(); i++) {
+        if (shape[i] >= 0) {
+            if (shape[i] != arr_shape[i]) {
+                throw std::runtime_error(message);
+            }
+        }
+    }
+}
+
+
+/**
+ * \brief Returns py::array wrapping a sequence, with the lifetime of the array tied to the sequence
+ */
+template <typename Sequence>
+inline py::array_t<typename Sequence::value_type, py::array::c_style> as_pyarray(Sequence &&seq) {
+    auto size = seq.size();
+    auto data = seq.data();
+    std::unique_ptr<Sequence> seq_ptr = std::make_unique<Sequence>(std::move(seq));
+    auto capsule = py::capsule(seq_ptr.get(), [](void *p) { std::unique_ptr<Sequence>(reinterpret_cast<Sequence*>(p)); });
+    seq_ptr.release();
+    return py::array(size, data, capsule);
+}
+
+
+
+class MeshModel {
+
+    private:
+        const float_array vertices;
+        const int_array triangles;
+        const Opcode::MeshInterface interface;
+        const Opcode::Model model;
+
+    public:
+
+        explicit MeshModel(const float_array vertices, const int_array triangles) :
+            /*
+            parameters
+             vertices: py:array<float>32, shape [n_vertices, 3]
+                vertex coordinates
+             triangles: py:array<int32>, shape [n_triangles, 3]
+                each trinagle is a triplex of indices into the vertices array
+            */
+            vertices    (vertices),
+            triangles   (triangles),
+            interface   (init_interface()),
+            model       (init_model())
+        {
+        }
+
+        int_array ray_query(const float_array rays) const {
+            /*
+            parameters
+                rays: py:array<float32>[n_rays, 2, 3]
+                    axis-1 contains origin and direction vectors
+                    note that contiguity in all axes is important
+            returns:
+                faces: py::array<int32>[n_rays]
+                    first triangle index or -1 in case of no hit
+            */
+            validate_shape(
+                rays,
+                std::array<int, 3>{-1, 2, 3},
+                "Rays should have shape [*, 2, 3]"
+            );
+
+            auto ice_rays = reinterpret_cast<IceMaths::Ray *>(rays.request().ptr);
+            auto n_rays = rays.shape()[0];
+            std::vector<int32_t> faces;
+
+            {
+                py::gil_scoped_release release;
+
+                faces.reserve(n_rays);
+
+                Opcode::RayCollider RC = Opcode::RayCollider();
+                RC.SetFirstContact(false);
+                RC.SetTemporalCoherence(true);
+                RC.SetClosestHit(true);
+                RC.SetCulling(false);
+
+                static udword Cache;
+                Opcode::CollisionFaces CF;
+                RC.SetDestination(&CF);
+
+                // perform ray queries and push results to vector
+                for (auto i=0; i < n_rays; i++) {
+                    bool status(RC.Collide(ice_rays[i], model, 0, &Cache));
+                    faces.push_back(RC.GetNbIntersections() ? CF.GetFaces()[0].mFaceID : -1);
+                }
+            }
+
+            return as_pyarray(std::move(faces));
+        }
+
+
+
+    private:
+        Opcode::MeshInterface init_interface() const {
+
+            validate_shape(triangles, std::array<int, 2>{-1, 3}, "Triangles should have shape [*, 3]");
+            validate_shape(vertices, std::array<int, 2>{-1, 3}, "Vertices should have shape [*, 3]");
+
+            Opcode::MeshInterface interface;
+            interface.SetNbTriangles(triangles.shape()[0]);
+            interface.SetNbVertices(vertices.shape()[0]);
+            interface.SetPointers(
+                reinterpret_cast<IceMaths::IndexedTriangle *>(triangles.request().ptr),
+                reinterpret_cast<IceMaths::Point *>(vertices.request().ptr)
+            );
+            return interface;
+        }
+
+        Opcode::Model init_model() const {
+            py::gil_scoped_release release;
+
+            //Tree building settings
+            Opcode::OPCODECREATE OPCC;
+            OPCC.mIMesh = const_cast<Opcode::MeshInterface*>(&interface);
+            OPCC.mNoLeaf = true;
+            OPCC.mQuantized = false;
+            OPCC.mKeepOriginal = false;
+
+            Opcode::Model model;
+            model.Build(OPCC);
+            return model;
+        }
+
+    friend class MeshCollision;
+};
+
+
+class MeshCollision {
+
+private:
+    const MeshModel& mesh0;
+    const MeshModel& mesh1;
+
+    const Opcode::BVTCache cache;
+
+    Opcode::BVTCache init_cache() const {
+        Opcode::BVTCache cache;
+        cache.Model0 = &mesh0.model;
+        cache.Model1 = &mesh1.model;
+        return cache;
+    }
+
+public:
+    explicit MeshCollision(const MeshModel& mesh0, const MeshModel& mesh1) :
+        mesh0(mesh0),
+        mesh1(mesh1),
+        cache(init_cache())
+    {}
+
+    int_array query(const float_array affine0, const float_array affine1) const {
+        /*
+        parameters
+            affine0: py::array<float32>[4, 4]
+            affine1: py::array<float32>[4, 4]
+        returns
+            pairs: py::array<int32>[n_pairs, 2]
+                each pair of ints denotes a pair of indices into the triangle arrays of both meshes
+        */
+        validate_shape(affine0, std::array<int, 2>{4, 4}, "Affine should have shape [4, 4]");
+        validate_shape(affine1, std::array<int, 2>{4, 4}, "Affine should have shape [4, 4]");
+
+        auto affine0_ptr = reinterpret_cast<IceMaths::Matrix4x4*>(affine0.request().ptr);
+        auto affine1_ptr = reinterpret_cast<IceMaths::Matrix4x4*>(affine1.request().ptr);
+
+        // declare outside gil scope
+        std::vector<int32_t> pairs;
+        unsigned long n_pairs = 0;
+
+        // Collision query
+        Opcode::AABBTreeCollider TC;
+        {
+            py::gil_scoped_release release;
+
+            const bool IsOk(
+                TC.Collide(
+                    const_cast<Opcode::BVTCache&>(cache),       // const in this context, but not for TC
+                    affine0_ptr,
+                    affine1_ptr
+                )
+            );
+
+            // lets see how much hits we have
+            n_pairs = TC.GetContactStatus() ? TC.GetNbPairs() : 0;
+            const IceCore::Pair* ice_pairs = TC.GetContactStatus() ? TC.GetPairs() : (IceCore::Pair*)0;
+            auto foo = reinterpret_cast<const int32_t*>(ice_pairs);
+            // push pairs into vector.
+            // alternatively, make our capsule hang on to AABBTreeCollider?
+            // internal container type may have null pointer when empty; not sure how py::array feels about that
+            pairs.insert(pairs.end(), foo, foo + n_pairs * 2);
+        }
+
+        // return view with shape [n_pairs, 2]
+        // return reinterpret_pyarray<int32_t>(
+        //     as_pyarray(std::move(pairs)),
+        //     std::vector<size_t>{n_pairs, 2}
+        // );
+        auto foo = as_pyarray(std::move(pairs));
+        foo.resize(std::vector<size_t>{n_pairs, 2});
+        return foo;
+
+    }
+};
+
+
+
+PYBIND11_MODULE(pyopcode, m) {
+
+    py::class_<MeshModel>(m, "Model")
+        .def(py::init<float_array, int_array>())
+        .def("ray_query", &MeshModel::ray_query);
+
+    py::class_<MeshCollision>(m, "Collision")
+        .def(py::init<MeshModel&, MeshModel&>())
+        .def("query", &MeshCollision::query);
+
+}
diff --git a/vendor/opcode/OPC_RayAABBOverlap.h b/vendor/opcode/OPC_RayAABBOverlap.h
index 288f8cb..2622b05 100644
--- a/vendor/opcode/OPC_RayAABBOverlap.h
+++ b/vendor/opcode/OPC_RayAABBOverlap.h
@@ -37,7 +37,7 @@ inline_ BOOL RayCollider::SegmentAABBOverlap(const Point& center, const Point& e
 	float Dz = mData2.z - center.z;		if(fabsf(Dz) > extents.z + mFDir.z)	return FALSE;
 
 	//float f;
-	register float f = mData.y * Dz - mData.z * Dy;	if(fabsf(f) > extents.y*mFDir.z + extents.z*mFDir.y)	return FALSE;
+	float f = mData.y * Dz - mData.z * Dy;	if(fabsf(f) > extents.y*mFDir.z + extents.z*mFDir.y)	return FALSE;
 	f = mData.z * Dx - mData.x * Dz;	if(fabsf(f) > extents.x*mFDir.z + extents.z*mFDir.x)	return FALSE;
 	f = mData.x * Dy - mData.y * Dx;	if(fabsf(f) > extents.x*mFDir.y + extents.y*mFDir.x)	return FALSE;
 
@@ -80,7 +80,7 @@ inline_ BOOL RayCollider::SegmentAABBOverlap(const Point& center, const Point& e
 //	float Dz = mOrigin.z - center.z;	if(GREATER(Dz, extents.z) && ((SIR(Dz)-1)^SIR(mDir.z))>=0.0f)	return FALSE;
 
 	//float f;
-	register float f = mDir.y * Dz - mDir.z * Dy;		if(fabsf(f) > extents.y*mFDir.z + extents.z*mFDir.y)	return FALSE;
+	float f = mDir.y * Dz - mDir.z * Dy;		if(fabsf(f) > extents.y*mFDir.z + extents.z*mFDir.y)	return FALSE;
 	f = mDir.z * Dx - mDir.x * Dz;		if(fabsf(f) > extents.x*mFDir.z + extents.z*mFDir.x)	return FALSE;
 	f = mDir.x * Dy - mDir.y * Dx;		if(fabsf(f) > extents.x*mFDir.y + extents.y*mFDir.x)	return FALSE;
 
diff --git a/version.py b/version.py
deleted file mode 100644
index be26ea8..0000000
--- a/version.py
+++ /dev/null
@@ -1,16 +0,0 @@
-import re
-from os import path
-
-__version__ = False  # Prevent 'undefined name' warning.
-
-# Read file that should contain __version__ var, and find it. Though it should be the first line,
-# we scan the whole file and exec the version setter to have it available in our context, instead
-# of more fragile string parsing and regexing.
-with open(path.join(path.dirname(path.abspath(__file__)), 'pyopcode', '__init__.py'), 'r') as f:
-    for line in f:
-        if re.search('^__version__ =', line):
-            exec(line)
-            break
-
-
-print(__version__)


Test patch:
diff --git a/tests/test_pyopcode.py b/tests/test_pyopcode.py
index bb1633d..7c5264f 100644
--- a/tests/test_pyopcode.py
+++ b/tests/test_pyopcode.py
@@ -22,30 +22,32 @@ def test_invalid():
     vertices = np.random.rand(10, 3)
     triangles = np.arange(10 * 3).reshape(10, 3).astype(np.int32)
 
-    with pytest.raises(ValueError):
+    with pytest.raises(Exception):
         pyopcode.Model(vertices, triangles)
 
     vertices = np.random.rand(10, 2).astype(np.float32)
-    with pytest.raises(ValueError):
-        pyopcode.Model(vertices, triangles)
-
-    vertices = np.asfortranarray(np.random.rand(10, 3).astype(np.float32))
-    with pytest.raises(ValueError):
+    with pytest.raises(Exception):
         pyopcode.Model(vertices, triangles)
 
 
 def test_manual():
-    vertices_a = np.array([
-        [0, 0, 0],
-        [1, 0, 0],
-        [0, 1, 0],
-        [1, 1, 0],
-    ], dtype='f4')
-    triangles_a = np.array([
-        [0, 1, 2],
-        [3, 2, 1],
-    ], dtype='i4')
-    vertices_b = (vertices_a.copy() + [0.6, 0.6, 0]).astype('f4')
+    vertices_a = np.array(
+        [
+            [0, 0, 0],
+            [1, 0, 0],
+            [0, 1, 0],
+            [1, 1, 0],
+        ],
+        dtype="f4",
+    )
+    triangles_a = np.array(
+        [
+            [0, 1, 2],
+            [3, 2, 1],
+        ],
+        dtype="i4",
+    )
+    vertices_b = (vertices_a.copy() + [0.6, 0.6, 0]).astype("f4")
     triangles_b = triangles_a.copy()
     mesh_a = pyopcode.Model(vertices_a, triangles_a)
     mesh_b = pyopcode.Model(vertices_b, triangles_b)
@@ -53,9 +55,15 @@ def test_manual():
     transform = np.identity(4, dtype=np.float32)
 
     idx = collider.query(transform, transform)
-    npt.assert_array_equal(idx, np.array([
-        [1, 0],
-    ], dtype='i4'))
+    npt.assert_array_equal(
+        idx,
+        np.array(
+            [
+                [1, 0],
+            ],
+            dtype="i4",
+        ),
+    )
 
 
 def test_triangle_intersection():
@@ -73,7 +81,9 @@ def test_triangle_intersection():
 
 def test_no_triangle_intersection():
     vertices_a, triangles_a = triangle_soup(10)
-    vertices_b, triangles_b = triangle_soup(10, range=(40, 41))  # apply insane offset to avoid any intersection
+    vertices_b, triangles_b = triangle_soup(
+        10, range=(40, 41)
+    )  # apply insane offset to avoid any intersection
     mesh_a = pyopcode.Model(vertices_a, triangles_a)
     mesh_b = pyopcode.Model(vertices_b, triangles_b)
 
@@ -119,20 +129,24 @@ def transform_generator():
             yield a
 
     from multiprocessing.pool import ThreadPool
+
     pool = ThreadPool(processes=4)
-    results = pool.imap_unordered(lambda affine: col.query(affine, identity), transform_generator())
+    results = pool.imap_unordered(
+        lambda affine: col.query(affine, identity), transform_generator()
+    )
 
     import time
-    start = time.clock()
+
+    start = time.time()
     for r in results:
         print(len(r))
-    print(time.clock() - start)
+    print(time.time() - start)
 
 
 def test_rays():
     vertices, triangles = triangle_soup(10)
     mesh = pyopcode.Model(vertices, triangles)
 
-    rays = np.random.normal(0, 1, (100, 2, 3)).reshape(-1, 6).astype(np.float32)
+    rays = np.random.normal(0, 1, (100, 2, 3)).astype(np.float32)
     faces = mesh.ray_query(rays)
     print(faces)