Repository: vc1492a/PyNomaly (language: python)

Issue:
Move continuous integration from Travis CI to Github Actions
Our free travis CI credit ran out a long time ago. With this PR we move the our current CI Pipeline from travis to github actions.

With this new pipeline we test for Python versions 3.6 - 3.12. During the process some regressions in our unit tests on older Python versions were discovered such as using the `tuple` instead of `Tuple` in the type annotations. These small fixes are included in this PR.

An interesting read regarding costs etc.:
https://docs.github.com/en/actions/administering-github-actions/usage-limits-billing-and-administration

According to this, we fall under the free category.


Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index cc65586..b80b260 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,12 +6,168 @@ nasaValve
 rel_research
 PyNomaly/loop_dev.py
 /PyNomaly.egg-info/
-.pytest_cache
-build
-htmlcov/
-*.egg
 *.pyc
-.coverage
 *.coverage.*
 .coveragerc
-venv/
\ No newline at end of file
+
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+#  Usually these files are written by a python script from a template
+#  before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+#   For a library or package, you might want to ignore these files since the code is
+#   intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+#   However, in case of collaboration, if having platform-specific dependencies or dependencies
+#   having no cross-platform support, pipenv may install dependencies that don't work, or not
+#   install all needed dependencies.
+#Pipfile.lock
+
+# poetry
+#   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
+#   This is especially recommended for binary packages to ensure reproducibility, and is more
+#   commonly ignored for libraries.
+#   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
+#poetry.lock
+
+# pdm
+#   Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
+#pdm.lock
+#   pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
+#   in version control.
+#   https://pdm.fming.dev/#use-with-ide
+.pdm.toml
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# PyCharm
+#  JetBrains specific template is maintained in a separate JetBrains.gitignore that can
+#  be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+#  and can be added to the global gitignore or merged into this file.  For a more nuclear
+#  option (not recommended) you can uncomment the following to ignore the entire idea folder.
+#.idea/
+
diff --git a/readme.md b/readme.md
index 1a23f85..77b1724 100644
--- a/readme.md
+++ b/readme.md
@@ -7,7 +7,7 @@ scores in the range of [0,1] that are directly interpretable as the probability
 [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
 [![PyPi](https://img.shields.io/badge/pypi-0.3.3-blue.svg)](https://pypi.python.org/pypi/PyNomaly/0.3.3)
 ![](https://img.shields.io/pypi/dm/PyNomaly.svg?logoColor=blue)
-[![Build Status](https://travis-ci.org/vc1492a/PyNomaly.svg?branch=main)](https://travis-ci.org/vc1492a/PyNomaly)
+![Tests](https://github.com/vc1492a/PyNomaly/actions/workflows/tests.yml/badge.svg)
 [![Coverage Status](https://coveralls.io/repos/github/vc1492a/PyNomaly/badge.svg?branch=main)](https://coveralls.io/github/vc1492a/PyNomaly?branch=main)
 [![JOSS](http://joss.theoj.org/papers/f4d2cfe680768526da7c1f6a2c103266/status.svg)](http://joss.theoj.org/papers/f4d2cfe680768526da7c1f6a2c103266)
 


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..eeb6f20
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,72 @@
+# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
+# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
+
+name: tests
+
+on:
+  push:
+    branches: [ "main", "dev" ]
+  pull_request:
+    branches: [ "main", "dev" ]
+
+jobs:
+  # there is no python3.6 available on newer ubuntu instances
+  # so we have this one to run on ubuntu-20.04
+  test-python36:
+    runs-on: ubuntu-20.04
+    strategy:
+      fail-fast: false
+      matrix:
+        python-version: ["3.6"]
+
+    steps:
+    - uses: actions/checkout@v4
+    - name: Set up Python ${{ matrix.python-version }}
+      uses: actions/setup-python@v3
+      with:
+        python-version: ${{ matrix.python-version }}
+    - name: Install dependencies
+      run: |
+        python -m pip install --upgrade pip
+        python -m pip install flake8 pytest
+        pip install -r requirements.txt
+        pip install -r requirements_ci.txt
+    - name: Lint with flake8
+      run: |
+        # stop the build if there are Python syntax errors or undefined names
+        flake8 . --count --exit-zero --select=E9,F63,F7,F82 --show-source --statistics
+        # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
+        flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
+    - name: Test with pytest
+      run: |
+        pytest --cov=PyNomaly
+  
+  test:
+
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"]
+
+    steps:
+    - uses: actions/checkout@v4
+    - name: Set up Python ${{ matrix.python-version }}
+      uses: actions/setup-python@v3
+      with:
+        python-version: ${{ matrix.python-version }}
+    - name: Install dependencies
+      run: |
+        python -m pip install --upgrade pip
+        python -m pip install flake8 pytest
+        pip install -r requirements.txt
+        pip install -r requirements_ci.txt
+    - name: Lint with flake8
+      run: |
+        # stop the build if there are Python syntax errors or undefined names
+        flake8 . --count --exit-zero --select=E9,F63,F7,F82 --show-source --statistics
+        # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
+        flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
+    - name: Test with pytest
+      run: |
+        pytest --cov=PyNomaly
diff --git a/tests/test_loop.py b/tests/test_loop.py
index 9e5850f..bb214da 100644
--- a/tests/test_loop.py
+++ b/tests/test_loop.py
@@ -4,8 +4,9 @@
 from PyNomaly import loop
 
 import logging
+from typing import Tuple
 import numpy as np
-from numpy.testing import assert_array_equal
+from numpy.testing import assert_array_equal, assert_array_almost_equal
 import pandas as pd
 import pytest
 from sklearn.datasets import load_iris
@@ -51,6 +52,28 @@ def X_n8() -> np.ndarray:
     return X
 
 
+@pytest.fixture()
+def X_n20_scores() -> Tuple[np.ndarray, np.ndarray]:
+    """
+    Fixture that returns a tuple containing a 20 element numpy array
+    and the precalculated loOP scores based on that array.
+    :return: tuple(input_data,exptected_scores)
+    """
+    input_data = np.array([0.02059752, 0.32629926, 0.63036653, 0.94409321,
+                           0.63251097, 0.47598494, 0.80204026, 0.34845067,
+                           0.81556468, 0.89183, 0.25210317, 0.11460502,
+                           0.19953434, 0.36955067, 0.06038041, 0.34527368,
+                           0.56621582, 0.90533649, 0.33773613, 0.71573306])
+
+    expected_scores = np.array([0.6356276742921594, 0.0, 0.0,
+                                0.48490790006974044, 0.0, 0.0, 0.0, 0.0,
+                                0.021728288376168012, 0.28285086151683225,
+                                0.0, 0.18881886507113213, 0.0, 0.0,
+                                0.45350246469681843, 0.0, 0.07886635748113013,
+                                0.3349068501560546, 0.0, 0.0])
+    return (input_data, expected_scores)
+
+
 @pytest.fixture()
 def X_n120() -> np.ndarray:
     """
@@ -122,6 +145,18 @@ def test_loop(X_n8) -> None:
     assert np.min(score[-2:]) > np.max(score[:-2])
 
 
+def test_regression(X_n20_scores) -> None:
+    """
+    Tests for potential regression errors by comparing current results
+    to the exptected results. Any changes to the code should still return
+    the same result given the same dataset
+    """
+    input_data, expected_scores = X_n20_scores
+    clf = loop.LocalOutlierProbability(input_data).fit()
+    scores = clf.local_outlier_probabilities
+    assert_array_almost_equal(scores, expected_scores, 6)
+
+
 def test_loop_performance(X_n120) -> None:
     """
     Using a set of known anomalies (labels), tests the performance (using
@@ -170,7 +205,7 @@ def test_input_nodata(X_n140_outliers) -> None:
     assert len(record) == 1
     # check that the message matches
     assert record[0].message.args[
-               0] == "Data or a distance matrix must be provided."
+        0] == "Data or a distance matrix must be provided."
 
 
 def test_input_incorrect_type(X_n140_outliers) -> None:
@@ -192,8 +227,8 @@ def test_input_incorrect_type(X_n140_outliers) -> None:
     assert len(record) == 1
     # check that the message matches
     assert record[0].message.args[
-               0] == "Argument 'n_neighbors' is not of type (<class 'int'>, " \
-                     "<class 'numpy.integer'>)."
+        0] == "Argument 'n_neighbors' is not of type (<class 'int'>, " \
+        "<class 'numpy.integer'>)."
 
 
 def test_input_neighbor_zero(X_n120) -> None:
@@ -213,7 +248,7 @@ def test_input_neighbor_zero(X_n120) -> None:
     assert len(record) == 1
     # check that the message matches
     assert record[0].message.args[
-               0] == "n_neighbors must be greater than 0. Fit with 10 instead."
+        0] == "n_neighbors must be greater than 0. Fit with 10 instead."
 
 
 def test_input_distonly(X_n120) -> None:
@@ -236,8 +271,8 @@ def test_input_distonly(X_n120) -> None:
     assert len(record) == 1
     # check that the message matches
     assert record[0].message.args[
-               0] == "A neighbor index matrix and distance matrix must both " \
-                     "be provided when not using raw input data."
+        0] == "A neighbor index matrix and distance matrix must both " \
+        "be provided when not using raw input data."
 
 
 def test_input_neighboronly(X_n120) -> None:
@@ -260,7 +295,7 @@ def test_input_neighboronly(X_n120) -> None:
     assert len(record) == 1
     # check that the message matches
     assert record[0].message.args[
-               0] == "Data or a distance matrix must be provided."
+        0] == "Data or a distance matrix must be provided."
 
 
 def test_input_too_many(X_n120) -> None:
@@ -284,8 +319,8 @@ def test_input_too_many(X_n120) -> None:
     assert len(record) == 1
     # check that the message matches
     assert record[0].message.args[
-               0] == "Only one of the following may be provided: data or a " \
-                     "distance matrix (not both)."
+        0] == "Only one of the following may be provided: data or a " \
+        "distance matrix (not both)."
 
 
 def test_distance_neighbor_shape_mismatch(X_n120) -> None:
@@ -318,8 +353,8 @@ def test_distance_neighbor_shape_mismatch(X_n120) -> None:
     assert len(record) == 1
     # check that the message matches
     assert record[0].message.args[
-               0] == "The shape of the distance and neighbor " \
-                     "index matrices must match."
+        0] == "The shape of the distance and neighbor " \
+        "index matrices must match."
 
 
 def test_input_neighbor_mismatch(X_n120) -> None:
@@ -345,10 +380,10 @@ def test_input_neighbor_mismatch(X_n120) -> None:
     assert len(record) == 1
     # check that the message matches
     assert record[0].message.args[
-               0] == "The shape of the distance or " \
-                     "neighbor index matrix does not " \
-                     "match the number of neighbors " \
-                     "specified."
+        0] == "The shape of the distance or " \
+        "neighbor index matrix does not " \
+        "match the number of neighbors " \
+        "specified."
 
 
 def test_loop_dist_matrix(X_n120) -> None:
@@ -509,13 +544,13 @@ def test_missing_values() -> None:
     assert len(record_b) == 1
     # check that the message matches
     assert record_b[0].message.args[
-               0] == "Method does not support missing values in input data."
+        0] == "Method does not support missing values in input data."
 
 
 def test_small_cluster_size(X_n140_outliers) -> None:
     """
-    Test to ensure that the program exits when the specified number of neighbors
-    is larger than the smallest cluster size in the input data.
+    Test to ensure that the program exits when the specified number of
+    neighbors is larger than the smallest cluster size in the input data.
     :param X_n140_outliers: A pytest Fixture that generates 140 observations.
     :return: None
     """
@@ -541,10 +576,10 @@ def test_small_cluster_size(X_n140_outliers) -> None:
     assert len(record_b) == 1
     # check that the message matches
     assert record_b[0].message.args[
-               0] == "Number of neighbors specified larger than smallest " \
-                     "cluster. Specify a number of neighbors smaller than " \
-                     "the smallest cluster size (observations in smallest " \
-                     "cluster minus one)."
+        0] == "Number of neighbors specified larger than smallest " \
+        "cluster. Specify a number of neighbors smaller than " \
+        "the smallest cluster size (observations in smallest " \
+        "cluster minus one)."
 
 
 def test_stream_fit(X_n140_outliers) -> None:
@@ -634,8 +669,8 @@ def test_stream_cluster(X_n140_outliers) -> None:
     assert len(record) == 1
     # check that the message matches
     assert record[0].message.args[
-               0] == "Stream approach does not support clustered data. " \
-                     "Automatically refit using single cluster of points."
+        0] == "Stream approach does not support clustered data. " \
+        "Automatically refit using single cluster of points."
 
 
 def test_stream_performance(X_n140_outliers) -> None: