Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
9b67b5e
Add repository audit findings report
claude Jul 14, 2026
afdfbd0
Fix 5 high-severity bugs from repo audit (H1-H5)
claude Jul 14, 2026
e6f77e5
Fix M1 (element coverage) and M2 (post-HF method misrouting)
claude Jul 14, 2026
eef6323
Fix M3 (UHF dipole/Mulliken) and M4 (NMR reference provenance)
claude Jul 14, 2026
a1dd342
Fix M5: IR-intensity inner-loop dm0/spin dispatch mismatch
claude Jul 14, 2026
18e368c
Fix M6 (PES scan NaN/trajectory handling) and M7 (broken angle/dihedr…
claude Jul 14, 2026
a50c509
Fix M8 (event log race/cost) and M9 (save_thumbnail exception guard)
claude Jul 14, 2026
60a21d8
Fix M10 (PubChem client edge cases) and M11 (export filename sanitiza…
claude Jul 14, 2026
5ae8431
Fix M12 (markdown-bold-to-HTML mangling in method notes)
claude Jul 14, 2026
4b4c392
Fix M13 (quantui CLI eagerly importing the full GUI stack)
claude Jul 14, 2026
6b0a0fb
Fix M14 (logging.basicConfig() at library import time)
claude Jul 14, 2026
6d2c171
Fix Low-severity data/doc nits (basis table, constants, docstrings)
claude Jul 14, 2026
89e79eb
Add exception chaining at 12 raise-in-except sites (ruff B904)
claude Jul 14, 2026
5dafa73
HTML-escape StepProgress labels/messages
claude Jul 14, 2026
14c27ce
Coerce all save_result numeric/boolean fields through JSON-safe helpers
claude Jul 14, 2026
5caa6cb
Mark audit findings as fixed/deferred in AUDIT_FINDINGS.md
claude Jul 14, 2026
a80d936
Fix list_results() lexicographic collision-suffix sort order
claude Jul 14, 2026
2632432
Cache perf log parsing in calc_log._read_all()
claude Jul 14, 2026
ba10d33
Make NMR pyscf.prop.nmr compat patches idempotent
claude Jul 14, 2026
8d6995d
Allow single-atom molecules in parse_xyz_input
claude Jul 14, 2026
bf97cad
Add Python 3.9 to CI test matrix
claude Jul 14, 2026
ac44200
Mark 5 more Low-severity findings as fixed in AUDIT_FINDINGS.md
claude Jul 14, 2026
2fd2427
Fix CI failures: black formatting + Python 3.9 ASE compatibility
claude Jul 14, 2026
4dc772c
Update AUDIT_FINDINGS.md: Python 3.9 CI item verified, real bug found…
claude Jul 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11"]
python-version: ["3.9", "3.10", "3.11"]

steps:
- uses: actions/checkout@v4
Expand Down
185 changes: 185 additions & 0 deletions AUDIT_FINDINGS.md

Large diffs are not rendered by default.

9 changes: 5 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,11 @@ quantui = [
[project.optional-dependencies]
# PySCF requires Linux/macOS/WSL — not available on Windows natively.
# Use the Apptainer container (apptainer/quantui.def) for Windows.
# pyscf>=2.13.0: pyscf.prop.infrared is in pyscf 2.13.0+ core (creates the
# pyscf.prop namespace that infrared lives under).
# pyscf-properties: provides additional pyscf.prop.* modules (EFG, IR, etc.).
# NMR is accessed via pyscf.nmr (core, not overwritten by pyscf-properties).
# pyscf.prop.infrared is NOT in pyscf/pyscf-properties as of pyscf 2.13 —
# freq_calc.py computes IR intensities itself via finite-difference dipole
# displacement instead (see the comment there). NMR is NOT accessed via
# pyscf.nmr (that module doesn't exist in released pyscf); it's
# pyscf.prop.nmr, provided by pyscf-properties below.
pyscf = [
"pyscf>=2.13.0",
"pyscf-properties",
Expand Down
36 changes: 26 additions & 10 deletions quantui/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
__version__ = "0.4.0"

import logging
from typing import Any

logging.getLogger(__name__).addHandler(logging.NullHandler())

Expand Down Expand Up @@ -40,9 +41,6 @@
VALID_ATOMS,
WIDGET_LAYOUT,
)

# Educational help content (requires ipywidgets at display time)
from .help_content import HELP_TOPICS, VALID_TOPICS, help_panel
from .molecule import Molecule, parse_xyz_input

# Orbital visualization (matplotlib energy diagrams, cube-file viewer)
Expand All @@ -55,9 +53,6 @@
plot_orbital_diagram,
)

# Progress indicators
from .progress import StepProgress

# Security — catchable exception for constraint violations
from .security import SecurityError
from .utils import (
Expand Down Expand Up @@ -169,10 +164,31 @@
VISUALIZATION_AVAILABLE = False
PY3DMOL_AVAILABLE = False

# App class — imported last so all package symbols are defined first.
# app.py imports from submodules directly, but placing this last is an
# extra safeguard against accidental circular-import issues in the future.
from .app import QuantUIApp
# App class, StepProgress, and help_content are resolved lazily via
# module __getattr__ (PEP 562) below. All three unconditionally pull in
# ipywidgets (app.py additionally pulls in the rest of the GUI stack), and
# eagerly importing them here defeats lightweight consumers like
# ``quantui.cli`` that only need pure-Python submodules (calc_log,
# analytics, gpu_offload) — see cli.py's module docstring.
_LAZY_ATTRS = {
"QuantUIApp": (".app", "QuantUIApp"),
"StepProgress": (".progress", "StepProgress"),
"HELP_TOPICS": (".help_content", "HELP_TOPICS"),
"VALID_TOPICS": (".help_content", "VALID_TOPICS"),
"help_panel": (".help_content", "help_panel"),
}


def __getattr__(name: str) -> Any:
target = _LAZY_ATTRS.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
module_name, attr_name = target
import importlib

module = importlib.import_module(module_name, __name__)
return getattr(module, attr_name)


__all__ = [
# Config constants
Expand Down
17 changes: 17 additions & 0 deletions quantui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -709,6 +709,22 @@ def write(self, text: str) -> None:
def flush(self) -> None:
pass

def close(self) -> None:
"""No-op — required so ASE treats this as an already-open stream.

ASE's ``IOContext.openfile()`` (used by ``BFGS(..., logfile=...)``
in optimizer.py / pes_scan.py) checks ``hasattr(file, "close")`` to
decide whether *file* is an already-open, file-like object it
should leave alone, vs. a path string it should ``open()`` itself.
Without this method, ase>=3.22 (the floor this project pins) still
happened to work via a later refactor's more lenient check, but
ase==3.26.0 (the newest version pip resolves for Python 3.9) hits
the stricter ``openfile()`` and raises
``TypeError: expected str, bytes or os.PathLike object`` — a real
Python-3.9-specific compatibility gap the L6 audit fix's CI matrix
expansion caught.
"""

def getvalue(self) -> str:
return self._buf.getvalue()

Expand Down Expand Up @@ -996,6 +1012,7 @@ def __init__(self) -> None:
# ``_apply_analysis_context`` resets these between contexts so stale
# state from a prior calc cannot leak into the next molecule.
self._last_orb_mo_coeff: Any = None
self._last_orb_mo_occ: Any = None
self._last_orb_mol_atom: Any = None
self._last_orb_mol_basis: Any = None
# Last-generated cube file path + orbital label (M-EXPORT / EXPORT.5).
Expand Down
1 change: 1 addition & 0 deletions quantui/app_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ def apply_analysis_context(app: Any, ctx: Any) -> None:
# to activate re-sets these in show_orbital_diagram.
app._last_orb_info = None
app._last_orb_mo_coeff = None
app._last_orb_mo_occ = None
app._last_orb_mol_atom = None
app._last_orb_mol_basis = None
app.traj_accordion.set_title(0, "Trajectory Viewer")
Expand Down
17 changes: 12 additions & 5 deletions quantui/app_exports.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from pathlib import Path
from typing import Any

from .results_storage import _safe_name


def on_export(app: Any, btn: Any) -> None:
"""Export a standalone Python calculation script."""
Expand All @@ -19,9 +21,14 @@ def on_export(app: Any, btn: Any) -> None:
method=app.method_dd.value,
basis=app.basis_dd.value,
)
# M11 audit fix (2026-07-14): the basis set is embedded verbatim in
# the filename (e.g. "6-31G*.py"), and "*" is invalid in a Windows
# filename — this export silently failed there. _safe_name (already
# used by results_storage for the same purpose) replaces anything
# that isn't alphanumeric/underscore/hyphen with "x".
fname = (
f"{app._molecule.get_formula()}"
f"_{app.method_dd.value}_{app.basis_dd.value}.py"
f"{_safe_name(app._molecule.get_formula())}"
f"_{_safe_name(app.method_dd.value)}_{_safe_name(app.basis_dd.value)}.py"
)
calc.generate_calculation_script(Path(fname))
app.export_status.value = f"Saved: {fname}"
Expand All @@ -36,7 +43,7 @@ def on_export_xyz(app: Any, btn: Any) -> None:
return
try:
mol, method, basis = export_molecule_and_label(app)
fname = f"{mol.get_formula()}_{method}_{basis}.xyz"
fname = f"{_safe_name(mol.get_formula())}_{_safe_name(method)}_{_safe_name(basis)}.xyz"
xyz_body = mol.to_xyz_string()
full_xyz = (
f"{len(mol.atoms)}\n{mol.get_formula()} {method}/{basis}\n{xyz_body}\n"
Expand All @@ -57,7 +64,7 @@ def on_export_mol(app: Any, btn: Any) -> None:
from rdkit import Chem

mol, method, basis = export_molecule_and_label(app)
fname = f"{mol.get_formula()}_{method}_{basis}.mol"
fname = f"{_safe_name(mol.get_formula())}_{_safe_name(method)}_{_safe_name(basis)}.mol"
rdmol = molecule_to_rdkit(mol)
if rdmol is None:
app.struct_export_status.value = "RDKit could not parse the structure."
Expand All @@ -79,7 +86,7 @@ def on_export_pdb(app: Any, btn: Any) -> None:
from rdkit import Chem

mol, method, basis = export_molecule_and_label(app)
fname = f"{mol.get_formula()}_{method}_{basis}.pdb"
fname = f"{_safe_name(mol.get_formula())}_{_safe_name(method)}_{_safe_name(basis)}.pdb"
rdmol = molecule_to_rdkit(mol)
if rdmol is None:
app.struct_export_status.value = "RDKit could not parse the structure."
Expand Down
18 changes: 17 additions & 1 deletion quantui/app_formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,22 @@ def _nmr_table(label: str, shifts: list, sym: str) -> str:
"</td></tr>"
)

# M4 audit fix (2026-07-14): the reference shielding constants table only
# covers a handful of method/basis combinations; any other combination
# silently substitutes the B3LYP/6-31G* constants, which can shift the
# reported ppm values by several ppm relative to a properly calibrated
# reference. Surface that substitution rather than let it pass silently.
_ref_warn = ""
if getattr(r, "is_fallback_reference", False):
_ref_warn = (
'<tr><td colspan="2" style="padding:6px 0 0">'
'<span style="color:#b45309;font-size:12px">'
f"⚠ No calibrated TMS reference for {r.method}/{r.basis} — using "
f"{getattr(r, 'reference_key', 'B3LYP/6-31G*')} constants instead. "
"Shifts may be off by a few ppm.</span>"
"</td></tr>"
)

_empty = ""
if not r.h_shifts() and not r.c_shifts():
_empty = (
Expand All @@ -310,7 +326,7 @@ def _nmr_table(label: str, shifts: list, sym: str) -> str:
f'padding:10px 14px;border-radius:4px;margin:6px 0">'
f"<b>NMR Shielding &mdash; {r.formula} ({r.method}/{r.basis})</b>"
f'<table style="margin-top:8px;font-size:14px;border-collapse:collapse">'
f"{header_rows}{h_table}{c_table}{_empty}{_basis_warn}</table></div>"
f"{header_rows}{h_table}{c_table}{_empty}{_basis_warn}{_ref_warn}</table></div>"
)


Expand Down
14 changes: 10 additions & 4 deletions quantui/app_runflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -1072,10 +1072,16 @@ def update_notes(app: Any, change: Any = None) -> None:
)
notes = calc.get_educational_notes()
if notes:
safe = (
notes.replace("**", "<b>", 1)
.replace("**", "</b>", 1)
.replace("\n\n", "<br><br>")
# M12 audit fix (2026-07-14): .replace("**", ..., 1) only
# converts the FIRST **bold** pair in the whole string — every
# note after the first (get_educational_notes() typically
# returns 2-3 separate "**Label**: description" paragraphs
# joined by "\n\n") kept its literal "**" markers instead of
# being rendered bold. A regex replaces every **...** pair.
import re as _re

safe = _re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", notes).replace(
"\n\n", "<br><br>"
)
with app.notes_output:
display(
Expand Down
21 changes: 18 additions & 3 deletions quantui/app_visualization.py
Original file line number Diff line number Diff line change
Expand Up @@ -965,6 +965,7 @@ def show_orbital_diagram(app: Any, result: Any) -> bool:

app._last_orb_info = info
app._last_orb_mo_coeff = getattr(result, "mo_coeff", None)
app._last_orb_mo_occ = mo_occ
app._last_orb_mol_atom = getattr(result, "pyscf_mol_atom", None)
app._last_orb_mol_basis = getattr(result, "pyscf_mol_basis", None)

Expand Down Expand Up @@ -1171,6 +1172,7 @@ def _is_stale() -> bool:
mo_coeff = getattr(app, "_last_orb_mo_coeff", None)
mol_atom = getattr(app, "_last_orb_mol_atom", None)
mol_basis = getattr(app, "_last_orb_mol_basis", None)
mo_occ_for_charge = getattr(app, "_last_orb_mo_occ", None)
if mo_coeff is None or mol_atom is None or mol_basis is None:
return

Expand All @@ -1179,6 +1181,7 @@ def _is_stale() -> bool:

from quantui.orbital_visualization import (
generate_cube_from_arrays,
infer_charge_and_spin,
plot_cube_isosurface,
render_orbital_isosurface_py3dmol,
)
Expand All @@ -1204,7 +1207,19 @@ def _is_stale() -> bool:
ts = _dt.now().strftime("%Y-%m-%d_%H-%M-%S-%f")
cube_path = cube_dir / f"{safe_formula}_{safe_orb}_{ts}.cube"

generate_cube_from_arrays(mol_atom, mol_basis, mo_coeff, orb_idx, cube_path)
# Charge/spin aren't carried on the app's orbital-state attributes —
# infer them from the MO occupations so charged/open-shell molecules
# (H3O+, OH-, radicals, ...) don't fail to build in PySCF (H1 fix).
_charge, _spin = infer_charge_and_spin(mol_atom, mo_occ_for_charge)
generate_cube_from_arrays(
mol_atom,
mol_basis,
mo_coeff,
orb_idx,
cube_path,
charge=_charge,
spin=_spin,
)
scene_bgcolor = app._plotly_theme_colors()["scene_bgcolor"]

# Route the render: py3Dmol does native, full-resolution in-browser
Expand Down Expand Up @@ -2488,14 +2503,14 @@ def build_vib_export_html(app: Any, mode_number: int) -> tuple[str, str]:
import numpy as np
import py3Dmol # noqa: F401 — probe; make_view imports it for the export
except ImportError as exc:
raise ValueError(f"py3Dmol unavailable for fallback export: {exc}")
raise ValueError(f"py3Dmol unavailable for fallback export: {exc}") from exc

try:
displ = np.array(freq_result.displacements[mode_number - 1], dtype=float)
except (AttributeError, IndexError, ValueError, TypeError) as exc:
raise ValueError(
f"Could not read displacements for mode {mode_number}: {exc}"
)
) from exc

atoms = list(molecule.atoms)
base_coords = np.array(molecule.coordinates, dtype=float)
Expand Down
24 changes: 3 additions & 21 deletions quantui/benchmarks.py
Original file line number Diff line number Diff line change
Expand Up @@ -774,27 +774,9 @@ def n_total(self) -> int:

def _count_electrons(atoms: list[str], charge: int) -> int:
"""Rough electron count: sum of atomic numbers minus charge."""
_Z = {
"H": 1,
"He": 2,
"Li": 3,
"Be": 4,
"B": 5,
"C": 6,
"N": 7,
"O": 8,
"F": 9,
"Ne": 10,
"Na": 11,
"Mg": 12,
"Al": 13,
"Si": 14,
"P": 15,
"S": 16,
"Cl": 17,
"Ar": 18,
}
return sum(_Z.get(a, 6) for a in atoms) - charge
from .config import ATOMIC_NUMBERS

return sum(ATOMIC_NUMBERS.get(a, 6) for a in atoms) - charge


# ---------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion quantui/cactus.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ def resolve_to_sdf(identifier: str, conformer_3d: bool = True) -> str:
return str(response.text)
except requests.RequestException as e:
logger.error(f"CACTUS request failed: {e}")
raise PubChemAPIError(f"Failed to connect to CACTUS: {e}")
raise PubChemAPIError(f"Failed to connect to CACTUS: {e}") from e

raise MoleculeNotFoundError(
f"CACTUS could not resolve '{identifier}' (last status: {last_status})"
Expand Down
Loading
Loading