• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

anthonypdawson / vector-inspector / 22329517074

23 Feb 2026 11:33PM UTC coverage: 59.86% (+2.6%) from 57.258%
22329517074

Pull #20

github

anthonypdawson
feat(tests): add unit tests for cluster runner, search runner, and connection manager panel functionality
Pull Request #20: Version 0.5.0 - Add Histogram visualization, update sample data generation, connection info panel, tests

320 of 544 new or added lines in 12 files covered. (58.82%)

26 existing lines in 8 files now uncovered.

8287 of 13844 relevant lines covered (59.86%)

0.6 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

46.75
/src/vector_inspector/ui/views/visualization_view.py
1
"""Vector visualization view with dimensionality reduction (modular panels)."""
2

3
from __future__ import annotations
1✔
4

5
import tempfile
1✔
6
import traceback
1✔
7
import webbrowser
1✔
8
from datetime import UTC
1✔
9
from typing import Any, Optional
1✔
10

11
import numpy as np
1✔
12
from PySide6.QtCore import QThread, Signal
1✔
13
from PySide6.QtWidgets import (
1✔
14
    QCheckBox,
15
    QHBoxLayout,
16
    QLabel,
17
    QMessageBox,
18
    QSpinBox,
19
    QTabWidget,
20
    QVBoxLayout,
21
    QWidget,
22
)
23

24
from vector_inspector.core.connection_manager import ConnectionInstance
1✔
25

26
# Feature flags now accessed via app_state.advanced_features_enabled
27
from vector_inspector.core.logging import log_error, log_info
1✔
28
from vector_inspector.services import ClusterRunner, ThreadedTaskRunner
1✔
29
from vector_inspector.services.visualization_service import VisualizationService
1✔
30
from vector_inspector.state import AppState
1✔
31
from vector_inspector.ui.components.loading_dialog import LoadingDialog
1✔
32
from vector_inspector.ui.views.visualization import ClusteringPanel, DRPanel, HistogramPanel, PlotPanel
1✔
33

34

35
class VisualizationThread(QThread):
1✔
36
    """Background thread for dimensionality reduction."""
37

38
    finished = Signal(np.ndarray)
1✔
39
    error = Signal(str)
1✔
40

41
    def __init__(self, embeddings, method, n_components):
1✔
42
        super().__init__()
×
43
        self.embeddings = embeddings
×
44
        self.method = method
×
45
        self.n_components = n_components
×
46

47
    def run(self):
1✔
48
        """Run dimensionality reduction."""
49
        try:
×
50
            result = VisualizationService.reduce_dimensions(
×
51
                self.embeddings, method=self.method, n_components=self.n_components
52
            )
53
            if result is not None:
×
54
                self.finished.emit(result)
×
55
            else:
56
                self.error.emit("Dimensionality reduction failed")
×
57
        except Exception as e:
×
58
            traceback.print_exc()
×
59
            self.error.emit(str(e))
×
60

61

62
class ClusteringThread(QThread):
1✔
63
    """Background thread for clustering."""
64

65
    finished = Signal(object)  # cluster_labels, algorithm
1✔
66
    error = Signal(str)
1✔
67

68
    def __init__(self, embeddings, algorithm, params):
1✔
69
        super().__init__()
×
70
        self.embeddings = embeddings
×
71
        self.algorithm = algorithm
×
72
        self.params = params
×
73

74
    def run(self):
1✔
75
        """Run clustering."""
76
        try:
×
77
            from vector_inspector.core.clustering import run_clustering
×
78

79
            labels, algorithm = run_clustering(self.embeddings, self.algorithm, self.params)
×
80
            self.finished.emit((labels, algorithm))
×
81
        except Exception as e:
×
82
            traceback.print_exc()
×
83
            self.error.emit(str(e))
×
84

85

86
class VisualizationDataLoadThread(QThread):
1✔
87
    """Background thread for loading visualization data."""
88

89
    finished = Signal(dict)  # data
1✔
90
    error = Signal(str)
1✔
91

92
    def __init__(self, connection, collection, sample_size, parent=None):
1✔
93
        super().__init__(parent)
×
94
        self.connection = connection
×
95
        self.collection = collection
×
96
        self.sample_size = sample_size
×
97

98
    def run(self):
1✔
99
        """Load data from collection."""
100
        try:
×
101
            if not self.connection:
×
102
                self.error.emit("No database connection available")
×
103
                return
×
104

105
            if self.sample_size is None:
×
106
                data = self.connection.get_all_items(self.collection)
×
107
            else:
108
                data = self.connection.get_all_items(self.collection, limit=self.sample_size)
×
109

110
            if data:
×
111
                self.finished.emit(data)
×
112
            else:
113
                self.error.emit("Failed to load data")
×
114
        except Exception as e:
×
115
            traceback.print_exc()
×
116
            self.error.emit(str(e))
×
117

118

119
class VisualizationView(QWidget):
1✔
120
    """View for visualizing vectors in 2D/3D using modular panels."""
121

122
    # Signal emitted when user wants to view a point in data browser
123
    view_in_data_browser_requested = Signal(str)  # item_id
1✔
124

125
    app_state: AppState
1✔
126
    task_runner: ThreadedTaskRunner
1✔
127
    cluster_runner: ClusterRunner
1✔
128

129
    def __init__(
1✔
130
        self,
131
        app_state: AppState,
132
        task_runner: ThreadedTaskRunner,
133
        connection_manager=None,
134
        parent=None,
135
    ):
136
        super().__init__(parent)
1✔
137

138
        # Store AppState and task runner
139
        self.app_state = app_state
1✔
140
        self.task_runner = task_runner
1✔
141
        self.cluster_runner = ClusterRunner()
1✔
142
        self.connection = self.app_state.provider
1✔
143

144
        self.current_collection = ""
1✔
145
        self.current_data = None
1✔
146
        self.reduced_data = None
1✔
147
        self.visualization_thread = None
1✔
148
        self.data_load_thread = None
1✔
149
        self.clustering_thread = None
1✔
150
        self.temp_html_files = []
1✔
151
        self.cluster_labels = None
1✔
152
        self._last_temp_html = None
1✔
153
        self.loading_dialog = LoadingDialog("Loading visualization...", self)
1✔
154
        self._connection_manager = connection_manager
1✔
155
        self._setup_ui()
1✔
156
        self._connect_plot_signals()
1✔
157

158
        # Connect to AppState signals
159
        self._connect_state_signals()
1✔
160
        # Update services with current connection if available
161
        if self.app_state.provider:
1✔
162
            self._on_provider_changed(self.app_state.provider)
1✔
163

164
    def _connect_state_signals(self) -> None:
1✔
165
        """Subscribe to AppState changes."""
166
        # React to connection changes
167
        self.app_state.provider_changed.connect(self._on_provider_changed)
1✔
168

169
        # React to collection changes
170
        self.app_state.collection_changed.connect(self._on_collection_changed)
1✔
171

172
        # React to loading state
173
        self.app_state.loading_started.connect(self._on_loading_started)
1✔
174
        self.app_state.loading_finished.connect(self._on_loading_finished)
1✔
175

176
        # React to errors
177
        self.app_state.error_occurred.connect(self._on_error)
1✔
178

179
    def _on_provider_changed(self, connection: Optional[ConnectionInstance]) -> None:
1✔
180
        """React to provider/connection change."""
181
        # Update connection
182
        self.connection = connection
1✔
183
        self.histogram_panel.set_connection(connection)
1✔
184

185
    def _on_collection_changed(self, collection: str) -> None:
1✔
186
        """React to collection change."""
187
        if collection:
×
188
            self.set_collection(collection)
×
189

190
    def _on_loading_started(self, message: str) -> None:
1✔
191
        """React to loading started."""
192
        self.loading_dialog.show_loading(message)
×
193

194
    def _on_loading_finished(self) -> None:
1✔
195
        """React to loading finished."""
196
        self.loading_dialog.hide()
×
197

198
    def _on_error(self, title: str, message: str) -> None:
1✔
199
        """React to error."""
200
        QMessageBox.critical(self, title, message)
×
201

202
    def _connect_plot_signals(self):
1✔
203
        """Connect plot panel signals."""
204
        self.plot_panel.view_in_data_browser.connect(self._on_view_in_data_browser)
1✔
205

206
    def _setup_ui(self):
1✔
207
        layout = QVBoxLayout(self)
1✔
208

209
        # Shared controls (sample size + use all data)
210
        shared_layout = QHBoxLayout()
1✔
211
        shared_layout.addWidget(QLabel("Sample size:"))
1✔
212
        self.sample_spin = QSpinBox()
1✔
213
        self.sample_spin.setMinimum(10)
1✔
214
        # Feature gating: limit sample size in free version
215
        if self.app_state.advanced_features_enabled:
1✔
216
            self.sample_spin.setMaximum(10000)
×
217
        else:
218
            self.sample_spin.setMaximum(500)
1✔
219
        self.sample_spin.setValue(500)
1✔
220
        self.sample_spin.setSingleStep(100)
1✔
221
        shared_layout.addWidget(self.sample_spin)
1✔
222
        self.use_all_checkbox = QCheckBox("Use all data")
1✔
223
        shared_layout.addWidget(self.use_all_checkbox)
1✔
224
        shared_layout.addStretch()
1✔
225
        layout.addLayout(shared_layout)
1✔
226

227
        # Feature gating: disable "Use all data" in free version
228
        if not self.app_state.advanced_features_enabled:
1✔
229
            self.use_all_checkbox.setEnabled(False)
1✔
230
            self.use_all_checkbox.setToolTip(self.app_state.get_feature_tooltip())
1✔
231

232
        def on_use_all_changed():
1✔
233
            self.sample_spin.setEnabled(not self.use_all_checkbox.isChecked())
×
234

235
        self.use_all_checkbox.stateChanged.connect(on_use_all_changed)
1✔
236

237
        # Modular panels
238
        self.clustering_panel = ClusteringPanel(self, app_state=self.app_state)
1✔
239
        self.dr_panel = DRPanel(self)
1✔
240
        self.plot_panel = PlotPanel(self)
1✔
241
        self.histogram_panel = HistogramPanel(self)
1✔
242
        self.histogram_panel.set_connection_manager(self._connection_manager)
1✔
243

244
        # Tab widget: Tab 1 = Visualization, Tab 2 = Distributions
245
        self.tab_widget = QTabWidget()
1✔
246

247
        viz_tab = QWidget()
1✔
248
        viz_layout = QVBoxLayout(viz_tab)
1✔
249
        viz_layout.setContentsMargins(0, 0, 0, 0)
1✔
250
        viz_layout.addWidget(self.clustering_panel)
1✔
251
        viz_layout.addWidget(self.dr_panel)
1✔
252
        viz_layout.addWidget(self.plot_panel, stretch=10)
1✔
253
        self.tab_widget.addTab(viz_tab, "Visualization")
1✔
254

255
        self.tab_widget.addTab(self.histogram_panel, "Distributions")
1✔
256

257
        layout.addWidget(self.tab_widget, stretch=10)
1✔
258

259
        self.status_label = QLabel("No collection selected")
1✔
260
        self.status_label.setStyleSheet("color: gray;")
1✔
261
        self.status_label.setMaximumHeight(30)
1✔
262
        layout.addWidget(self.status_label)
1✔
263

264
        # Connect DRPanel generate button
265
        self.dr_panel.generate_button.clicked.connect(self._generate_visualization)
1✔
266
        self.dr_panel.open_browser_button.clicked.connect(self._open_in_browser)
1✔
267

268
        # Connect ClusteringPanel run button
269
        self.clustering_panel.cluster_button.clicked.connect(self._run_clustering)
1✔
270

271
    def _generate_visualization(self):
1✔
272
        """Generate visualization of vectors."""
273
        # Disable browser button until plot is generated
274
        self.dr_panel.open_browser_button.setEnabled(False)
×
275

276
        if not self.current_collection:
×
277
            QMessageBox.warning(self, "No Collection", "Please select a collection first.")
×
278
            return
×
279

280
        if self.use_all_checkbox.isChecked():
×
281
            sample_size = None
×
282
        else:
283
            sample_size = self.sample_spin.value()
×
NEW
284
        self._last_sample_size = sample_size
×
285

286
        # Cancel any existing data load thread
287
        if self.data_load_thread and self.data_load_thread.isRunning():
×
288
            self.data_load_thread.quit()
×
289
            self.data_load_thread.wait()
×
290

291
        # Create and start data load thread
292
        self.data_load_thread = VisualizationDataLoadThread(
×
293
            self.connection,
294
            self.current_collection,
295
            sample_size,
296
            parent=self,
297
        )
298
        self.data_load_thread.finished.connect(self._on_data_loaded)
×
299
        self.data_load_thread.error.connect(self._on_data_load_error)
×
300

301
        # Show loading dialog during data load
302
        self.loading_dialog.show_loading("Loading data for visualization...")
×
303
        self.data_load_thread.start()
×
304

305
    def _on_data_loaded(self, data: dict) -> None:
1✔
306
        """Handle successful data load."""
307
        self.loading_dialog.hide_loading()
×
308

309
        if (
×
310
            data is None
311
            or not data
312
            or "embeddings" not in data
313
            or data["embeddings"] is None
314
            or len(data["embeddings"]) == 0
315
        ):
316
            QMessageBox.warning(
×
317
                self,
318
                "No Data",
319
                "No embeddings found in collection. Make sure the collection contains vector embeddings.",
320
            )
321
            return
×
322

323
        self.current_data = data
×
NEW
324
        self.histogram_panel.set_data(
×
325
            data,
326
            collection_name=self.current_collection,
327
            sample_size=getattr(self, "_last_sample_size", None),
328
        )
329
        self.status_label.setText("Reducing dimensions...")
×
330
        self.dr_panel.generate_button.setEnabled(False)
×
331

332
        # Get parameters
333
        method = self.dr_panel.method_combo.currentText().lower()
×
334
        if method == "t-sne":
×
335
            method = "tsne"
×
336
        n_components = 2 if self.dr_panel.dimensions_combo.currentText() == "2D" else 3
×
337

338
        # Run dimensionality reduction in background thread
339
        self.visualization_thread = VisualizationThread(data["embeddings"], method, n_components)
×
340
        self.visualization_thread.finished.connect(self._on_reduction_finished)
×
341
        self.visualization_thread.error.connect(self._on_reduction_error)
×
342
        # Show loading during reduction
343
        self.loading_dialog.show_loading("Reducing dimensions...")
×
344
        self.visualization_thread.start()
×
345

346
    def _on_data_load_error(self, error_message: str) -> None:
1✔
347
        """Handle data load error."""
348
        self.loading_dialog.hide_loading()
×
349
        QMessageBox.warning(
×
350
            self,
351
            "Load Error",
352
            f"Failed to load data: {error_message}",
353
        )
354

355
    def _on_reduction_finished(self, reduced_data: Any):
1✔
356
        """Handle dimensionality reduction completion."""
357
        self.loading_dialog.hide_loading()
×
358
        self.reduced_data = reduced_data
×
359
        self.plot_panel.create_plot(
×
360
            reduced_data=reduced_data,
361
            current_data=self.current_data,
362
            cluster_labels=self.cluster_labels,
363
            method_name=self.dr_panel.method_combo.currentText(),
364
        )
365
        self._save_temp_html()
×
366
        self.dr_panel.generate_button.setEnabled(True)
×
367
        self.dr_panel.open_browser_button.setEnabled(True)
×
368
        self.status_label.setText("Visualization complete")
×
369

370
    def _on_reduction_error(self, error_msg: str):
1✔
371
        """Handle dimensionality reduction error."""
372
        self.loading_dialog.hide_loading()
×
373
        log_error("Visualization failed: %s", error_msg)
×
374
        QMessageBox.warning(self, "Error", f"Visualization failed: {error_msg}")
×
375
        self.dr_panel.generate_button.setEnabled(True)
×
376
        self.status_label.setText("Visualization failed")
×
377

378
    def _save_temp_html(self):
1✔
379
        """Save current plot HTML to temp file for browser viewing."""
380
        html = self.plot_panel.get_current_html()
×
381
        if html:
×
382
            with tempfile.NamedTemporaryFile(delete=False, suffix=".html", mode="w", encoding="utf-8") as temp_file:
×
383
                temp_file.write(html)
×
384
                temp_file.flush()
×
385
                self.temp_html_files.append(temp_file.name)
×
386
                self._last_temp_html = temp_file.name
×
387

388
    def _open_in_browser(self):
1✔
389
        """Open the last generated plot in a web browser."""
390
        if self._last_temp_html:
×
391
            webbrowser.open(f"file://{self._last_temp_html}")
×
392

393
    def _run_clustering(self):
1✔
394
        """Run clustering on current data."""
395
        if not self.current_collection:
×
396
            QMessageBox.warning(self, "No Collection", "Please select a collection first.")
×
397
            return
×
398

399
        # Load data if not already loaded
400
        if self.current_data is None:
×
401
            if self.use_all_checkbox.isChecked():
×
402
                sample_size = None
×
403
            else:
404
                sample_size = self.sample_spin.value()
×
405

406
            # Cancel any existing data load thread
407
            if self.data_load_thread and self.data_load_thread.isRunning():
×
408
                self.data_load_thread.quit()
×
409
                self.data_load_thread.wait()
×
410

411
            # Create and start data load thread for clustering
412
            self.data_load_thread = VisualizationDataLoadThread(
×
413
                self.connection,
414
                self.current_collection,
415
                sample_size,
416
                parent=self,
417
            )
418
            self.data_load_thread.finished.connect(self._on_clustering_data_loaded)
×
419
            self.data_load_thread.error.connect(self._on_data_load_error)
×
420

421
            # Show loading dialog during data load
422
            self.loading_dialog.show_loading("Loading data for clustering...")
×
423
            self.data_load_thread.start()
×
424
        else:
425
            # Data already loaded, proceed with clustering
426
            self._start_clustering()
×
427

428
    def _on_clustering_data_loaded(self, data: dict) -> None:
1✔
429
        """Handle successful data load for clustering."""
430
        self.loading_dialog.hide_loading()
×
431

432
        if (
×
433
            data is None
434
            or not data
435
            or "embeddings" not in data
436
            or data["embeddings"] is None
437
            or len(data["embeddings"]) == 0
438
        ):
439
            QMessageBox.warning(
×
440
                self,
441
                "No Data",
442
                "No embeddings found in collection.",
443
            )
444
            return
×
445

446
        self.current_data = data
×
NEW
447
        self.histogram_panel.set_data(
×
448
            data,
449
            collection_name=self.current_collection,
450
            sample_size=getattr(self, "_last_sample_size", None),
451
        )
UNCOV
452
        self._start_clustering()
×
453

454
    def _start_clustering(self) -> None:
1✔
455
        """Start clustering with already loaded data."""
456
        # Get algorithm and parameters from panel
457
        algorithm = self.clustering_panel.cluster_algorithm_combo.currentText()
×
458
        params = self.clustering_panel.get_clustering_params()
×
459

460
        # Run clustering in background thread
461
        self.loading_dialog.show_loading("Running clustering...")
×
462
        self.clustering_panel.cluster_button.setEnabled(False)
×
463

464
        self.clustering_thread = ClusteringThread(self.current_data["embeddings"], algorithm, params)
×
465
        self.clustering_thread.finished.connect(self._on_clustering_finished)
×
466
        self.clustering_thread.error.connect(self._on_clustering_error)
×
467
        self.clustering_thread.start()
×
468

469
    def _on_clustering_finished(self, result):
1✔
470
        """Handle clustering completion."""
471
        self.loading_dialog.hide_loading()
×
472
        labels, algo = result
×
473
        self.cluster_labels = labels
×
474

475
        # Count clusters
476
        unique_labels = set(self.cluster_labels)
×
477
        # Update clustering result label in panel
478
        if algo in ["HDBSCAN", "DBSCAN", "OPTICS"]:
×
479
            n_clusters = len([label for label in unique_labels if label != -1])
×
480
            n_noise = list(self.cluster_labels).count(-1)
×
481
            msg = f"Found {n_clusters} clusters, {n_noise} noise points"
×
482
        else:
483
            n_clusters = len(unique_labels)
×
484
            msg = f"Found {n_clusters} clusters"
×
485

486
        self.clustering_panel.cluster_result_label.setText(msg)
×
487
        self.clustering_panel.cluster_result_label.setVisible(True)
×
488
        self.status_label.setText(msg)
×
489
        self.status_label.setStyleSheet("color: green;")
×
490
        self.clustering_panel.cluster_button.setEnabled(True)
×
491

492
        # Save cluster labels to metadata if checkbox is checked
493
        if self.clustering_panel.save_to_metadata_checkbox.isChecked():
×
494
            self._save_cluster_labels_to_metadata()
×
495

496
        # Recreate plot with cluster colors if we have reduced data
497
        if self.reduced_data is not None:
×
498
            self.plot_panel.create_plot(
×
499
                reduced_data=self.reduced_data,
500
                current_data=self.current_data,
501
                cluster_labels=self.cluster_labels,
502
                method_name=self.dr_panel.method_combo.currentText(),
503
            )
504
            self._save_temp_html()
×
505

506
    def _save_cluster_labels_to_metadata(self):
1✔
507
        """Save cluster labels to item metadata in the database."""
508
        if not self.current_data or not self.cluster_labels.any():
1✔
509
            return
1✔
510

511
        if not self.connection:
1✔
512
            log_error("Cannot save cluster labels: no database connection")
1✔
513
            return
1✔
514

515
        if not self.current_collection:
1✔
516
            log_error("Cannot save cluster labels: no collection selected")
1✔
517
            return
1✔
518

519
        try:
1✔
520
            from datetime import datetime
1✔
521

522
            ids = self.current_data.get("ids", [])
1✔
523
            metadatas = self.current_data.get("metadatas", [])
1✔
524

525
            # Update metadata with cluster labels
526
            updated_metadatas = []
1✔
527
            for i, (item_id, metadata) in enumerate(zip(ids, metadatas)):
1✔
528
                if i >= len(self.cluster_labels):
1✔
529
                    break
×
530

531
                # Create a copy of metadata to avoid modifying original
532
                updated_meta = dict(metadata) if metadata else {}
1✔
533
                updated_meta["cluster"] = int(self.cluster_labels[i])
1✔
534
                updated_meta["updated_at"] = datetime.now(UTC).isoformat()
1✔
535
                updated_metadatas.append(updated_meta)
1✔
536

537
            # Batch update all items with new cluster metadata
538
            success = self.connection.update_items(
1✔
539
                self.current_collection,
540
                ids=ids[: len(updated_metadatas)],
541
                metadatas=updated_metadatas,
542
            )
543

544
            if success:
1✔
545
                log_info("Successfully saved %d cluster labels to metadata", len(updated_metadatas))
1✔
546
                # Update local cache
547
                self.current_data["metadatas"] = updated_metadatas
1✔
548
            else:
549
                log_error("Failed to save cluster labels to metadata")
1✔
550
                QMessageBox.warning(
1✔
551
                    self,
552
                    "Warning",
553
                    "Clustering complete, but failed to save cluster labels to metadata.",
554
                )
555
        except Exception as e:
×
556
            log_error("Error saving cluster labels to metadata: %s", e)
×
557
            QMessageBox.warning(self, "Warning", f"Clustering complete, but error saving labels to metadata: {e!s}")
×
558

559
    def _on_clustering_error(self, error_msg: str):
1✔
560
        """Handle clustering error."""
561
        self.loading_dialog.hide_loading()
×
562
        log_error("Clustering failed: %s", error_msg)
×
563
        QMessageBox.warning(self, "Error", f"Clustering failed: {error_msg}")
×
564
        self.clustering_panel.cluster_button.setEnabled(True)
×
565
        self.status_label.setText("Clustering failed")
×
566

567
    def set_collection(self, collection_name: str):
1✔
568
        """Set the current collection to visualize."""
569
        self.current_collection = collection_name
×
570
        self.current_data = None
×
571
        self.reduced_data = None
×
572
        self.cluster_labels = None
×
573
        # Clear clustering results when switching collection/provider
574
        try:
×
575
            if hasattr(self, "clustering_panel") and hasattr(self.clustering_panel, "cluster_result_label"):
×
576
                self.clustering_panel.cluster_result_label.setVisible(False)
×
577
                self.clustering_panel.cluster_result_label.setText("")
×
578
        except Exception:
×
579
            pass
×
580

581
        self.status_label.setText(f"Collection: {collection_name}")
×
582

583
    def _on_view_in_data_browser(self, _point_index: int, point_id: str):
1✔
584
        """Handle button click to view selected point in data browser.
585

586
        Args:
587
            _point_index: Index of the selected point (unused)
588
            point_id: ID of the selected point
589
        """
590
        if point_id:
1✔
591
            self.view_in_data_browser_requested.emit(point_id)
1✔
592

593
    def cleanup_temp_html(self):
1✔
594
        """Clean up temporary HTML files."""
595
        import contextlib
×
596
        import os
×
597

598
        for f in getattr(self, "temp_html_files", []):
×
599
            with contextlib.suppress(Exception):
×
600
                os.remove(f)
×
601
        self.temp_html_files = []
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc