loghunter-cli 0.1.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. loghunter/__init__.py +3 -0
  2. loghunter/cli.py +1108 -0
  3. loghunter/cli_init.py +567 -0
  4. loghunter/common/__init__.py +1 -0
  5. loghunter/common/allowlist.py +436 -0
  6. loghunter/common/clustering.py +326 -0
  7. loghunter/common/config.py +221 -0
  8. loghunter/common/display.py +323 -0
  9. loghunter/common/errors.py +45 -0
  10. loghunter/common/finding.py +239 -0
  11. loghunter/common/loader/__init__.py +136 -0
  12. loghunter/common/loader/diagnostics.py +94 -0
  13. loghunter/common/loader/discovery.py +335 -0
  14. loghunter/common/loader/io.py +76 -0
  15. loghunter/common/loader/pipeline.py +1010 -0
  16. loghunter/common/loader/sniff.py +184 -0
  17. loghunter/common/loader/types.py +207 -0
  18. loghunter/common/loader/windowing.py +523 -0
  19. loghunter/common/output.py +93 -0
  20. loghunter/common/paths.py +105 -0
  21. loghunter/common/sources.py +392 -0
  22. loghunter/data/allowlist/connections.txt +50 -0
  23. loghunter/data/allowlist/domains_devices.txt +5 -0
  24. loghunter/data/allowlist/domains_homelab.txt +5 -0
  25. loghunter/data/allowlist/domains_universal.txt +125 -0
  26. loghunter/data/config_example.toml +144 -0
  27. loghunter/detectors/__init__.py +5 -0
  28. loghunter/detectors/auth.py +27 -0
  29. loghunter/detectors/aws.py +671 -0
  30. loghunter/detectors/beacon.py +258 -0
  31. loghunter/detectors/dns.py +778 -0
  32. loghunter/detectors/dnsblock.py +29 -0
  33. loghunter/detectors/duration.py +178 -0
  34. loghunter/detectors/protocol.py +26 -0
  35. loghunter/detectors/scan.py +735 -0
  36. loghunter/detectors/ssl.py +25 -0
  37. loghunter/detectors/syslog.py +266 -0
  38. loghunter/detectors/weird.py +27 -0
  39. loghunter/digest/__init__.py +43 -0
  40. loghunter/digest/_stats.py +182 -0
  41. loghunter/digest/blob.py +698 -0
  42. loghunter/digest/cloudtrail.py +341 -0
  43. loghunter/digest/conn.py +367 -0
  44. loghunter/digest/dns.py +364 -0
  45. loghunter/digest/syslog.py +269 -0
  46. loghunter/exporters/__init__.py +534 -0
  47. loghunter/exporters/cloudtrail.py +499 -0
  48. loghunter/exporters/splunk.py +222 -0
  49. loghunter/outputs/__init__.py +1 -0
  50. loghunter/outputs/allowlist.py +75 -0
  51. loghunter/outputs/csv.py +70 -0
  52. loghunter/outputs/email.py +44 -0
  53. loghunter/outputs/html.py +99 -0
  54. loghunter/outputs/json.py +77 -0
  55. loghunter/outputs/text.py +1422 -0
  56. loghunter/parsers/__init__.py +1 -0
  57. loghunter/parsers/cloudtrail.py +287 -0
  58. loghunter/parsers/dnsmasq.py +331 -0
  59. loghunter/parsers/syslog.py +150 -0
  60. loghunter/parsers/zeek.py +294 -0
  61. loghunter/parsers/zeek_tsv.py +310 -0
  62. loghunter/runner.py +1895 -0
  63. loghunter_cli-0.1.0.dev0.dist-info/METADATA +336 -0
  64. loghunter_cli-0.1.0.dev0.dist-info/RECORD +122 -0
  65. loghunter_cli-0.1.0.dev0.dist-info/WHEEL +5 -0
  66. loghunter_cli-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  67. loghunter_cli-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
  68. loghunter_cli-0.1.0.dev0.dist-info/top_level.txt +4 -0
  69. migrations/cloudtrail_parquet.py +59 -0
  70. migrations/conn_fft.py +550 -0
  71. migrations/conn_scan.py +1097 -0
  72. migrations/dns_dbscan.py +520 -0
  73. migrations/get_syslog.py +402 -0
  74. migrations/syslog_drain3.py +479 -0
  75. scratch/junk/parquet.py +59 -0
  76. tests/__init__.py +1 -0
  77. tests/_cloudtrail_fakes.py +116 -0
  78. tests/conftest.py +17 -0
  79. tests/test_allowlist_defaults_accessor.py +90 -0
  80. tests/test_architecture_spine.py +302 -0
  81. tests/test_aws_detector.py +504 -0
  82. tests/test_be_like_water.py +106 -0
  83. tests/test_cli_help.py +342 -0
  84. tests/test_cli_multi_positional.py +458 -0
  85. tests/test_cloudtrail_exporter.py +631 -0
  86. tests/test_cloudtrail_exporter_botocore.py +207 -0
  87. tests/test_cloudtrail_parser.py +393 -0
  88. tests/test_clustering.py +85 -0
  89. tests/test_clustering_interruptible.py +404 -0
  90. tests/test_config_cli.py +1006 -0
  91. tests/test_config_example_drift.py +164 -0
  92. tests/test_digest_blob.py +1237 -0
  93. tests/test_digest_cli.py +1040 -0
  94. tests/test_digest_cloudtrail.py +980 -0
  95. tests/test_digest_conn.py +1189 -0
  96. tests/test_digest_dns.py +770 -0
  97. tests/test_digest_stats.py +282 -0
  98. tests/test_digest_syslog.py +724 -0
  99. tests/test_display.py +370 -0
  100. tests/test_dns_detector.py +1010 -0
  101. tests/test_dnsmasq_parser.py +467 -0
  102. tests/test_duration_detector.py +491 -0
  103. tests/test_export_orchestrator_shape.py +153 -0
  104. tests/test_init_wizard.py +707 -0
  105. tests/test_loader.py +3639 -0
  106. tests/test_loader_package_surface.py +115 -0
  107. tests/test_loader_window_model.py +215 -0
  108. tests/test_output_path_cascade.py +575 -0
  109. tests/test_resolve_path.py +111 -0
  110. tests/test_root_provenance.py +212 -0
  111. tests/test_runner.py +2599 -0
  112. tests/test_scan_detector.py +455 -0
  113. tests/test_search_paths.py +50 -0
  114. tests/test_sniff_orchestrator.py +373 -0
  115. tests/test_sniff_recognizers.py +573 -0
  116. tests/test_source_resolution_seam.py +471 -0
  117. tests/test_sources.py +648 -0
  118. tests/test_splunk_exporter.py +351 -0
  119. tests/test_syslog_detector.py +458 -0
  120. tests/test_syslog_parser.py +582 -0
  121. tests/test_text_output.py +1225 -0
  122. tests/test_zeek_tsv_parser.py +580 -0
@@ -0,0 +1,404 @@
1
+ """Process-isolation harness for HDBSCAN.fit_predict — Ctrl-C honoured.
2
+
3
+ Covers loghunter.common.clustering.fit_predict_interruptible and its
4
+ helpers. Detector-logic tests live in tests/test_dns_detector.py and
5
+ are intentionally NOT extended here — they flip _CLUSTERING_ISOLATE_ENABLED
6
+ to False via an autouse fixture and exercise the in-process path.
7
+
8
+ Worker targets MUST be module-level so they pickle cleanly under spawn.
9
+ Nested functions, lambdas, and closures would not survive spawn re-import.
10
+ Tests rebind clustering._WORKER_TARGET to the helpers below before
11
+ invoking fit_predict_interruptible; the spawn child re-imports this
12
+ module (its qualified name is tests.test_clustering_interruptible) and
13
+ finds the target by name.
14
+
15
+ All test data is synthetic numpy — no real network data anywhere.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import importlib
21
+ import subprocess
22
+ import sys
23
+ import textwrap
24
+ import time
25
+ from pathlib import Path
26
+
27
+ import numpy as np
28
+ import pytest
29
+
30
+ from loghunter.common import clustering
31
+
32
+
33
+ # ── Module-level workers (picklable under spawn) ─────────────────────────────
34
+
35
+
36
+ def _blocking_worker(
37
+ result_queue, X, min_cluster_size, min_samples, backend,
38
+ ) -> None:
39
+ """Test target: park forever until the parent kills us. Used to
40
+ verify that a parent-side KeyboardInterrupt drives the
41
+ terminate→kill→cleanup sequence and that the child does not leak."""
42
+ while True:
43
+ time.sleep(0.05)
44
+
45
+
46
+ def _erroring_worker(
47
+ result_queue, X, min_cluster_size, min_samples, backend,
48
+ ) -> None:
49
+ """Test target: report an error tuple and exit. Verifies the parent
50
+ surfaces worker errors as a normal ValueError, not a multiprocessing
51
+ traceback."""
52
+ result_queue.put(("error", "RuntimeError: induced failure"))
53
+
54
+
55
+ def _dying_worker(
56
+ result_queue, X, min_cluster_size, min_samples, backend,
57
+ ) -> None:
58
+ """Test target: exit WITHOUT putting anything on the queue. Simulates
59
+ segfault / OOM kill / unhandled signal in the real worker. The parent
60
+ polling rail must raise RuntimeError, not hang forever."""
61
+ sys.exit(7)
62
+
63
+
64
+ def _good_worker(
65
+ result_queue, X, min_cluster_size, min_samples, backend,
66
+ ) -> None:
67
+ """Test target: return a fixed all-zeros label array."""
68
+ result_queue.put(("ok", np.zeros(len(X), dtype=np.int64)))
69
+
70
+
71
+ # ── 1. Interruptibility — KeyboardInterrupt propagates, child terminated ────
72
+
73
+
74
+ def test_keyboard_interrupt_propagates_and_terminates_child(
75
+ monkeypatch: pytest.MonkeyPatch,
76
+ ) -> None:
77
+ """A parent-side KeyboardInterrupt during the polling wait causes the
78
+ helper to invoke the committed terminate→join→kill→cleanup→re-raise
79
+ sequence. The KeyboardInterrupt must propagate to the caller; the
80
+ child must be terminated (not zombied); and the helper's cleanup
81
+ must complete without raising (a still-alive child would make
82
+ Process.close() raise — that's the implicit proof the child died).
83
+
84
+ Deterministic harness: patch _await_child_result to raise
85
+ KeyboardInterrupt directly. The polling loop's queue.get is what
86
+ would actually raise when the main thread sees SIGINT; raising
87
+ explicitly avoids racing a real signal in the test process.
88
+ """
89
+ monkeypatch.setattr(
90
+ clustering, "_WORKER_TARGET", _blocking_worker,
91
+ )
92
+
93
+ # Spy on Process.terminate so we capture state BEFORE the helper's
94
+ # cleanup calls child.close() (which makes the handle unqueryable).
95
+ snapshot: dict = {}
96
+ real_terminate = type(
97
+ clustering.multiprocessing.get_context("spawn").Process(
98
+ target=_good_worker, args=(None, None, 0, 0, "x"),
99
+ )
100
+ ).terminate
101
+
102
+ def _spy_terminate(self):
103
+ snapshot["alive_before_terminate"] = self.is_alive()
104
+ return real_terminate(self)
105
+
106
+ monkeypatch.setattr(
107
+ clustering.multiprocessing.context.SpawnProcess,
108
+ "terminate", _spy_terminate,
109
+ )
110
+
111
+ def _intercepted_await(result_queue, child):
112
+ # Hand the live child to the snapshot dict so a post-cleanup
113
+ # query against its (now-closed) handle isn't required.
114
+ snapshot["proc"] = child
115
+ raise KeyboardInterrupt
116
+
117
+ monkeypatch.setattr(
118
+ clustering, "_await_child_result", _intercepted_await,
119
+ )
120
+
121
+ X = np.zeros((10, 2), dtype=np.float64)
122
+ with pytest.raises(KeyboardInterrupt):
123
+ clustering.fit_predict_interruptible(
124
+ X, min_cluster_size=5, min_samples=2,
125
+ )
126
+
127
+ # The terminate spy fired — proves the helper went down the interrupt
128
+ # cleanup path rather than the normal-return cleanup path. Child was
129
+ # alive when terminate ran (i.e. it was a real running process the
130
+ # interrupt-cleanup had to take down, not a no-op).
131
+ assert snapshot.get("alive_before_terminate") is True
132
+ # The helper completed cleanup without raising from child.close() —
133
+ # which would have raised if the child were still alive at that
134
+ # point. That's the implicit proof that terminate→join→(kill?) drove
135
+ # the child to a terminated state.
136
+
137
+
138
+ # ── 2. No resource_tracker leak — subprocess harness ────────────────────────
139
+
140
+
141
+ @pytest.mark.skipif(
142
+ sys.platform.startswith("win"),
143
+ reason="multiprocessing.resource_tracker is a POSIX concern",
144
+ )
145
+ def test_no_resource_tracker_warning_on_interrupt(tmp_path: Path) -> None:
146
+ """Regression guard: a SIGINT during a stock-hdbscan clustering run
147
+ must terminate the child cleanly with NO resource_tracker 'leaked
148
+ semaphore' message on subprocess stderr.
149
+
150
+ pytest's warnings.catch_warnings does NOT reliably capture
151
+ multiprocessing.resource_tracker output — that line is emitted on
152
+ process-shutdown stderr from a sibling thread the pytest capture
153
+ machinery has already torn down. A subprocess harness captures the
154
+ real stderr, including post-shutdown chatter.
155
+
156
+ Stock hdbscan is the target backend: it spawns the nested
157
+ multiprocessing pool that leaks semaphores. fast_hdbscan uses numba
158
+ threads, so the bug never manifests there. The harness explicitly
159
+ rebinds clustering.HDBSCAN to stock's class — setting
160
+ ACTIVE_BACKEND='hdbscan' alone would leave clustering.HDBSCAN
161
+ pointing at fast_hdbscan.HDBSCAN (which won at module-load time) and
162
+ _build_clusterer would pass core_dist_n_jobs=1 to the wrong class.
163
+ """
164
+ try:
165
+ import hdbscan as _stock # noqa: F401
166
+ except ImportError:
167
+ pytest.skip("stock hdbscan not importable")
168
+
169
+ script = tmp_path / "harness.py"
170
+ script.write_text(textwrap.dedent('''
171
+ import os
172
+ import signal
173
+ import threading
174
+ import time
175
+
176
+ import numpy as np
177
+ import hdbscan as _stock
178
+
179
+ from loghunter.common import clustering
180
+
181
+ # Both knobs are load-bearing — ACTIVE_BACKEND drives
182
+ # _build_clusterer's kwarg branch; clustering.HDBSCAN drives
183
+ # the class actually constructed. Setting one without the
184
+ # other silently mis-targets the test.
185
+ clustering.HDBSCAN = _stock.HDBSCAN
186
+ clustering.ACTIVE_BACKEND = "hdbscan"
187
+
188
+ # Reasonably sized synthetic feature matrix so clustering takes
189
+ # long enough for the SIGINT thread below to land mid-compute.
190
+ rng = np.random.default_rng(0)
191
+ X = rng.normal(size=(2000, 4)).astype(np.float64)
192
+
193
+ def _sigint_after():
194
+ time.sleep(0.5)
195
+ os.kill(os.getpid(), signal.SIGINT)
196
+
197
+ threading.Thread(target=_sigint_after, daemon=True).start()
198
+ try:
199
+ clustering.fit_predict_interruptible(
200
+ X, min_cluster_size=50, min_samples=5,
201
+ )
202
+ except KeyboardInterrupt:
203
+ pass
204
+ '''))
205
+
206
+ result = subprocess.run(
207
+ [sys.executable, str(script)],
208
+ capture_output=True, text=True, timeout=60,
209
+ )
210
+ # Harness must complete cleanly — a crash for unrelated reasons
211
+ # would leave the resource_tracker assertions trivially true and
212
+ # silently false-pass the regression. The harness's own
213
+ # except-KeyboardInterrupt arm swallows the interrupt, so a
214
+ # clean exit code is the right expectation. (Glenn CR P2.)
215
+ assert result.returncode == 0, (
216
+ f"harness crashed (rc={result.returncode}); "
217
+ f"stderr={result.stderr!r}"
218
+ )
219
+ # The regression signals — none of these strings may appear in
220
+ # post-interrupt stderr.
221
+ assert "resource_tracker" not in result.stderr, result.stderr
222
+ assert "leaked semaphore" not in result.stderr, result.stderr
223
+ assert "leaked shared_memory" not in result.stderr, result.stderr
224
+
225
+
226
+ # ── 3. Equivalence — isolated vs in-process ─────────────────────────────────
227
+
228
+
229
+ def test_isolation_does_not_change_labels() -> None:
230
+ """The isolated path and the in-process escape hatch must produce
231
+ identical label arrays on the same input. Isolation is a control
232
+ affordance, not a numerical change.
233
+
234
+ Uses a synthetic feature matrix with a clear two-cluster structure
235
+ + a handful of outliers, so HDBSCAN produces a deterministic
236
+ non-trivial label vector both ways.
237
+ """
238
+ rng = np.random.default_rng(42)
239
+ cluster_a = rng.normal(loc=0.0, scale=0.05, size=(60, 4))
240
+ cluster_b = rng.normal(loc=2.0, scale=0.05, size=(60, 4))
241
+ outliers = rng.uniform(low=-3.0, high=5.0, size=(8, 4))
242
+ X = np.ascontiguousarray(
243
+ np.vstack([cluster_a, cluster_b, outliers]).astype(np.float64),
244
+ )
245
+
246
+ # Run isolated first so any backend-conditional kwarg in the worker
247
+ # is exercised — the in-process run is the calibration reference.
248
+ labels_isolated = clustering.fit_predict_interruptible(
249
+ X, min_cluster_size=10, min_samples=5,
250
+ )
251
+
252
+ # Force the in-process path for the second run.
253
+ saved = clustering._CLUSTERING_ISOLATE_ENABLED
254
+ clustering._CLUSTERING_ISOLATE_ENABLED = False
255
+ try:
256
+ labels_in_process = clustering.fit_predict_interruptible(
257
+ X, min_cluster_size=10, min_samples=5,
258
+ )
259
+ finally:
260
+ clustering._CLUSTERING_ISOLATE_ENABLED = saved
261
+
262
+ assert np.array_equal(labels_isolated, labels_in_process), (
263
+ f"isolated={labels_isolated.tolist()!r} "
264
+ f"in_process={labels_in_process.tolist()!r}"
265
+ )
266
+
267
+
268
+ # ── 4. Backend-conditional kwarg — _build_clusterer direct ──────────────────
269
+
270
+
271
+ def test_build_clusterer_stock_passes_core_dist_n_jobs_1(
272
+ monkeypatch: pytest.MonkeyPatch,
273
+ ) -> None:
274
+ """Stock hdbscan branch: core_dist_n_jobs=1 must be in the kwargs so
275
+ no nested multiprocessing pool spawns (the source of the leaked-
276
+ semaphore warning). Tested by mocking clustering.HDBSCAN to a
277
+ recording fake and calling _build_clusterer directly — no spawn,
278
+ no escape-hatch path, just the construction surface."""
279
+ recorded: dict = {}
280
+
281
+ class _RecordingHDBSCAN:
282
+ def __init__(self, **kwargs):
283
+ recorded.update(kwargs)
284
+
285
+ monkeypatch.setattr(clustering, "HDBSCAN", _RecordingHDBSCAN)
286
+
287
+ clustering._build_clusterer(
288
+ "hdbscan", min_cluster_size=100, min_samples=10,
289
+ )
290
+
291
+ assert recorded == {
292
+ "min_cluster_size": 100,
293
+ "min_samples": 10,
294
+ "core_dist_n_jobs": 1,
295
+ }
296
+
297
+
298
+ def test_build_clusterer_fast_omits_core_dist_n_jobs(
299
+ monkeypatch: pytest.MonkeyPatch,
300
+ ) -> None:
301
+ """fast_hdbscan branch: core_dist_n_jobs is NOT in the kwargs (it
302
+ isn't in fast_hdbscan's signature; passing it would TypeError).
303
+ Numba threads on the fast backend don't use semaphores, so no
304
+ resource-tracker concern exists there."""
305
+ recorded: dict = {}
306
+
307
+ class _RecordingHDBSCAN:
308
+ def __init__(self, **kwargs):
309
+ recorded.update(kwargs)
310
+
311
+ monkeypatch.setattr(clustering, "HDBSCAN", _RecordingHDBSCAN)
312
+
313
+ clustering._build_clusterer(
314
+ "fast_hdbscan", min_cluster_size=2000, min_samples=100,
315
+ )
316
+
317
+ assert recorded == {"min_cluster_size": 2000, "min_samples": 100}
318
+ assert "core_dist_n_jobs" not in recorded
319
+
320
+
321
+ # ── 5. Error propagation — worker raises → parent ValueError ────────────────
322
+
323
+
324
+ def test_worker_error_surfaces_as_value_error(
325
+ monkeypatch: pytest.MonkeyPatch,
326
+ ) -> None:
327
+ """A worker that puts an ('error', ...) tuple must surface as a
328
+ normal ValueError in the parent — the existing detector contract.
329
+ No hang, no multiprocessing traceback bleeding through."""
330
+ monkeypatch.setattr(
331
+ clustering, "_WORKER_TARGET", _erroring_worker,
332
+ )
333
+
334
+ X = np.zeros((10, 2), dtype=np.float64)
335
+ with pytest.raises(ValueError, match="induced failure"):
336
+ clustering.fit_predict_interruptible(
337
+ X, min_cluster_size=5, min_samples=2,
338
+ )
339
+
340
+
341
+ # ── 6. Child dies without queueing — parent RuntimeError, no hang ───────────
342
+
343
+
344
+ def test_child_dying_without_result_raises_runtime_error(
345
+ monkeypatch: pytest.MonkeyPatch,
346
+ ) -> None:
347
+ """If the worker exits without putting a result on the queue
348
+ (segfault, OOM kill, unhandled signal), the polling rail's
349
+ is_alive() check must raise RuntimeError mentioning the exit code.
350
+ Indefinite queue.get() would have hung forever — this is the
351
+ regression guard for the hang.
352
+
353
+ Also asserts the universal cleanup path RAN on this abnormal-exit
354
+ code path. Pre-fix (Glenn CR P1): fit_predict_interruptible only
355
+ caught KeyboardInterrupt, so RuntimeError from _await_child_result
356
+ bypassed queue drain/close/join_thread AND child.close — the exact
357
+ multiprocessing resource leak the helper was designed to handle.
358
+ Spying on _drain_and_close_queue confirms the cleanup ran.
359
+ """
360
+ monkeypatch.setattr(
361
+ clustering, "_WORKER_TARGET", _dying_worker,
362
+ )
363
+
364
+ cleanup_calls: list = []
365
+ real_drain = clustering._drain_and_close_queue
366
+
367
+ def _spy_drain(result_queue):
368
+ cleanup_calls.append("drain")
369
+ return real_drain(result_queue)
370
+
371
+ monkeypatch.setattr(
372
+ clustering, "_drain_and_close_queue", _spy_drain,
373
+ )
374
+
375
+ X = np.zeros((10, 2), dtype=np.float64)
376
+ start = time.monotonic()
377
+ with pytest.raises(RuntimeError, match=r"exitcode=7"):
378
+ clustering.fit_predict_interruptible(
379
+ X, min_cluster_size=5, min_samples=2,
380
+ )
381
+ elapsed = time.monotonic() - start
382
+ # If this exceeds a few seconds, the polling rail regressed (the
383
+ # helper waited on an indefinite queue.get instead of polling
384
+ # is_alive). Generous bound — startup overhead varies.
385
+ assert elapsed < 10.0, f"helper took {elapsed:.2f}s — polling rail regressed?"
386
+ # Cleanup MUST have run on the abnormal-exit path. Without this,
387
+ # P1 regresses: RuntimeError flies past both cleanup branches and
388
+ # resource_tracker leaks on exactly the path the helper exists
389
+ # to handle.
390
+ assert cleanup_calls == ["drain"], (
391
+ f"cleanup did not run on dead-child path; called: {cleanup_calls!r}"
392
+ )
393
+
394
+
395
+ # ── Module export sanity ────────────────────────────────────────────────────
396
+
397
+
398
+ def test_clustering_module_public_surface() -> None:
399
+ """The new helper is exported alongside HDBSCAN and ACTIVE_BACKEND."""
400
+ importlib.reload(clustering)
401
+ assert set(clustering.__all__) == {
402
+ "HDBSCAN", "ACTIVE_BACKEND", "fit_predict_interruptible",
403
+ }
404
+ assert callable(clustering.fit_predict_interruptible)