diff --git a/tests/test_loop.py b/tests/test_loop.py index 90d45d8..48522d2 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -483,6 +483,169 @@ def test_parameters(X_n120) -> None: ) +def test_public_api_contract(X_n120) -> None: + """ + Guards against unintentional breaking changes to the public API of + LocalOutlierProbability. Exercises every non-underscored constructor + keyword argument, every fit() keyword argument, public method, and + public attribute so that a rename or removal of any of them causes this + test to fail. Additions to the API are unaffected. + + Written against the 1.0.0 scikit-learn-style API: the constructor takes + configuration only, data goes to ``fit(X, y=None, ...)``, and fitted + state lives on ``_``-suffixed attributes (with read-only legacy aliases). + :param X_n120: A pytest Fixture that generates 120 observations. + :return: None + """ + # exercise every public constructor keyword argument by name; a rename + # or removal raises a TypeError here. Data parameters are no longer + # constructor arguments in 1.0.0 -- they are exercised via fit() below. + clf = loop.LocalOutlierProbability( + extent=3, + n_neighbors=10, + use_numba=NUMBA, + n_jobs=1, + progress_bar=False, + ) + + # sklearn contract: get_params() exposes exactly the constructor's + # parameters -- the configuration parameters plus the deprecated data + # keywords, which stay constructor arguments (with a FutureWarning) + # until they are removed in a future version + assert set(clf.get_params()) == { + "extent", + "n_neighbors", + "use_numba", + "n_jobs", + "progress_bar", + "data", + "distance_matrix", + "neighbor_matrix", + "cluster_labels", + } + + # exercise every public method by name, and every fit() keyword + # argument by name (a rename or removal raises a TypeError here) + assert hasattr(clf, "fit") and callable(clf.fit) + fitted = clf.fit( + X_n120, + y=None, + distance_matrix=None, + neighbor_matrix=None, + cluster_labels=None, + ) + assert fitted is clf + for method_name in ( + "fit", + "fit_predict", + "predict", + "decision_function", + "score_samples", + "stream", + "get_params", + "set_params", + ): + assert hasattr(fitted, method_name) and callable( + getattr(fitted, method_name) + ), f"missing public method: {method_name}" + fitted.stream(X_n120[0]) + fitted.predict(X_n120) + fitted.decision_function(X_n120) + fitted.score_samples(X_n120) + + # every public (non-underscored) attribute expected to exist post-fit: + # configuration parameters, the deprecated constructor data inputs, + # the `_`-suffixed fitted state, and the read-only legacy aliases + expected_public_attributes = [ + # configuration parameters (constructor) + "extent", + "n_neighbors", + "use_numba", + "n_jobs", + "progress_bar", + # deprecated constructor data inputs (always present, None here) + "data", + "distance_matrix", + "neighbor_matrix", + "cluster_labels", + # fitted state (scikit-learn convention) + "data_", + "distance_matrix_", + "neighbor_matrix_", + "cluster_labels_", + "n_neighbors_", + "n_features_in_", + "offset_", + "points_vector_", + "prob_distances_", + "prob_distances_ev_", + "norm_prob_local_outlier_factor_", + "local_outlier_probabilities_", + "is_fit_", + # read-only legacy aliases for fitted output attributes + "points_vector", + "prob_distances", + "prob_distances_ev", + "norm_prob_local_outlier_factor", + "local_outlier_probabilities", + "is_fit", + ] + for attr_name in expected_public_attributes: + assert hasattr(fitted, attr_name), f"missing public attribute: {attr_name}" + + # the legacy aliases must read the canonical fitted attributes + assert fitted.local_outlier_probabilities is fitted.local_outlier_probabilities_ + assert fitted.is_fit is fitted.is_fit_ + + # exercise every public constructor argument positionally, in the + # documented 1.0.0 order, using a distinct value per slot. Swapping two + # parameters of the same type (e.g. extent/n_neighbors/n_jobs, or + # use_numba/progress_bar) doesn't raise an error -- it silently + # assigns the wrong value -- so each assigned attribute is checked + # against the value passed at its expected position. + positional_clf = loop.LocalOutlierProbability(2, 15, False, 1, True) + assert positional_clf.extent == 2 + assert positional_clf.n_neighbors == 15 + assert positional_clf.use_numba is False + assert positional_clf.n_jobs == 1 + assert positional_clf.progress_bar is True + + # data is positional in fit(), sklearn-style: fit(X, y) + positional_fit = loop.LocalOutlierProbability(n_neighbors=10).fit( + X_n120, None + ) + assert positional_fit.data_ is X_n120 + assert positional_fit.is_fit_ is True + + # the `LoOP` alias is part of the public API + from PyNomaly import LoOP + + assert LoOP is loop.LocalOutlierProbability + assert loop.LoOP is loop.LocalOutlierProbability + + # the exception hierarchy is part of the public API: customers import + # these names directly (as this test file does at the top) and may + # catch on the base classes, so both the import paths and the + # hierarchy itself must remain stable. The canonical home is + # PyNomaly.exceptions; PyNomaly.loop and PyNomaly re-export them. + from PyNomaly import exceptions + from PyNomaly.loop import ( + PyNomalyError, + ValidationError, + ClusterSizeError as ImportedClusterSizeError, + MissingValuesError as ImportedMissingValuesError, + ) + + assert PyNomalyError is exceptions.PyNomalyError + assert ValidationError is exceptions.ValidationError + assert ImportedClusterSizeError is exceptions.ClusterSizeError + assert ImportedMissingValuesError is exceptions.MissingValuesError + assert issubclass(ImportedClusterSizeError, ValidationError) + assert issubclass(ImportedMissingValuesError, ValidationError) + assert issubclass(ValidationError, PyNomalyError) + assert issubclass(PyNomalyError, Exception) + + def test_n_neighbors() -> None: """ Tests the functionality of providing a large number of neighbors that