diff --git a/.github/workflows/build_wheels.yml b/.github/workflows/build_wheels.yml index b490172..ed4d0a8 100644 --- a/.github/workflows/build_wheels.yml +++ b/.github/workflows/build_wheels.yml @@ -26,17 +26,48 @@ jobs: cd build cmake .. make -j + - name: Test run: | cd build ctest --no-tests=error --output-on-failure + - uses: actions/upload-artifact@v3 + with: + name: executable + path: ./build/executable/dbscan + + generate_matrix: + name: Generate Matrix + runs-on: ubuntu-latest + + outputs: + OSES: ${{ steps.development.outputs.OSES || steps.production.outputs.OSES }} + CIBW_ARCHS_LINUX: ${{ steps.development.outputs.CIBW_ARCHS_LINUX || steps.production.outputs.CIBW_ARCHS_LINUX }} + + steps: + - name: Development + id: development + if: "!startsWith(github.ref, 'refs/tags/v')" + run: | + echo 'OSES=["ubuntu-latest"]' >> $GITHUB_OUTPUT + echo 'CIBW_ARCHS_LINUX="auto"' >> $GITHUB_OUTPUT + + - name: Production + id: production + if: startsWith(github.ref, 'refs/tags/v') + run: | + echo 'OSES=["ubuntu-latest", "macos-latest", "windows-latest"]' >> $GITHUB_OUTPUT + echo 'CIBW_ARCHS_LINUX="auto aarch64"' >> $GITHUB_OUTPUT + build_wheels: + needs: [generate_matrix] name: Build wheels on ${{ matrix.os }} runs-on: ${{ matrix.os }} + if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v') strategy: matrix: - os: [ubuntu-latest] + os: ${{ fromJson(needs.generate_matrix.outputs.OSES) }} steps: - uses: actions/checkout@v3 @@ -44,32 +75,29 @@ jobs: fetch-depth: 0 - name: Set up QEMU - if: runner.os == 'Linux' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v')) + if: runner.os == 'Linux' && startsWith(github.ref, 'refs/tags/v') uses: docker/setup-qemu-action@v2 with: platforms: all - - name: Build wheels (development) - if: github.ref != 'refs/heads/master' && !startsWith(github.ref, 'refs/tags/v') - uses: pypa/cibuildwheel@v2.11.2 - env: - CIBW_ARCHS_MACOS: "arm64" - - - name: Build wheels (production) - if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v') + - name: Build wheels uses: pypa/cibuildwheel@v2.11.2 env: CIBW_ARCHS_MACOS: "x86_64 arm64" - CIBW_ARCHS_LINUX: "auto aarch64" + CIBW_ARCHS_LINUX: ${{ fromJson(needs.generate_matrix.outputs.CIBW_ARCHS_LINUX) }} + CIBW_TEST_REQUIRES: pytest + CIBW_TEST_EXTRAS: "test36" + CIBW_TEST_COMMAND: "pytest {package}/test" - uses: actions/upload-artifact@v3 with: + name: wheels path: ./wheelhouse/*.whl build_sdist: name: Build source distribution runs-on: ubuntu-latest - if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v') + if: startsWith(github.ref, 'refs/tags/v') steps: - uses: actions/checkout@v3 with: @@ -80,10 +108,11 @@ jobs: - uses: actions/upload-artifact@v3 with: + name: wheels path: dist/*.tar.gz upload_pypi: - needs: [build_and_test, build_wheels, build_sdist] + 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.ref, 'refs/tags/v') @@ -94,7 +123,7 @@ jobs: with: # unpacks default artifact into dist/ # if `name: artifact` is omitted, the action will create extra parent dir - name: artifact + name: wheels path: dist - uses: pypa/gh-action-pypi-publish@v1.5.0 diff --git a/setup.py b/setup.py index 2116a12..b6b33cf 100644 --- a/setup.py +++ b/setup.py @@ -34,6 +34,9 @@ def initialize_options(self): version = setuptools_scm.get_version() +with open("README.md", "r", encoding="utf-8") as fh: + long_description = fh.read() + setuptools.setup( name="dbscan", version=version, @@ -60,4 +63,26 @@ def initialize_options(self): 'example': ['scikit-learn', 'matplotlib'], }, zip_safe=False, + + # To be removed when setuptools is good enough to support pyproject.toml + # completely. + author="Yiqiu Wang", + author_email="yiqiu_wang@icloud.com", + description="Theoretically efficient and practical parallel DBSCAN", + long_description=long_description, + long_description_content_type="text/markdown", + keywords='cluster clustering density dbscan', + url="https://github.com/wangyiqiu/dbscan-python", + license='MIT', + classifiers=[ + 'Development Status :: 2 - Pre-Alpha', + 'Intended Audience :: Science/Research', + 'Intended Audience :: Developers', + "License :: OSI Approved :: MIT License", + 'Programming Language :: C++', + 'Programming Language :: Python :: 3.8', + 'Topic :: Software Development', + 'Topic :: Scientific/Engineering', + "Operating System :: POSIX :: Linux", + ], ) diff --git a/test/test_python_module.py b/test/test_python_module.py new file mode 100644 index 0000000..cb8aa6f --- /dev/null +++ b/test/test_python_module.py @@ -0,0 +1,47 @@ +""" +This file is almost identical to "example.py", except that it compares it +to the correct answer to create a test case. +""" + +import numpy as np + +from sklearn.datasets import make_blobs +from sklearn.preprocessing import StandardScaler + +from sklearn.cluster import DBSCAN as goldDBSCAN +from dbscan import sklDBSCAN as ourDBSCAN + +import unittest + +class ExampleTest(unittest.TestCase): + + def verify_output(self, X): + # ###################################################################### + # Compute DBSCAN + + labels = goldDBSCAN(eps=0.3, min_samples=10).fit(X).labels_ + true_labels = ourDBSCAN(eps=0.3, min_samples=10).fit(X).labels_ + + # ###################################################################### + # Test case + + mapping = {-1: -1} + + for i, (label, true_label) in enumerate(zip(labels, true_labels)): + if true_label in mapping: + self.assertTrue( + mapping[true_label] == label, + "Contradiction at slot {}. mapping={}. true={}. ours={}." \ + .format(i, mapping, true_label, label) + ) + else: + mapping[true_label] = label + + def test_example(self): + # ###################################################################### + # Generate sample data + centers = [[1, 1], [-1, -1], [1, -1]] + X, labels_true = make_blobs(n_samples=750, centers=centers, + cluster_std=0.4, random_state=0) + X = StandardScaler().fit_transform(X) + self.verify_output(X)