LibViz 0.1.0__tar.gz

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 (33) hide show
  1. libviz-0.1.0/PKG-INFO +23 -0
  2. libviz-0.1.0/README.md +1 -0
  3. libviz-0.1.0/pyproject.toml +56 -0
  4. libviz-0.1.0/src/libviz/__init__.py +0 -0
  5. libviz-0.1.0/src/libviz/bridge/__init__.py +1 -0
  6. libviz-0.1.0/src/libviz/bridge/library_manager_bridge.py +149 -0
  7. libviz-0.1.0/src/libviz/core/__init__.py +1 -0
  8. libviz-0.1.0/src/libviz/core/db/__init__.py +1 -0
  9. libviz-0.1.0/src/libviz/core/db/extensions.py +3 -0
  10. libviz-0.1.0/src/libviz/core/db/migrations.py +27 -0
  11. libviz-0.1.0/src/libviz/core/db/models/__init__.py +5 -0
  12. libviz-0.1.0/src/libviz/core/db/models/adducts_type.py +56 -0
  13. libviz-0.1.0/src/libviz/core/db/models/compound.py +25 -0
  14. libviz-0.1.0/src/libviz/core/db/models/library_metadata.py +21 -0
  15. libviz-0.1.0/src/libviz/core/db/models/spectrum.py +89 -0
  16. libviz-0.1.0/src/libviz/core/db/models/spectrum_metadata.py +14 -0
  17. libviz-0.1.0/src/libviz/core/library.py +398 -0
  18. libviz-0.1.0/src/libviz/core/managers/__init__.py +1 -0
  19. libviz-0.1.0/src/libviz/core/managers/library_manager.py +93 -0
  20. libviz-0.1.0/src/libviz/core/readers/__init__.py +1 -0
  21. libviz-0.1.0/src/libviz/core/readers/msp_reader.py +211 -0
  22. libviz-0.1.0/src/libviz/core/utils/__init__.py +3 -0
  23. libviz-0.1.0/src/libviz/core/utils/library_io_utils.py +52 -0
  24. libviz-0.1.0/src/libviz/core/utils/logging_utils.py +25 -0
  25. libviz-0.1.0/src/libviz/core/utils/mass_calulation_utils.py +58 -0
  26. libviz-0.1.0/src/libviz/gui/Libviz/Pages/LibraryHomePage.qml +88 -0
  27. libviz-0.1.0/src/libviz/gui/Libviz/Pages/WelcomePage.qml +27 -0
  28. libviz-0.1.0/src/libviz/gui/Libviz/Pages/qmldir +4 -0
  29. libviz-0.1.0/src/libviz/gui/__init__.py +0 -0
  30. libviz-0.1.0/src/libviz/gui/libviz_resources.py +369 -0
  31. libviz-0.1.0/src/libviz/gui/main.qml +150 -0
  32. libviz-0.1.0/src/libviz/gui/resources.qrc +8 -0
  33. libviz-0.1.0/src/libviz/main.py +44 -0
libviz-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.1
2
+ Name: LibViz
3
+ Version: 0.1.0
4
+ Summary: App to visualize MS library spectra
5
+ Author-Email: Matteo Miotto <miotsdata@gmail.com>
6
+ License: MIT
7
+ Requires-Python: ==3.13
8
+ Requires-Dist: sqlalchemy>=2.0.46
9
+ Requires-Dist: alembic>=1.18.4
10
+ Requires-Dist: pandas>=3.0.0
11
+ Requires-Dist: numpy>=2.4.2
12
+ Requires-Dist: pyteomics>=4.7.5
13
+ Requires-Dist: tqdm>=4.67.3
14
+ Requires-Dist: lxml>=6.0.2
15
+ Provides-Extra: gui
16
+ Requires-Dist: pyside6>=6.10.2; extra == "gui"
17
+ Requires-Dist: pyqt6>=6.10.2; extra == "gui"
18
+ Requires-Dist: PySide6-Essentials>=6.10.2; extra == "gui"
19
+ Provides-Extra: all
20
+ Requires-Dist: libviz[gui]; extra == "all"
21
+ Description-Content-Type: text/markdown
22
+
23
+ # LibViz
libviz-0.1.0/README.md ADDED
@@ -0,0 +1 @@
1
+ # LibViz
@@ -0,0 +1,56 @@
1
+ [project]
2
+ name = "LibViz"
3
+ version = "0.1.0"
4
+ description = "App to visualize MS library spectra"
5
+ authors = [
6
+ { name = "Matteo Miotto", email = "miotsdata@gmail.com" },
7
+ ]
8
+ dependencies = [
9
+ "sqlalchemy>=2.0.46",
10
+ "alembic>=1.18.4",
11
+ "pandas>=3.0.0",
12
+ "numpy>=2.4.2",
13
+ "pyteomics>=4.7.5",
14
+ "tqdm>=4.67.3",
15
+ "lxml>=6.0.2",
16
+ ]
17
+ requires-python = "==3.13"
18
+ readme = "README.md"
19
+
20
+ [project.license]
21
+ text = "MIT"
22
+
23
+ [project.optional-dependencies]
24
+ gui = [
25
+ "pyside6>=6.10.2",
26
+ "pyqt6>=6.10.2",
27
+ "PySide6-Essentials>=6.10.2",
28
+ ]
29
+ all = [
30
+ "libviz[gui]",
31
+ ]
32
+
33
+ [build-system]
34
+ requires = [
35
+ "pdm-backend",
36
+ ]
37
+ build-backend = "pdm.backend"
38
+
39
+ [tool.pdm]
40
+ distribution = true
41
+
42
+ [tool.pdm.build]
43
+ excludes = [
44
+ "src/libviz/old",
45
+ "tests",
46
+ "**/.DS_Store",
47
+ ]
48
+
49
+ [dependency-groups]
50
+ dev = [
51
+ "pytest>=9.0.2",
52
+ "pylint>=4.0.4",
53
+ "pyside6-stubs>=6.7.3.0",
54
+ "hupper>=1.12.1",
55
+ "pyinstaller>=6.19.0",
56
+ ]
File without changes
@@ -0,0 +1 @@
1
+ from .library_manager_bridge import LibraryManagerBridge
@@ -0,0 +1,149 @@
1
+ # libviz/bridge/library_manager_bridge.py
2
+
3
+ from PySide6.QtCore import QObject, Signal, Slot, Property, QThread, QUrl # pylint: disable=no-name-in-module
4
+ from libviz.core import LibraryManager
5
+
6
+
7
+ class _MspLoaderWorker(QObject):
8
+ """Runs in a separate thread — no Qt UI calls allowed here."""
9
+ finished = Signal(str) # emits path on success
10
+ failed = Signal(str) # emits error message on failure
11
+
12
+ def __init__(self, manager: LibraryManager, msp_path: str):
13
+ super().__init__()
14
+ self._manager = manager
15
+ self._msp_path = msp_path
16
+
17
+ @Slot()
18
+ def run(self):
19
+ try:
20
+ self._manager.load_library_from_msp(self._msp_path)
21
+ self.finished.emit(self._msp_path)
22
+ except Exception as e:
23
+ self.failed.emit(str(e))
24
+
25
+
26
+ class LibraryManagerBridge(QObject):
27
+
28
+ # --- Signals ---
29
+ libraryLoaded = Signal(str)
30
+ libraryUnloaded = Signal(str)
31
+ selectedLibraryChanged = Signal()
32
+ libraryCountChanged = Signal()
33
+ errorOccurred = Signal(str)
34
+ statusChanged = Signal(str)
35
+
36
+ def __init__(self, manager: LibraryManager, parent=None):
37
+ super().__init__(parent)
38
+ self._manager = manager
39
+ self._thread: QThread | None = None
40
+ self._worker: _MspLoaderWorker | None = None
41
+
42
+ # --- Internal helpers ---
43
+
44
+ def _set_status(self, message: str):
45
+ self.statusChanged.emit(message)
46
+
47
+ def _clear_status(self):
48
+ self.statusChanged.emit("")
49
+
50
+ # --- Slots ---
51
+
52
+ @Slot(str, str)
53
+ def createLibrary(self, name: str, path: str):
54
+ try:
55
+ self._set_status(f"Creating: {path}")
56
+ self._manager.create_library(name, path)
57
+ self.libraryCountChanged.emit()
58
+ self.libraryLoaded.emit(path)
59
+ except Exception as e:
60
+ self.errorOccurred.emit(str(e))
61
+ finally:
62
+ self._clear_status()
63
+
64
+ @Slot(str)
65
+ def loadLibraryFromMsp(self, msp_path: str):
66
+ """Offloads the heavy parse to a QThread; status bar shown until done."""
67
+ if self._thread and self._thread.isRunning():
68
+ self.errorOccurred.emit("An MSP load is already in progress.")
69
+ return
70
+
71
+ msp_path = QUrl(msp_path).toLocalFile()
72
+
73
+ self._set_status(f"Loading: {msp_path}")
74
+
75
+ self._worker = _MspLoaderWorker(self._manager, msp_path)
76
+ self._thread = QThread(self)
77
+ self._worker.moveToThread(self._thread)
78
+
79
+ # Wire up lifecycle
80
+ self._thread.started.connect(self._worker.run)
81
+ self._worker.finished.connect(self._on_msp_loaded)
82
+ self._worker.failed.connect(self._on_msp_failed)
83
+
84
+ # Clean up thread and worker after work is done
85
+ self._worker.finished.connect(self._thread.quit)
86
+ self._worker.failed.connect(self._thread.quit)
87
+ self._thread.finished.connect(self._worker.deleteLater)
88
+ self._thread.finished.connect(self._thread.deleteLater)
89
+
90
+ self._thread.start()
91
+
92
+ def _on_msp_loaded(self, path: str):
93
+ self._clear_status()
94
+ self.libraryCountChanged.emit()
95
+ self.libraryLoaded.emit(path)
96
+
97
+ def _on_msp_failed(self, message: str):
98
+ self._clear_status()
99
+ self.errorOccurred.emit(message)
100
+
101
+ @Slot(str)
102
+ def loadLibraryFromDb(self, path: str):
103
+ try:
104
+ self._set_status(f"Loading: {path}")
105
+ self._manager.load_library_from_db(path)
106
+ self.libraryCountChanged.emit()
107
+ self.libraryLoaded.emit(path)
108
+ except Exception as e:
109
+ self.errorOccurred.emit(str(e))
110
+ finally:
111
+ self._clear_status()
112
+
113
+ @Slot(str)
114
+ def saveAsDb(self, path: str):
115
+ try:
116
+ self._set_status(f"Saving: {path}")
117
+ self._manager.save_as_db(path)
118
+ except Exception as e:
119
+ self.errorOccurred.emit(str(e))
120
+ finally:
121
+ self._clear_status()
122
+
123
+ @Slot(str)
124
+ def unloadLibrary(self, path: str):
125
+ try:
126
+ self._manager.unload_library(path)
127
+ self.libraryCountChanged.emit()
128
+ self.libraryUnloaded.emit(path)
129
+ except Exception as e:
130
+ self.errorOccurred.emit(str(e))
131
+
132
+ @Slot(str)
133
+ def selectLibrary(self, path: str):
134
+ self._manager._selected_library = path
135
+ self.selectedLibraryChanged.emit()
136
+
137
+ # --- Properties ---
138
+
139
+ @Property(bool, notify=libraryCountChanged)
140
+ def hasLibraries(self) -> bool:
141
+ return self._manager.has_libraries
142
+
143
+ @Property(int, notify=libraryCountChanged)
144
+ def libraryCount(self) -> int:
145
+ return self._manager.library_count
146
+
147
+ @Property(str, notify=selectedLibraryChanged)
148
+ def selectedLibraryKey(self) -> str:
149
+ return self._manager.selected_library_key or ""
@@ -0,0 +1 @@
1
+ from .managers import LibraryManager
@@ -0,0 +1 @@
1
+ from .extensions import Base
@@ -0,0 +1,3 @@
1
+ from sqlalchemy.orm import declarative_base
2
+
3
+ Base = declarative_base()
@@ -0,0 +1,27 @@
1
+ import sys
2
+ from pathlib import Path
3
+ from alembic.config import Config
4
+ from alembic import command
5
+
6
+ def get_bundle_dir():
7
+ """Get the path to the app directory, even if frozen by PyInstaller."""
8
+ if getattr(sys, 'frozen', False):
9
+ # Running in a bundle (.exe / .app)
10
+ return Path(sys._MEIPASS)
11
+ # Running in normal Python environment
12
+ return Path(__file__).parent.parent.parent.parent.parent
13
+
14
+ def upgrade_library_schema(db_path: Path):
15
+ bundle_dir = get_bundle_dir()
16
+ ini_path = bundle_dir / "alembic.ini"
17
+
18
+ alembic_cfg = Config(str(ini_path))
19
+
20
+ # CRITICAL: Tell Alembic where the versions folder is inside the bundle
21
+ # This overrides the 'script_location' in alembic.ini
22
+ script_location = bundle_dir / "migrations"
23
+ alembic_cfg.set_main_option("script_location", str(script_location))
24
+
25
+ alembic_cfg.set_main_option("sqlalchemy.url", f"sqlite:///{db_path}")
26
+
27
+ command.upgrade(alembic_cfg, "head")
@@ -0,0 +1,5 @@
1
+ from .compound import Compound
2
+ from .spectrum import Spectrum, calculate_spectrum_hash
3
+ from .adducts_type import AdductType, populate_adduct_types
4
+ from .spectrum_metadata import SpectrumMetadata
5
+ from .library_metadata import LibraryMetadata
@@ -0,0 +1,56 @@
1
+ from sqlalchemy import Column, Integer, String, Float
2
+ from sqlalchemy.orm import relationship
3
+
4
+ from libviz.core.db import Base
5
+
6
+ PROTON = 1.007276466812
7
+ ELECTRON = 0.000548579909065 # optional, often ignored
8
+
9
+ COMMON_ADDUCTS = [
10
+ # Positive mode
11
+ {"formula": "[M+H]+", "charge": 1, "delta": PROTON, "multiplication_factor": 1},
12
+ {"formula": "[M+Na]+", "charge": 1, "delta": 22.989218, "multiplication_factor": 1},
13
+ {"formula": "[M+K]+", "charge": 1, "delta": 38.963158, "multiplication_factor": 1},
14
+ {"formula": "[M+NH4]+", "charge": 1, "delta": 18.033823, "multiplication_factor": 1},
15
+
16
+ {"formula": "[M+2H]2+", "charge": 2, "delta": 2 * PROTON, "multiplication_factor": 1},
17
+
18
+ # Negative mode
19
+ {"formula": "[M-H]-", "charge": -1, "delta": -PROTON, "multiplication_factor": 1},
20
+ {"formula": "[M+Cl]-", "charge": -1, "delta": 34.969402, "multiplication_factor": 1},
21
+ {"formula": "[M+FA-H]-", "charge": -1, "delta": 44.998201, "multiplication_factor": 1},
22
+ ]
23
+
24
+ class AdductType(Base):
25
+ __tablename__ = "adduct_type"
26
+
27
+ id = Column(Integer, primary_key=True)
28
+
29
+ formula = Column(String, unique=True)
30
+ charge = Column(Integer, nullable=False, index=True)
31
+ delta_mass = Column(Float, index=True)
32
+ multiplication_factor = Column(Integer, default=1, nullable=False, index=True)
33
+
34
+ spectra = relationship("Spectrum", back_populates="adduct_type")
35
+
36
+
37
+ def populate_adduct_types(session):
38
+
39
+ for p in COMMON_ADDUCTS:
40
+ exists = session.query(AdductType)\
41
+ .filter_by(formula=p["formula"])\
42
+ .one_or_none()
43
+
44
+ if exists:
45
+ continue
46
+
47
+ session.add(
48
+ AdductType(
49
+ formula=p["formula"],
50
+ charge=p["charge"],
51
+ delta_mass=p["delta"],
52
+ multiplication_factor=p["multiplication_factor"]
53
+ )
54
+ )
55
+
56
+ session.commit()
@@ -0,0 +1,25 @@
1
+ from sqlalchemy import Column, Integer, String, Float, DateTime
2
+ from sqlalchemy.orm import relationship
3
+ from datetime import datetime
4
+
5
+ from libviz.core.db import Base
6
+
7
+ class Compound(Base):
8
+ __tablename__ = "compound"
9
+
10
+ id = Column(Integer, primary_key=True)
11
+
12
+ name = Column(String(collation="NOCASE"), index=True)
13
+ formula = Column(String, index=True)
14
+ exact_mass = Column(Float, index=True)
15
+ inchikey = Column(String, unique=True, nullable=False)
16
+ smiles = Column(String, index=True, unique=True)
17
+ pubchem_id = Column(Integer, index=True, unique=True)
18
+ added_on = Column(
19
+ DateTime(timezone=True),
20
+ default=datetime.now,
21
+ nullable=False
22
+ )
23
+
24
+
25
+ spectra = relationship("Spectrum", back_populates="compound")
@@ -0,0 +1,21 @@
1
+ from sqlalchemy import Column, Integer, String, DateTime
2
+ from datetime import datetime
3
+
4
+ from libviz.core.db import Base
5
+
6
+ class LibraryMetadata(Base):
7
+ __tablename__ = "library_metadata"
8
+
9
+ id = Column(Integer, primary_key=True)
10
+ name = Column(String, nullable=False)
11
+ added_on = Column(
12
+ DateTime(timezone=True),
13
+ default=datetime.now,
14
+ nullable=False
15
+ )
16
+ last_updated_on = Column(
17
+ DateTime(timezone=True),
18
+ default=datetime.now,
19
+ onupdate=datetime.now,
20
+ nullable=False
21
+ )
@@ -0,0 +1,89 @@
1
+ import hashlib
2
+ import numpy as np
3
+
4
+ from sqlalchemy import CheckConstraint, Column, Integer, String, Float, DateTime, ForeignKey, LargeBinary, UniqueConstraint
5
+ from sqlalchemy.orm import relationship
6
+ from datetime import datetime
7
+
8
+ from libviz.core.db import Base
9
+
10
+ class Spectrum(Base):
11
+ __tablename__ = "spectrum"
12
+
13
+ id = Column(Integer, primary_key=True)
14
+ compound_id = Column(Integer, ForeignKey("compound.id"), nullable=False, index=True)
15
+ precursor_mz = Column(Float, index=True, nullable=False)
16
+ adduct_type_id = Column(Integer, ForeignKey("adduct_type.id"), nullable=False, index=True)
17
+ polarity = Column(String, CheckConstraint("polarity IN ('POSITIVE', 'NEGATIVE')"), nullable=True, index=True)
18
+ collision_energy = Column(Float, index=True, nullable=False)
19
+ mz_array = Column(LargeBinary, nullable=False)
20
+ intensity_array = Column(LargeBinary, nullable=False)
21
+ added_on = Column(
22
+ DateTime(timezone=True),
23
+ default=datetime.now,
24
+ nullable=False
25
+ )
26
+ spectrum_hash = Column(String(64), nullable=False, index=True)
27
+
28
+ compound = relationship("Compound", back_populates="spectra")
29
+ adduct_type = relationship("AdductType", back_populates="spectra")
30
+ spectrum_metadata = relationship(
31
+ "SpectrumMetadata",
32
+ back_populates="spectrum",
33
+ cascade="all, delete-orphan"
34
+ )
35
+
36
+ __table_args__ = (
37
+ UniqueConstraint(
38
+ "compound_id",
39
+ "adduct_type_id",
40
+ "polarity",
41
+ "collision_energy",
42
+ "spectrum_hash",
43
+ name="uq_spectrum_identity"
44
+ ),
45
+ )
46
+
47
+ @classmethod
48
+ def from_arrays(
49
+ cls,
50
+ *,
51
+ compound_id: int,
52
+ precursor_mz: float,
53
+ adduct_type_id: int,
54
+ polarity: str,
55
+ collision_energy: float,
56
+ mz: np.ndarray,
57
+ intensity: np.ndarray,
58
+ ):
59
+
60
+ mz = np.asarray(mz, dtype="<f4")
61
+ intensity = np.asarray(intensity, dtype="<f4")
62
+ order = np.argsort(mz)
63
+ mz = np.round(mz[order], 4)
64
+ intensity = np.round(intensity[order], 2)
65
+
66
+
67
+ return cls(
68
+ compound_id=compound_id,
69
+ precursor_mz=precursor_mz,
70
+ adduct_type_id=adduct_type_id,
71
+ polarity=polarity,
72
+ collision_energy=collision_energy,
73
+ mz_array=mz.tobytes(),
74
+ intensity_array=intensity.tobytes(),
75
+ spectrum_hash=calculate_spectrum_hash(mz, intensity),
76
+ )
77
+
78
+ def mz_numpy(self) -> np.ndarray:
79
+ return np.frombuffer(self.mz_array, dtype="<f4")
80
+
81
+ def intensity_numpy(self) -> np.ndarray:
82
+ return np.frombuffer(self.intensity_array, dtype="<f4")
83
+
84
+
85
+ def calculate_spectrum_hash(mz: np.ndarray, intensity: np.ndarray) -> str:
86
+ h = hashlib.sha256()
87
+ h.update(np.asarray(mz, dtype="<f4").tobytes())
88
+ h.update(np.asarray(intensity, dtype="<f4").tobytes())
89
+ return h.hexdigest()
@@ -0,0 +1,14 @@
1
+ from sqlalchemy import Column, Integer, String, ForeignKey
2
+ from sqlalchemy.orm import relationship
3
+
4
+ from libviz.core.db import Base
5
+
6
+ class SpectrumMetadata(Base):
7
+ __tablename__ = "spectrum_metadata"
8
+
9
+ id = Column(Integer, primary_key=True)
10
+ spectrum_id = Column(Integer, ForeignKey("spectrum.id"), nullable=False, index=True)
11
+ key = Column(String, nullable=False, index=True)
12
+ value = Column(String, nullable=False)
13
+
14
+ spectrum = relationship("Spectrum", back_populates="spectrum_metadata")