Repository: RockRobotic/copc-lib (language: python)

Issue:
Use scikit-build for Python extension?
The scikit-build library has great support for running CMake. Additionally, it plays nice(er) with modern Python packaging like configuration pyproject.toml, etc.

See PDAL's Python bindings for a scikit-build configuration that mixes CMake and Python https://github.com/PDAL/python


Discussion / hints:
Good point. I'll be working on this when I add pip/conda wheels (waiting on a new laz-perf release to start that process, as we'll be updating to conform to the v1 specs in #51)

Fix patch:
diff --git a/.github/workflows/pip.yaml b/.github/workflows/pip.yaml
new file mode 100644
index 00000000..265523e9
--- /dev/null
+++ b/.github/workflows/pip.yaml
@@ -0,0 +1,94 @@
+name: Wheels
+
+on:
+  workflow_dispatch:
+  release:
+    types:
+      - published
+
+env:
+  CIBW_TEST_COMMAND: pytest {project}/test
+  CIBW_TEST_EXTRAS: test
+  CIBW_SKIP: "cp310-win* *_i686" # just exclude platforms that error :))
+
+jobs:
+  build_sdist:
+    name: Build SDist
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/checkout@v2
+      with:
+        submodules: true
+
+    - uses: actions/checkout@v2
+      with:
+        repository: hobu/laz-perf
+        ref: 3.0.0
+        path: libs/laz-perf
+
+    - name: Build SDist
+      run: pipx run build --sdist
+
+    - name: Check metadata
+      run: pipx run twine check dist/*
+
+    - uses: actions/upload-artifact@v2
+      with:
+        path: dist/*.tar.gz
+
+
+  build_wheels:
+    name: Wheels on ${{ matrix.os }}
+    runs-on: ${{ matrix.os }}
+    strategy:
+      fail-fast: false
+      matrix:
+        os: [ubuntu-latest, macos-latest, windows-latest]
+
+    steps:
+    - uses: actions/checkout@v2
+      with:
+        submodules: true
+
+    - uses: actions/checkout@v2
+      with:
+        repository: hobu/laz-perf
+        ref: 3.0.0
+        path: libs/laz-perf
+
+    - uses: suisei-cn/actions-download-file@v1
+      name: Download autzen
+      with:
+        url: "https://github.com/PDAL/data/raw/a3d2a351ca1002c7ea4daa96b5c5fcb0fafeaa6f/autzen/autzen-classified.copc.laz"
+        target: test/data/
+
+    - uses: pypa/cibuildwheel@v2.3.1
+
+    - name: Verify clean directory
+      run: git diff --exit-code
+      shell: bash
+
+    - name: Upload wheels
+      uses: actions/upload-artifact@v2
+      with:
+        path: wheelhouse/*.whl
+
+
+  upload_all:
+    name: Upload if release
+    needs: [build_wheels, build_sdist]
+    runs-on: ubuntu-latest
+    #if: github.event_name == 'release' && github.event.action == 'published'
+
+    steps:
+    - uses: actions/setup-python@v2
+
+    - 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_password }}
diff --git a/.gitignore b/.gitignore
index 5a92e259..d9db3d51 100644
--- a/.gitignore
+++ b/.gitignore
@@ -62,3 +62,5 @@ share/python-wheels/
 MANIFEST
 
 /CMakeSettings.json
+
+/_skbuild/
diff --git a/.gitmodules b/.gitmodules
deleted file mode 100644
index cd28315c..00000000
--- a/.gitmodules
+++ /dev/null
@@ -1,7 +0,0 @@
-[submodule "lib/Catch2"]
-	url = git@github.com:catchorg/Catch2.git
-	path = lib/Catch2
-[submodule "lib/pybind11"]
-	path = lib/pybind11
-	url = git@github.com:pybind/pybind11.git
-	branch = stable
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index d222d38a..9bf3d4ab 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,3 +1,5 @@
+exclude: version
+
 repos:
 -   repo: https://github.com/pre-commit/pre-commit-hooks
     rev: v4.1.0  # Use the ref you want to point at
diff --git a/.release-it.json b/.release-it.json
index 24dbe125..425d3781 100644
--- a/.release-it.json
+++ b/.release-it.json
@@ -9,7 +9,7 @@
   },
   "plugins": {
     "@release-it/bumper": {
-      "out": [ "setup.py", "CMakeLists.txt" ]
+      "out": [ "version" ]
     },
     "@release-it/keep-a-changelog": {
       "filename": "CHANGELOG.md",
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 9c2ab98b..d43ed46c 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -1,9 +1,10 @@
 cmake_minimum_required(VERSION 3.15)
 
 # ref https://github.com/robotology/how-to-export-cpp-library/blob/master/CMakeLists.txt
+file(READ "version" ver)
 project(COPCLIB
         LANGUAGES CXX C
-        VERSION 2.2.0)
+        VERSION ${ver})
 
 # Defines the CMAKE_INSTALL_LIBDIR, CMAKE_INSTALL_BINDIR and many other useful macros.
 # See https://cmake.org/cmake/help/latest/module/GNUInstallDirs.html
@@ -15,9 +16,8 @@ option(BUILD_SHARED_LIBS "Build libraries as shared as opposed to static" ON)
 
 option(WITH_TESTS "Build test and example files." OFF)
 option(WITH_PYTHON "Build python bindings." OFF)
-option(ONLY_PYTHON "Build only python bindings, for setup.py env" OFF)
 
-if (ONLY_PYTHON)
+if (SKBUILD)
     set(WITH_PYTHON ON)
     set(WITH_TESTS OFF)
     set(BUILD_SHARED_LIBS ON)
@@ -51,6 +51,9 @@ endif()
 # See https://blog.kitware.com/create-dlls-on-windows-without-declspec-using-new-cmake-export-all-feature/
 set(CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS ON)
 
+# fix for static libraries
+set(CMAKE_POSITION_INDEPENDENT_CODE ON)
+
 # Under MSVC, we set CMAKE_DEBUG_POSTFIX to "d" to add a trailing "d" to library
 # built in debug mode. In this Windows user can compile, build and install the
 # library in both Release and Debug configuration avoiding naming clashes in the
@@ -80,10 +83,26 @@ if(WITH_TESTS)
     enable_testing()
 endif()
 
-# Find required packages
-# LAZPERF
+## Find required packages
+
 set(LAZPERF_VERSION "3.0.0")
-find_package(LAZPERF ${LAZPERF_VERSION} REQUIRED)
+# First, see if the repo is cloned locally, and if so, compile it
+# this is required for CI build of sdist
+if(EXISTS "${CMAKE_SOURCE_DIR}/libs/laz-perf")
+    set(WITH_TEST_TEMP ${WITH_TESTS})
+    set(WITH_TESTS OFF) # never build lazperf tests
+    add_subdirectory(libs/laz-perf)
+    include_directories(libs/laz-perf/cpp)
+    set(WITH_TESTS ${WITH_TEST_TEMP})
+    set(LAZPERF_LIB_NAME "lazperf")
+    set(LAZPERF_IS_FOUND ON)
+    set(EXTRA_EXPORT_TARGETS "lazperf")
+else()
+    # if not, assume lazperf is installed on the system
+    find_package(LAZPERF ${LAZPERF_VERSION} REQUIRED)
+    set(LAZPERF_LIB_NAME "LAZPERF::lazperf")
+    set(EXTRA_EXPORT_TARGETS "")
+endif()
 
 # Enable RPATH support for installed binaries and libraries
 include(AddInstallRPATHSupport)
@@ -101,6 +120,7 @@ if(NOT CMAKE_CONFIGURATION_TYPES)
 endif()
 
 ### Compile- and install-related commands.
+
 add_subdirectory(cpp)
 
 # Create and install CMake configuration files for your project that are
@@ -120,13 +140,15 @@ add_subdirectory(cpp)
 # Note that if your library depends from other libraries, you are probably
 # required to used the install_basic_package_files() DEPENDENCIES option.
 include(InstallBasicPackageFiles)
-install_basic_package_files(${PROJECT_NAME}
-                            VERSION ${${PROJECT_NAME}_VERSION}
-                            COMPATIBILITY AnyNewerVersion
-                            VARS_PREFIX ${PROJECT_NAME}
-                            DEPENDENCIES "LAZPERF ${LAZPERF_VERSION} REQUIRED"
-                            FIRST_TARGET copc-lib
-                            NO_CHECK_REQUIRED_COMPONENTS_MACRO)
+if (NOT SKBUILD)
+    install_basic_package_files(${PROJECT_NAME}
+                                VERSION ${${PROJECT_NAME}_VERSION}
+                                COMPATIBILITY AnyNewerVersion
+                                VARS_PREFIX ${PROJECT_NAME}
+                                DEPENDENCIES "LAZPERF ${LAZPERF_VERSION} REQUIRED"
+                                FIRST_TARGET copc-lib
+                                NO_CHECK_REQUIRED_COMPONENTS_MACRO)
+endif()
 # Add the uninstall target
 include(AddUninstallTarget)
 
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 00000000..6df5f440
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,11 @@
+include CMakeLists.txt cpp/CMakeLists.txt python/CMakeLists.txt
+recursive-include cmake *.cmake
+recursive-include cpp *.cpp *.hpp
+recursive-include python *.cpp *.py
+include README.md CHANGELOG.md LICENSE.md setup.py version pyproject.toml
+
+include libs/laz-perf/CMakeLists.txt libs/laz-perf/cpp/CMakeLists.txt
+recursive-include libs/laz-perf/cpp/lazperf *.cpp *.hpp
+recursive-include libs/laz-perf/cmake *.cmake
+recursive-include libs/laz-perf/cpp/benchmarks *.*
+recursive-include libs/laz-perf/cpp/tools *.*
diff --git a/cmake/DownloadExampleFiles.cmake b/cmake/DownloadExampleFiles.cmake
index 79116b11..d46dd19f 100644
--- a/cmake/DownloadExampleFiles.cmake
+++ b/cmake/DownloadExampleFiles.cmake
@@ -1,5 +1,4 @@
 macro(download_test_files OUT_PATH)
-
 	if(NOT EXISTS ${OUT_PATH})
 		if (EXISTS ${BaseAutzenFilePath})
 			file(WRITE ${OUT_PATH}) # this will create any necessary pathes, as CREATE_LINK doens't
@@ -9,7 +8,7 @@ macro(download_test_files OUT_PATH)
 			file (DOWNLOAD
 				"https://github.com/PDAL/data/raw/a3d2a351ca1002c7ea4daa96b5c5fcb0fafeaa6f/autzen/autzen-classified.copc.laz"
 				${OUT_PATH}
-			)
+			) # be sure to update this path in .github/workflows/pip.yaml as well!
 
 			set(BaseAutzenFilePath "${OUT_PATH}" PARENT_SCOPE)
 		endif()
diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt
index 1c69e9fc..5605991f 100644
--- a/cpp/CMakeLists.txt
+++ b/cpp/CMakeLists.txt
@@ -45,29 +45,44 @@ set(${LIBRARY_TARGET_NAME}_SRC
         src/las/vlr.cpp
 )
 
-add_library(${LIBRARY_TARGET_NAME} ${${LIBRARY_TARGET_NAME}_SRC} ${${LIBRARY_TARGET_NAME}_HDR})
-add_library(${PROJECT_NAME}::${LIBRARY_TARGET_NAME} ALIAS ${LIBRARY_TARGET_NAME})
+# Compile static library for pip wheels
+if (WITH_PYTHON)
+    add_library(${LIBRARY_TARGET_NAME}-s STATIC ${${LIBRARY_TARGET_NAME}_SRC} ${${LIBRARY_TARGET_NAME}_HDR})
+    set_target_properties(${LIBRARY_TARGET_NAME}-s PROPERTIES VERSION       ${${PROJECT_NAME}_VERSION})
+    target_include_directories(${LIBRARY_TARGET_NAME}-s PUBLIC "$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>"
+                                                         "$<INSTALL_INTERFACE:$<INSTALL_PREFIX>/${CMAKE_INSTALL_INCLUDEDIR}>")
+    if (LAZPERF_IS_FOUND)
+        target_link_libraries(${LIBRARY_TARGET_NAME}-s PRIVATE lazperf_s)
+    else()
+        target_link_libraries(${LIBRARY_TARGET_NAME}-s PRIVATE ${LAZPERF_LIB_NAME})
+    endif()
+    message(STATUS "Created target ${LIBRARY_TARGET_NAME}-s for export ${PROJECT_NAME}.")
+endif()
 
-set_target_properties(${LIBRARY_TARGET_NAME} PROPERTIES VERSION       ${${PROJECT_NAME}_VERSION})
-                                                        #PUBLIC_HEADER "${${LIBRARY_TARGET_NAME}_HDR}")
+if (NOT SKBUILD)
+    add_library(${LIBRARY_TARGET_NAME} ${${LIBRARY_TARGET_NAME}_SRC} ${${LIBRARY_TARGET_NAME}_HDR})
+    add_library(${PROJECT_NAME}::${LIBRARY_TARGET_NAME} ALIAS ${LIBRARY_TARGET_NAME})
 
-# Specify include directories for both compilation and installation process.
-# The $<INSTALL_PREFIX> generator expression is useful to ensure to create
-# relocatable configuration files, see https://cmake.org/cmake/help/latest/manual/cmake-packages.7.html#creating-relocatable-packages
-target_include_directories(${LIBRARY_TARGET_NAME} PUBLIC "$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>"
-                                                         "$<INSTALL_INTERFACE:$<INSTALL_PREFIX>/${CMAKE_INSTALL_INCLUDEDIR}>")
+    set_target_properties(${LIBRARY_TARGET_NAME} PROPERTIES VERSION       ${${PROJECT_NAME}_VERSION})
+
+    # Specify include directories for both compilation and installation process.
+    # The $<INSTALL_PREFIX> generator expression is useful to ensure to create
+    # relocatable configuration files, see https://cmake.org/cmake/help/latest/manual/cmake-packages.7.html#creating-relocatable-packages
+    target_include_directories(${LIBRARY_TARGET_NAME} PUBLIC "$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>"
+                                                                "$<INSTALL_INTERFACE:$<INSTALL_PREFIX>/${CMAKE_INSTALL_INCLUDEDIR}>")
 
-target_link_libraries(${LIBRARY_TARGET_NAME} PUBLIC LAZPERF::lazperf)
+    target_link_libraries(${LIBRARY_TARGET_NAME} PUBLIC ${LAZPERF_LIB_NAME})
 
-# Specify installation targets, typology and destination folders.
-install(TARGETS ${LIBRARY_TARGET_NAME}
-        EXPORT  ${PROJECT_NAME}
-        LIBRARY       DESTINATION "${CMAKE_INSTALL_LIBDIR}"                            COMPONENT shlib
-        ARCHIVE       DESTINATION "${CMAKE_INSTALL_LIBDIR}"                            COMPONENT lib
-        RUNTIME       DESTINATION "${CMAKE_INSTALL_BINDIR}"                            COMPONENT bin)
-        # PUBLIC_HEADER DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}/${LIBRARY_TARGET_NAME}" COMPONENT dev)
+    # Specify installation targets, typology and destination folders.
+    install(TARGETS ${LIBRARY_TARGET_NAME} ${EXTRA_EXPORT_TARGETS}
+            EXPORT  ${PROJECT_NAME}
+            LIBRARY       DESTINATION "${CMAKE_INSTALL_LIBDIR}"                            COMPONENT shlib
+            ARCHIVE       DESTINATION "${CMAKE_INSTALL_LIBDIR}"                            COMPONENT lib
+            RUNTIME       DESTINATION "${CMAKE_INSTALL_BINDIR}"                            COMPONENT bin)
+            # PUBLIC_HEADER DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}/${LIBRARY_TARGET_NAME}" COMPONENT dev)
 
-# Keep header file hierarchy
-install(DIRECTORY "include/${LIBRARY_TARGET_NAME}" DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}")
+    # Keep header file hierarchy
+    install(DIRECTORY "include/${LIBRARY_TARGET_NAME}" DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}")
 
-message(STATUS "Created target ${LIBRARY_TARGET_NAME} for export ${PROJECT_NAME}.")
+    message(STATUS "Created target ${LIBRARY_TARGET_NAME} for export ${PROJECT_NAME}.")
+endif()
diff --git a/cpp/include/copc-lib/geometry/vector3.hpp b/cpp/include/copc-lib/geometry/vector3.hpp
index e7137a0d..e61a6c5f 100644
--- a/cpp/include/copc-lib/geometry/vector3.hpp
+++ b/cpp/include/copc-lib/geometry/vector3.hpp
@@ -1,6 +1,7 @@
 #ifndef COPCLIB_GEOMETRY_VECTOR3_H_
 #define COPCLIB_GEOMETRY_VECTOR3_H_
 
+#include <limits>
 #include <sstream>
 #include <vector>
 
diff --git a/example/__init__.py b/example/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/example/example-reader.py b/example/example_reader.py
similarity index 93%
rename from example/example-reader.py
rename to example/example_reader.py
index 506a8657..8b643630 100644
--- a/example/example-reader.py
+++ b/example/example_reader.py
@@ -1,9 +1,12 @@
 import copclib as copc
+import os
+
+DATADIRECTORY = os.path.join(os.path.dirname(__file__), "..", "test", "data")
 
 
 def reader_example():
     # Create a reader object
-    reader = copc.FileReader("autzen-classified.copc.laz")
+    reader = copc.FileReader(os.path.join(DATADIRECTORY, "autzen-classified.copc.laz"))
 
     # Get the Las Header
     las_header = reader.copc_config.las_header
diff --git a/example/example-writer.py b/example/example_writer.py
similarity index 88%
rename from example/example-writer.py
rename to example/example_writer.py
index 2fa738a4..6df86f06 100644
--- a/example/example-writer.py
+++ b/example/example_writer.py
@@ -1,20 +1,24 @@
 import random
 import copclib as copc
 import math
+import os
 
 random.seed(0)
+DATADIRECTORY = os.path.join(os.path.dirname(__file__), "..", "test", "data")
 
 # In this example, we'll filter the autzen dataset to only contain depth levels 0-3.
 def TrimFileExample(compressor_example_flag):
 
     # We'll get our point data from this file
-    reader = copc.FileReader("autzen-classified.copc.laz")
+    reader = copc.FileReader(os.path.join(DATADIRECTORY, "autzen-classified.copc.laz"))
 
     # Copy the header to the new file
     cfg = reader.copc_config
 
     # Now, we can create our actual writer:
-    writer = copc.FileWriter("autzen-trimmed.copc.laz", cfg)
+    writer = copc.FileWriter(
+        os.path.join(DATADIRECTORY, "out", "autzen-trimmed.copc.laz"), cfg
+    )
 
     # GetAllNodes will load the entire hierarchy under a given key
     for node in reader.GetAllNodes():
@@ -46,7 +50,9 @@ def TrimFileExample(compressor_example_flag):
     writer.Close()
 
     # Now, let's test our new file
-    new_reader = copc.FileReader("autzen-trimmed.copc.laz")
+    new_reader = copc.FileReader(
+        os.path.join(DATADIRECTORY, "out", "autzen-trimmed.copc.laz")
+    )
 
     # Let's go through each node we've written and make sure it matches the original
     for node in new_reader.GetAllNodes():
@@ -68,7 +74,7 @@ def TrimFileExample(compressor_example_flag):
 # In this example, we'll filter the points in the autzen dataset based on bounds.
 def BoundsTrimFileExample():
     # We'll get our point data from this file
-    reader = copc.FileReader("autzen-classified.copc.laz")
+    reader = copc.FileReader(os.path.join(DATADIRECTORY, "autzen-classified.copc.laz"))
     old_header = reader.copc_config.las_header
 
     middle = (old_header.max + old_header.min) / 2
@@ -78,7 +84,11 @@ def BoundsTrimFileExample():
     cfg = reader.copc_config
 
     # Now, we can create our actual writer, here we will update the Point Format ID in the new file to be 8
-    writer = copc.FileWriter("autzen-bounds-trimmed.copc.laz", cfg, point_format_id=8)
+    writer = copc.FileWriter(
+        os.path.join(DATADIRECTORY, "out", "autzen-bounds-trimmed.copc.laz"),
+        cfg,
+        point_format_id=8,
+    )
 
     for node in reader.GetAllNodes():
         if node.key.Within(old_header, box):
@@ -101,7 +111,9 @@ def BoundsTrimFileExample():
     writer.Close()
 
     # Now, let's test our new file
-    new_reader = copc.FileReader("autzen-bounds-trimmed.copc.laz")
+    new_reader = copc.FileReader(
+        os.path.join(DATADIRECTORY, "out", "autzen-bounds-trimmed.copc.laz")
+    )
 
     # Let's go through each point and make sure they fit in the within the Box
     for node in new_reader.GetAllNodes():
@@ -112,7 +124,7 @@ def BoundsTrimFileExample():
 # In this example, we'll filter the points in the autzen dataset based on resolution.
 def ResolutionTrimFileExample():
     # We'll get our point data from this file
-    reader = copc.FileReader("autzen-classified.copc.laz")
+    reader = copc.FileReader(os.path.join(DATADIRECTORY, "autzen-classified.copc.laz"))
     old_header = reader.copc_config.las_header
 
     resolution = 10
@@ -129,7 +141,9 @@ def ResolutionTrimFileExample():
     cfg = reader.copc_config
 
     # Now, we can create our actual writer:
-    writer = copc.FileWriter("autzen-resolution-trimmed.copc.laz", cfg)
+    writer = copc.FileWriter(
+        os.path.join(DATADIRECTORY, "out", "autzen-resolution-trimmed.copc.laz"), cfg
+    )
 
     for node in reader.GetAllNodes():
         if node.key.d <= target_depth:
@@ -144,7 +158,9 @@ def ResolutionTrimFileExample():
     writer.Close()
 
     # Now, let's test our new file
-    new_reader = copc.FileReader("autzen-resolution-trimmed.copc.laz")
+    new_reader = copc.FileReader(
+        os.path.join(DATADIRECTORY, "out", "autzen-resolution-trimmed.copc.laz")
+    )
 
     new_header = new_reader.copc_config.las_header
     new_copc_info = new_reader.copc_config.copc_info
@@ -235,7 +251,9 @@ def NewFileExample():
     cfg.copc_info.spacing = 10
 
     # Now, we can create our COPC writer:
-    writer = copc.FileWriter("new-copc.copc.laz", cfg)
+    writer = copc.FileWriter(
+        os.path.join(DATADIRECTORY, "out", "new-copc.copc.laz"), cfg
+    )
     # writer = copc.FileWriter("new-copc.copc.laz", cfg,None,None,None,None,None)
     # writer = copc.FileWriter("new-copc.copc.laz", cfg,(1,1,1),(1,1,1),"test",)
     header = writer.copc_config.las_header
@@ -280,7 +298,7 @@ def NewFileExample():
     writer.Close()
 
     # We can check that the spatial bounds of the file have been respected
-    reader = copc.FileReader("new-copc.copc.laz")
+    reader = copc.FileReader(os.path.join(DATADIRECTORY, "out", "new-copc.copc.laz"))
     assert reader.ValidateSpatialBounds()
 
     # We can get the keys of all existing pages
diff --git a/libs/.gitignore b/libs/.gitignore
new file mode 100644
index 00000000..dc3de036
--- /dev/null
+++ b/libs/.gitignore
@@ -0,0 +1,1 @@
+laz-perf/
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..827aae60
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,13 @@
+[build-system]
+requires = [
+    "setuptools>=42",
+    "wheel",
+    "pybind11>=2.8.0",
+    "cmake>=3.21",
+    "scikit-build>=0.12",
+]
+
+build-backend = "setuptools.build_meta"
+
+[tool.isort]
+profile = "black"
diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt
index 78df5059..1bdf7d84 100644
--- a/python/CMakeLists.txt
+++ b/python/CMakeLists.txt
@@ -1,36 +1,45 @@
+if(SKBUILD)
+  # Scikit-Build does not add your site-packages to the search path
+  # automatically, so we need to add it _or_ the pybind11 specific directory
+  # here.
+  execute_process(
+    COMMAND "${PYTHON_EXECUTABLE}" -c
+            "import pybind11; print(pybind11.get_cmake_dir())"
+    OUTPUT_VARIABLE _tmp_dir
+    OUTPUT_STRIP_TRAILING_WHITESPACE COMMAND_ECHO STDOUT)
+  list(APPEND CMAKE_PREFIX_PATH "${_tmp_dir}")
+endif()
+
 list(APPEND CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR}/../cmake)
-include(BuildRequires)
+#include(BuildRequires)
 
-set(COPC_PYTHON_LIB copclib)
+set(COPC_PYTHON_LIB _core)
 find_package(pybind11 CONFIG REQUIRED)
 
 # add the python lib
 pybind11_add_module(${COPC_PYTHON_LIB} bindings.cpp)
-target_link_libraries(${COPC_PYTHON_LIB} PRIVATE COPCLIB::copc-lib)
+if (LAZPERF_IS_FOUND)
+    target_link_libraries(${COPC_PYTHON_LIB} PRIVATE lazperf_s copc-lib-s)
+else()
+    target_link_libraries(${COPC_PYTHON_LIB} PRIVATE ${LAZPERF_LIB_NAME} copc-lib-s)
+endif()
 
-if(WITH_TESTS)
-    set_target_properties(${COPC_PYTHON_LIB} PROPERTIES
-                                                        # have the python lib output to the python build dir
-                                                        # not the /lib dir
-                                                        RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}
-                                                        LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})
+install(TARGETS _core DESTINATION .)
 
-    include(DownloadExampleFiles)
-    download_test_files("${CMAKE_CURRENT_BINARY_DIR}/autzen-classified.copc.laz")
+file(COPY ${CMAKE_CURRENT_SOURCE_DIR}/copclib DESTINATION ${CMAKE_CURRENT_BINARY_DIR})
 
-    # PY_SOURCE_DIR is the folder we'll copy the python scripts into
-    set(PY_SOURCE_DIR ${CMAKE_CURRENT_BINARY_DIR})
+set_target_properties(${COPC_PYTHON_LIB} PROPERTIES
+                                                    # have the python lib output to the python build dir
+                                                    # not the /lib dir
+                                                    RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/copclib
+                                                    LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/copclib)
 
-    # gather all the python scripts
-    file(GLOB COPC_PYTHON_FILES
-            ${CMAKE_SOURCE_DIR}/test/*.py
-            ${CMAKE_SOURCE_DIR}/example/*.py
-    )
+if(WITH_TESTS)
+    include(DownloadExampleFiles)
+    download_test_files("${CMAKE_CURRENT_BINARY_DIR}/data/autzen-classified.copc.laz")
 
-    # copy all the python scripts into the build folder
-    foreach(filename ${COPC_PYTHON_FILES})
-        configure_file(${filename} ${PY_SOURCE_DIR} COPYONLY)
-    endforeach(filename)
+    # PY_SOURCE_DIR is the folder we'll copy the python scripts into
+    set(PY_SOURCE_DIR ${CMAKE_SOURCE_DIR}/test)
 
     # add the tests
     add_test(NAME python-tests
@@ -38,11 +47,11 @@ if(WITH_TESTS)
              WORKING_DIRECTORY ${PY_SOURCE_DIR}
             )
     add_test(NAME example_reader_py
-             COMMAND ${PYTHON_EXECUTABLE} "${PY_SOURCE_DIR}/example-reader.py"
+             COMMAND ${PYTHON_EXECUTABLE} "${PY_SOURCE_DIR}/example_reader.py"
              WORKING_DIRECTORY ${PY_SOURCE_DIR}
             )
     add_test(NAME example_writer_py
-             COMMAND ${PYTHON_EXECUTABLE} "${PY_SOURCE_DIR}/example-writer.py"
+             COMMAND ${PYTHON_EXECUTABLE} "${PY_SOURCE_DIR}/example_writer.py"
              WORKING_DIRECTORY ${PY_SOURCE_DIR}
             )
 
diff --git a/python/bindings.cpp b/python/bindings.cpp
index 0189c3ba..8ec5ec73 100644
--- a/python/bindings.cpp
+++ b/python/bindings.cpp
@@ -28,7 +28,7 @@ using namespace copc;
 
 PYBIND11_MAKE_OPAQUE(std::vector<char>);
 
-PYBIND11_MODULE(copclib, m)
+PYBIND11_MODULE(_core, m)
 {
     py::bind_vector<std::vector<char>>(m, "VectorChar", py::buffer_protocol())
         .def(py::pickle(
diff --git a/python/copclib/__init__.py b/python/copclib/__init__.py
new file mode 100644
index 00000000..6eb86461
--- /dev/null
+++ b/python/copclib/__init__.py
@@ -0,0 +1,1 @@
+from ._core import *
diff --git a/setup.py b/setup.py
index 648d1800..9d777395 100644
--- a/setup.py
+++ b/setup.py
@@ -1,131 +1,27 @@
-# -*- coding: utf-8 -*-
-import os
-import re
-import subprocess
-import sys
+from skbuild import setup
 
-from setuptools import setup, Extension
-from setuptools.command.build_ext import build_ext
+# Get the version from the pdal module
+with open("version", "r") as file:
+    lines = file.readlines()
+    version = lines[0]
 
-# Convert distutils Windows platform specifiers to CMake -A arguments
-PLAT_TO_CMAKE = {
-    "win32": "Win32",
-    "win-amd64": "x64",
-    "win-arm32": "ARM",
-    "win-arm64": "ARM64",
-}
+from setuptools import find_packages
 
-
-# A CMakeExtension needs a sourcedir instead of a file list.
-# The name must be the _single_ output extension from the CMake build.
-# If you need multiple extensions, see scikit-build.
-class CMakeExtension(Extension):
-    def __init__(self, name, sourcedir=""):
-        Extension.__init__(self, name, sources=[])
-        self.sourcedir = os.path.abspath(sourcedir)
-
-
-class CMakeBuild(build_ext):
-    def build_extension(self, ext):
-        extdir = os.path.abspath(os.path.dirname(self.get_ext_fullpath(ext.name)))
-
-        # required for auto-detection & inclusion of auxiliary "native" libs
-        if not extdir.endswith(os.path.sep):
-            extdir += os.path.sep
-
-        debug = int(os.environ.get("DEBUG", 0)) if self.debug is None else self.debug
-        cfg = "Debug" if debug else "Release"
-
-        # CMake lets you override the generator - we need to check this.
-        # Can be set with Conda-Build, for example.
-        cmake_generator = os.environ.get("CMAKE_GENERATOR", "")
-
-        # Set Python_EXECUTABLE instead if you use PYBIND11_FINDPYTHON
-        # EXAMPLE_VERSION_INFO shows you how to pass a value into the C++ code
-        # from Python.
-        cmake_args = [
-            "-DCMAKE_LIBRARY_OUTPUT_DIRECTORY={}".format(extdir),
-            "-DPYTHON_EXECUTABLE={}".format(sys.executable),
-            "-DCMAKE_BUILD_TYPE={}".format(cfg),  # not used on MSVC, but no harm
-            "-DONLY_PYTHON=ON",  # IMPORTANT pass this to cmakelists so that it doesn't override output path
-        ]
-        build_args = []
-        # Adding CMake arguments set as environment variable
-        # (needed e.g. to build for ARM OSx on conda-forge)
-        if "CMAKE_ARGS" in os.environ:
-            cmake_args += [item for item in os.environ["CMAKE_ARGS"].split(" ") if item]
-
-        if self.compiler.compiler_type != "msvc":
-            # Using Ninja-build since it a) is available as a wheel and b)
-            # multithreads automatically. MSVC would require all variables be
-            # exported for Ninja to pick it up, which is a little tricky to do.
-            # Users can override the generator with CMAKE_GENERATOR in CMake
-            # 3.15+.
-            if not cmake_generator:
-                try:
-                    import ninja  # noqa: F401
-
-                    cmake_args += ["-GNinja"]
-                except ImportError:
-                    pass
-
-        else:
-
-            # Single config generators are handled "normally"
-            single_config = any(x in cmake_generator for x in {"NMake", "Ninja"})
-
-            # CMake allows an arch-in-generator style for backward compatibility
-            contains_arch = any(x in cmake_generator for x in {"ARM", "Win64"})
-
-            # Specify the arch if using MSVC generator, but only if it doesn't
-            # contain a backward-compatibility arch spec already in the
-            # generator name.
-            if not single_config and not contains_arch:
-                cmake_args += ["-A", PLAT_TO_CMAKE[self.plat_name]]
-
-            # Multi-config generators have a different way to specify configs
-            if not single_config:
-                cmake_args += [
-                    "-DCMAKE_LIBRARY_OUTPUT_DIRECTORY_{}={}".format(cfg.upper(), extdir)
-                ]
-                build_args += ["--config", cfg]
-
-        if sys.platform.startswith("darwin"):
-            # Cross-compile support for macOS - respect ARCHFLAGS if set
-            archs = re.findall(r"-arch (\S+)", os.environ.get("ARCHFLAGS", ""))
-            if archs:
-                cmake_args += ["-DCMAKE_OSX_ARCHITECTURES={}".format(";".join(archs))]
-
-        # Set CMAKE_BUILD_PARALLEL_LEVEL to control the parallel build level
-        # across all generators.
-        if "CMAKE_BUILD_PARALLEL_LEVEL" not in os.environ:
-            # self.parallel is a Python 3 only way to set parallel jobs by hand
-            # using -j in the build_ext call, not supported by pip or PyPA-build.
-            if hasattr(self, "parallel") and self.parallel:
-                # CMake 3.12+ only.
-                build_args += ["-j{}".format(self.parallel)]
-
-        if not os.path.exists(self.build_temp):
-            os.makedirs(self.build_temp)
-
-        subprocess.check_call(
-            ["cmake", ext.sourcedir] + cmake_args, cwd=self.build_temp
-        )
-        subprocess.check_call(
-            ["cmake", "--build", "."] + build_args, cwd=self.build_temp
-        )
-
-
-# The information here can also be placed in setup.cfg - better separation of
-# logic and declaration, and simpler if you include description/version in a file.
 setup(
     name="copclib",
-    version="2.2.0",
+    version=version,
+    description=" copc-lib provides an easy-to-use interface for reading and creating Cloud Optimized Point Clouds",
+    license="BSD",
+    keywords="point cloud spatial copc",
     author="Rock Robotic Inc.",
     author_email="support@rockrobotic.com",
-    description="A python interface to Cloud-Optimized Point Clouds (COPC)",
-    long_description="",
-    ext_modules=[CMakeExtension("copclib")],
-    cmdclass={"build_ext": CMakeBuild},
-    zip_safe=False,
+    maintainer="Rock Robotic Inc.",
+    maintainer_email="support@rockrobotic.com",
+    url="https://github.com/RockRobotic/copc-lib",
+    extras_require={"test": ["pytest"]},
+    cmake_args=["-DWITH_PYTHON:BOOL=ON", "-DWITH_TESTS=OFF", "-DEMSCRIPTEN=OFF"],
+    cmake_install_dir="python/copclib",
+    packages=find_packages(where="python"),
+    package_dir={"": "python"},
+    exclude_package_data={"": ["libs/laz-perf"]},
 )
diff --git a/version b/version
new file mode 100644
index 00000000..e3a4f193
--- /dev/null
+++ b/version
@@ -0,0 +1,1 @@
+2.2.0
\ No newline at end of file


Test patch:
diff --git a/test/__init__.py b/test/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/test/data/.gitignore b/test/data/.gitignore
index c86e3f36..fd35268f 100644
--- a/test/data/.gitignore
+++ b/test/data/.gitignore
@@ -1,1 +1,2 @@
-las/
+out/
+*.laz
diff --git a/test/extents_test.py b/test/extents_test.py
index 567d89a4..5b131a39 100644
--- a/test/extents_test.py
+++ b/test/extents_test.py
@@ -1,6 +1,8 @@
 import pytest
 from sys import float_info
 import copclib as copc
+from .utils import get_data_dir
+import os
 
 
 def test_copc_extents():
@@ -23,7 +25,7 @@ def test_copc_extents():
 
     #### Default Extents ####
 
-    file_path = "writer_test.copc.laz"
+    file_path = os.path.join(get_data_dir(), "writer_test.copc.laz")
 
     eb_vlr = copc.EbVlr(num_eb_items)
     cfg = copc.CopcConfigWriter(point_format_id, extra_bytes_vlr=eb_vlr)
diff --git a/test/las_header_test.py b/test/las_header_test.py
index a2f79545..dd2a4da5 100644
--- a/test/las_header_test.py
+++ b/test/las_header_test.py
@@ -1,9 +1,11 @@
 import pickle
 import copclib as copc
 
+from .utils import get_autzen_file
+
 
 def test_get_bounds():
-    reader = copc.FileReader("autzen-classified.copc.laz")
+    reader = copc.FileReader(get_autzen_file())
     las_header = reader.copc_config.las_header
     box = las_header.Bounds()
     assert box.x_min == las_header.min.x
diff --git a/test/pickle_test.py b/test/pickle_test.py
index 6ceab4b6..e3a2c562 100644
--- a/test/pickle_test.py
+++ b/test/pickle_test.py
@@ -1,6 +1,8 @@
 import pickle
 import copclib as copc
 
+from .utils import get_autzen_file
+
 
 def test_vector_char():
     char_vec = copc.VectorChar()
@@ -36,7 +38,7 @@ def test_node():
 
 def test_las_header():
 
-    reader = copc.FileReader("autzen-classified.copc.laz")
+    reader = copc.FileReader(get_autzen_file())
     las_header = reader.copc_config.las_header
 
     las_header_other = pickle.loads(pickle.dumps(las_header, -1))
diff --git a/test/point_test.py b/test/point_test.py
index 35d15962..28ab0ac1 100644
--- a/test/point_test.py
+++ b/test/point_test.py
@@ -116,10 +116,10 @@ def test_point():
     # Scan Angle
     point6.scan_angle = -30000
     assert point6.scan_angle == -30000
-    assert point6.scan_angle_degrees == -180.0
+    assert point6.scan_angle_degrees == pytest.approx(-180.0)
     point6.scan_angle = 30000
     assert point6.scan_angle == 30000
-    assert point6.scan_angle_degrees == 180.0
+    assert point6.scan_angle_degrees == pytest.approx(180.0)
 
     # User Data
     point6.user_data = 255
diff --git a/test/reader_test.py b/test/reader_test.py
index 2d10312e..04d2e86a 100644
--- a/test/reader_test.py
+++ b/test/reader_test.py
@@ -2,10 +2,12 @@
 import pytest
 from sys import float_info
 
+from .utils import get_autzen_file
+
 
 def test_reader():
     # Given a valid file path
-    reader = copc.FileReader("autzen-classified.copc.laz")
+    reader = copc.FileReader(get_autzen_file())
 
     # LasHeader Test
     header = reader.copc_config.las_header
@@ -67,7 +69,7 @@ def test_reader():
 
 def test_find_key():
     # Given a valid file path
-    reader = copc.FileReader("autzen-classified.copc.laz")
+    reader = copc.FileReader(get_autzen_file())
 
     key = copc.VoxelKey.RootKey()
     hier_entry = reader.FindNode(key)
@@ -85,7 +87,7 @@ def test_find_key():
 
 
 def test_get_all_children():
-    reader = copc.FileReader("autzen-classified.copc.laz")
+    reader = copc.FileReader(get_autzen_file())
 
     # Get root key
     nodes = reader.GetAllNodes()
@@ -107,19 +109,19 @@ def test_get_all_children():
 
 
 def test_get_all_page_keys():
-    reader = copc.FileReader("autzen-classified.copc.laz")
+    reader = copc.FileReader(get_autzen_file())
     assert len(reader.GetPageList()) == 1
 
 
 # TODO[Leo]: Make this test optional
 
 # def test_get_all_points():
-#     reader = copc.FileReader("autzen-classified.copc.laz")
+#     reader = copc.FileReader(get_autzen_file())
 #     assert len(reader.GetAllPoints()) == reader.las_header.point_count
 
 
 def test_point_error_handling():
-    reader = copc.FileReader("autzen-classified.copc.laz")
+    reader = copc.FileReader(get_autzen_file())
 
     invalid_node = copc.Node()
     with pytest.raises(RuntimeError):
@@ -144,7 +146,7 @@ def test_point_error_handling():
 
 def test_spatial_query_functions():
 
-    reader = copc.FileReader("autzen-classified.copc.laz")
+    reader = copc.FileReader(get_autzen_file())
 
     # Make horizontal 2D box of [400,400] roughly in the middle of the point cloud.
     middle = (reader.copc_config.las_header.max + reader.copc_config.las_header.min) / 2
diff --git a/test/utils.py b/test/utils.py
new file mode 100644
index 00000000..21104732
--- /dev/null
+++ b/test/utils.py
@@ -0,0 +1,12 @@
+import os
+import sys
+
+DATADIRECTORY = os.path.join(os.path.dirname(__file__), "data")
+
+
+def get_autzen_file():
+    return os.path.join(DATADIRECTORY, "autzen-classified.copc.laz")
+
+
+def get_data_dir():
+    return DATADIRECTORY
diff --git a/test/writer_test.py b/test/writer_test.py
index 6d7acee8..6dfb55ba 100644
--- a/test/writer_test.py
+++ b/test/writer_test.py
@@ -1,11 +1,14 @@
 import copclib as copc
 import pytest
 from sys import float_info
+import os
+
+from .utils import get_autzen_file, get_data_dir
 
 
 def test_writer_config():
     # Given a valid file path
-    file_path = "writer_test.copc.laz"
+    file_path = os.path.join(get_data_dir(), "writer_test.copc.laz")
 
     # Default config
     cfg = copc.CopcConfigWriter(6)
@@ -133,7 +136,7 @@ def test_writer_config():
     assert reader.copc_config.wkt == "TEST_WKT"
 
     # Copy
-    orig = copc.FileReader("autzen-classified.copc.laz")
+    orig = copc.FileReader(get_autzen_file())
 
     cfg = orig.copc_config
     writer = copc.FileWriter(file_path, cfg)
@@ -216,7 +219,7 @@ def test_writer_config():
 
 
 def test_writer_pages():
-    file_path = "writer_test.copc.laz"
+    file_path = os.path.join(get_data_dir(), "writer_test.copc.laz")
 
     writer = copc.FileWriter(file_path, copc.CopcConfigWriter(6))
 
@@ -292,9 +295,9 @@ def test_writer_pages():
 
 def test_writer_copy():
     # Given a valid file path
-    file_path = "writer_test.copc.laz"
+    file_path = os.path.join(get_data_dir(), "writer_test.copc.laz")
 
-    reader = copc.FileReader("autzen-classified.copc.laz")
+    reader = copc.FileReader(get_autzen_file())
 
     cfg = reader.copc_config
     writer = copc.FileWriter(file_path, cfg)
@@ -328,9 +331,9 @@ def test_writer_copy():
 
 def test_writer_copy_and_update():
 
-    file_path = "writer_test.copc.laz"
+    file_path = os.path.join(get_data_dir(), "writer_test.copc.laz")
 
-    orig = copc.FileReader("autzen-classified.copc.laz")
+    orig = copc.FileReader(get_autzen_file())
 
     cfg = orig.copc_config
 
@@ -610,7 +613,7 @@ def test_writer_copy_and_update():
 
 def test_check_spatial_bounds():
 
-    file_path = "writer_test.copc.laz"
+    file_path = os.path.join(get_data_dir(), "writer_test.copc.laz")
 
     cfg = copc.CopcConfigWriter(7, (0.1, 0.1, 0.1), (50, 50, 50))
     cfg.las_header.min = (-10, -10, -5)