diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index a466e96..ceb79a4 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -9,7 +9,7 @@ body: id: version attributes: label: DiffractScout version or commit - placeholder: "0.3.0 or commit SHA" + placeholder: "" validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/research_use.yml b/.github/ISSUE_TEMPLATE/research_use.yml index 270f821..b9a3ec7 100644 --- a/.github/ISSUE_TEMPLATE/research_use.yml +++ b/.github/ISSUE_TEMPLATE/research_use.yml @@ -11,7 +11,7 @@ body: id: version attributes: label: DiffractScout version or commit - placeholder: v0.3.0 or full commit SHA + placeholder: validations: required: true - type: dropdown @@ -48,4 +48,4 @@ body: label: Attribution permission options: - label: I consent to this public record being cited in project documentation and the JOSS impact evidence ledger. - required: true \ No newline at end of file + required: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a50704..cff1922 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,8 +111,15 @@ jobs: run: '[void][scriptblock]::Create((Get-Content scripts/publish_github.ps1 -Raw))' - name: Check installed dependencies run: python -m pip check - - name: Run tests + - name: Run complete test suite / macOS + if: runner.os == 'macOS' run: pytest -q + - name: Run core tests without GUI / Windows + if: runner.os == 'Windows' + run: pytest -q --ignore=tests/test_gui.py + - name: Run GUI startup classification tests / Windows + if: runner.os == 'Windows' + run: pytest -q tests/test_gui.py -k "test_gui_main or test_gui_setup" - name: Run and verify offline demo run: | python -m diffractscout demo -o ci_demo @@ -164,9 +171,15 @@ jobs: python -m venv wheel-test wheel-test/bin/python -m pip install dist/*.whl wheel-test/bin/python -m pip check - wheel-test/bin/python -m diffractscout demo -o wheel_demo - wheel-test/bin/python -m diffractscout verify wheel_demo - wheel-test/bin/python -m diffractscout benchmark -o wheel_benchmark + wheel-test/bin/diffractscout --version + wheel-test/bin/diffractscout demo -o wheel_demo + wheel-test/bin/diffractscout verify wheel_demo + wheel-test/bin/diffractscout benchmark -o wheel_benchmark + wheel_cif=$(wheel-test/bin/python -c "import pathlib,diffractscout; print(pathlib.Path(diffractscout.__file__).with_name('benchmark_data')/'fcc_al.cif')") + test -f "$wheel_cif" + wheel-test/bin/diffractscout-quick-export "$wheel_cif" -o wheel_quick.xlsx + wheel-test/bin/diffractscout verify wheel_quick_bundle + test -x wheel-test/bin/diffractscout-gui - name: Upload distributions uses: actions/upload-artifact@v7 with: diff --git a/.gitignore b/.gitignore index caf590c..f0e9acc 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,3 @@ _paper_demo/ # OMO/Codex local cache .omo/ - diff --git a/CHANGELOG.md b/CHANGELOG.md index e579988..eec2e0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,32 @@ All notable changes are recorded here. The project follows semantic versioning a path and constrained the Materials Project extras for Python 3.10 to `mp-api<0.46` and `pymatgen<2026` while leaving newer Python lower bounds unbounded. +- Parsed additive composition chains without dropping later terms or silently + treating them as a different composition syntax. +- Failed closed when a CIF contains an unknown element instead of emitting a + partial or fabricated composition/mass result. +- Matched elasticity-index records using normalized absolute CIF paths so an + index remains unambiguous across working directories. +- Included nested `manifest.json` files in manifest construction and + verification while excluding only the root manifest. +- Recovered stale transaction locks only when the no-replace safety checks + proved that the lock was isolated; an uncertain lock is preserved and fails + closed. +- Applied the profile-grid and reciprocal-candidate resource guards to + quick-export before expensive analysis begins. +- Defined `run_pipeline` elasticity-setting precedence: an explicit keyword + override wins, while an omitted override honors `AnalysisSettings`. +- Made GUI initialization failures actionable and tightened platform-specific + test skips so unavailable GUI environments are reported accurately. +- Hardened Windows launcher interpreter selection to prefer the checkout + environment before the active Python and `py -3` fallbacks. +- Added an installation hint for the optional Materials Project extras and + kept API-key handling explicit in user documentation. +- Recorded stable provenance definitions for expanded unit-cell mass and + transformed profile axes, including their schema names and limitations. +- Completed README/release gate hardening: first-run guidance and + release/readiness wording now keep unreleased source status, generated notes, + and scientific acceptance distinct. ### Notes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5c2c2e1..d99e0e9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,6 +19,13 @@ python -m pip install -e ".[test]" pytest -q ``` +Normal development and test work only needs `.[test]`. Before running the +complete local release preflight, install the additional release tooling with: + +```bash +python -m pip install -e ".[test,release]" +``` + Materials Project development additionally requires: ```bash diff --git a/README.md b/README.md index b70c5e1..35ffa69 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ # DiffractScout +> **Status:** The latest formal [GitHub Release is v0.3.0](https://github.com/D-sudoasd/DiffractScout/releases/tag/v0.3.0). The current source and package files in this checkout are **v0.4.0 Unreleased**. The supported Python range is **3.10–3.13**. Install this checkout from source (normally with an editable install), or install a wheel attached to a GitHub Release; no PyPI release is claimed. There is no standalone Windows EXE yet: Windows use requires Python. The repository launcher is a convenient source-checkout entry point; installed `diffractscout-gui` / `diffractscout gui` do not require it. + [![CI](https://github.com/D-sudoasd/DiffractScout/actions/workflows/ci.yml/badge.svg)](https://github.com/D-sudoasd/DiffractScout/actions/workflows/ci.yml) [![JOSS draft](https://github.com/D-sudoasd/DiffractScout/actions/workflows/draft-pdf.yml/badge.svg)](https://github.com/D-sudoasd/DiffractScout/actions/workflows/draft-pdf.yml) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) @@ -11,7 +13,7 @@ **DiffractScout turns a chemical-system question or a folder of CIF files into a verifiable theoretical powder-diffraction reference bundle.** It preserves database identity, exact CIF hashes, structural diagnostics, radiation settings, optional elastic-tensor provenance, indexed reflections, warnings, and file checksums in one workflow. -[中文说明](README.zh-CN.md) · [GUI guide](docs/GUI.md) · [Scientific contracts](docs/SCIENTIFIC_CONTRACTS.md) · [Architecture](docs/ARCHITECTURE.md) · [Validation](docs/VALIDATION.md) · [Analytic benchmarks](docs/ANALYTIC_BENCHMARKS.md) · [JOSS readiness](docs/JOSS_READINESS.md) +[中文说明](README.zh-CN.md) · [API](docs/API.md) · [GUI guide](docs/GUI.md) · [Scientific contracts](docs/SCIENTIFIC_CONTRACTS.md) · [Validation](docs/VALIDATION.md) · [Release procedure](docs/RELEASE.md) · [JOSS readiness](docs/JOSS_READINESS.md) ## Why this software exists @@ -41,7 +43,9 @@ diffractscout-gui The desktop interface exposes the scientific controls used by the Python API: radiation definition, angular window, *d*-spacing filters, profile model and spacing, pseudo-Voigt parameters, pattern axis, optional continuous patterns and figures, elastic-tensor pairing, candidate limits, reciprocal-space resource guards, overwrite authorization, progress, structured diagnostics, Excel/lab-view dependencies, and result access. The API key remains in memory and is not written to project files. See [docs/GUI.md](docs/GUI.md). -On Windows, double-click `启动DiffractScout.bat` after an editable install, or drag CIF files onto `quick_export_diffractscout.bat` for a one-shot lab export. +The GUI defaults to Chinese (`zh`); use its language selector to switch to English. The screenshots are illustrative and may show English even when a fresh launch starts in Chinese. + +On Windows, double-click `启动DiffractScout.bat` after an editable install, or drag CIF files onto `quick_export_diffractscout.bat` for a one-shot lab export. The GUI launcher is a source-checkout convenience entry point: it runs the checkout source and prefers the repository `.venv\Scripts\python.exe`, then the current/active `python`, then `py -3`. Installed `diffractscout-gui` / `diffractscout gui` do not require the repository launcher. ## CIF2Peaks parity features @@ -65,6 +69,40 @@ metadata distinguish `requested_two_theta_range_deg`, `profile_sampled_two_theta_range_deg` endpoints, `effective_window_empty`, and `geometric_d_min_A` versus the filter `d_min_A`. +## First-run quick start + +Run these commands from the DiffractScout checkout. They create a local virtual +environment, install the current source, print the source version, and execute +the offline synthetic demo followed by bundle verification. + +### Bash + +```bash +python3 -m venv .venv +source .venv/bin/activate +python -m pip install -e . +diffractscout --version +diffractscout demo -o outputs/first-run +diffractscout verify outputs/first-run +``` + +### PowerShell + +```powershell +py -3 -m venv .venv +.\.venv\Scripts\Activate.ps1 +python -m pip install -e . +diffractscout --version +diffractscout demo -o outputs/first-run +diffractscout verify outputs/first-run +``` + +The key successful output is `diffractscout 0.4.0`, `Analyzed phases: 1`, and +`PASS`. The demo is synthetic and offline; it is an installation and integrity +check, not experimental validation. A rerun needs a new output directory, or +an explicit `--overwrite` only after the existing directory has passed +`diffractscout verify` and is a recognized DiffractScout bundle. + ## Installation ### Local CIF analysis @@ -77,19 +115,44 @@ python -m pip install -e . ### Materials Project support +Use the optional `mp` extra and provide your own key through the environment. +Keep the placeholder below; never commit, paste, or share a real key. Prefer a +secret manager or an interactive shell prompt for automation, and unset/remove +the variable after use. DiffractScout keeps the key in process memory and does +not write it to result bundles. + +#### Bash + ```bash python -m pip install -e ".[mp]" -export MP_API_KEY="your-key" # PowerShell: $env:MP_API_KEY = "your-key" +export MP_API_KEY="replace-with-your-key" +diffractscout discover "Ti-Al-V" -o outputs/ti_al_v_candidates +unset MP_API_KEY ``` -### Optional extras +#### PowerShell -```bash -python -m pip install -e ".[figures]" # optional matplotlib rendering path / paper figures -python -m pip install -e ".[gui-dnd]" # optional Tk drag-and-drop helper -python -m pip install -e ".[mp]" # Materials Project +```powershell +python -m pip install -e ".[mp]" +$env:MP_API_KEY = "replace-with-your-key" +diffractscout discover "Ti-Al-V" -o outputs/ti_al_v_candidates +Remove-Item Env:MP_API_KEY ``` +### Optional extras + +| Extra | Install | Use | +|---|---|---| +| base | `python -m pip install -e .` | Offline local CIF analysis, CLI/API, synthetic demo and verification | +| `mp` | `python -m pip install -e ".[mp]"` | Materials Project discovery/download and optional provider metadata; requires your own API key | +| `figures` | `python -m pip install -e ".[figures]"` | Optional matplotlib rendering path and paper-figure tooling | +| `gui-dnd` | `python -m pip install -e ".[gui-dnd]"` | Optional `tkinterdnd2` file/folder drag-and-drop; button-based GUI use remains available without it | +| `test` | `python -m pip install -e ".[test]"` | Pytest, coverage, Ruff, YAML support, and development checks | +| `release` | `python -m pip install -e ".[release]"` | `build` and `twine` required for a complete local release preflight | + +For a formal release, install the wheel attached to its GitHub Release in a +compatible environment. This project does not claim a published PyPI release. + ### Development environment ```bash @@ -97,6 +160,10 @@ python -m pip install -e ".[test]" pytest -q ``` +Normal development and test work only needs `.[test]`. Before running the +complete local release preflight, install both test and release tooling with +`python -m pip install -e ".[test,release]"`. + ## Five-minute offline verification The demo uses an explicitly synthetic FCC structure and a synthetic isotropic stiffness tensor. It contains no experimental property values. @@ -214,6 +281,14 @@ The legacy fields `material_scattering_factor_R_hkl` and `material_scattering_fa The continuous pseudo-Voigt profile is a visualization product with user-supplied width and mixing fraction. Resource guards cap both profile-grid size and the conservative reciprocal-lattice candidate estimate before memory-intensive work begins. +The schema field `formula_weight_g_mol` means the expanded crystallographic +unit-cell mass in g/mol: it sums all occupied sites in the unit cell, including +the crystallographic multiplicity `Z`. It is not the empirical formula molar +mass. For continuous profiles, `pattern_axis` selects coordinates transformed +from a uniform `2theta` grid. `d`, `q`, and `g` are not uniformly resampled +axes, and no Jacobian is applied; the canonical schema names remain +`two_theta_deg`, `d_A`, `q_invA`, `g_invA`, `x_axis_mode`, and `x`. + Full equations, units, tensor convention, coordinate-frame rules, structure validation, and exclusions are defined in [docs/SCIENTIFIC_CONTRACTS.md](docs/SCIENTIFIC_CONTRACTS.md). ## Reliability and validation diff --git a/README.zh-CN.md b/README.zh-CN.md index d1af073..ba88a07 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -4,9 +4,11 @@ # DiffractScout 中文说明 +> **状态:** 最新正式 [GitHub Release 为 v0.3.0](https://github.com/D-sudoasd/DiffractScout/releases/tag/v0.3.0)。本 checkout 中当前 source/package files(源码和包文件/元数据)为 **v0.4.0 Unreleased(未发布)**。支持的 Python 版本范围为 **3.10–3.13**。请从源码安装(通常使用可编辑安装),或安装 GitHub Release 附带的 wheel;本项目不宣称已有 PyPI 发布版本。Windows 目前没有独立 EXE,使用必须有 Python;仓库启动器只是源码 checkout 的便捷入口,安装后的 `diffractscout-gui` / `diffractscout gui` 不依赖它。 + **DiffractScout 将合金/化学体系候选相检索、本地 CIF 检查、理论粉末衍射计算、可选晶面法向弹性分析和可验证结果导出连接为一个流程。** 每项结果均可追溯到数据库记录或本地文件、CIF 哈希、辐射条件、计算定义、软件版本和结构化诊断。 -[英文主页](README.md) · [GUI 使用说明](docs/GUI.md) · [科研计算约定](docs/SCIENTIFIC_CONTRACTS.md) · [验证策略](docs/VALIDATION.md) · [解析基准](docs/ANALYTIC_BENCHMARKS.md) · [JOSS 准备状态](docs/JOSS_READINESS.md) +[英文主页](README.md) · [API](docs/API.md) · [GUI 使用说明](docs/GUI.md) · [科研计算约定](docs/SCIENTIFIC_CONTRACTS.md) · [验证策略](docs/VALIDATION.md) · [发布流程](docs/RELEASE.md) · [JOSS 准备状态](docs/JOSS_READINESS.md) ## 两类工作流 @@ -32,7 +34,9 @@ diffractscout-gui 界面提供:CIF 文件与文件夹批量选择、递归扫描、光源/能量/波长、`2θ` 范围、`d` 过滤、线型模型、步长、FWHM、伪 Voigt 混合参数、CSV/Excel 谱线坐标、连续谱线与图件、弹性配对、候选相数量上限、倒易空间资源限制、覆盖授权、Excel/实验室视图依赖、运行状态、结构化日志和结果入口。API 密钥只保存在当前进程内存中,不写入项目文件。详见 [docs/GUI.md](docs/GUI.md)。 -Windows 下可在可编辑安装后双击 `启动DiffractScout.bat` 启动界面;或将 CIF 拖到 `quick_export_diffractscout.bat` 进行一次实验室默认导出。 +GUI 默认语言为中文(`zh`),可通过语言选择器切换为 English。截图仅作示意,可能显示英文,即使新启动的界面默认是中文。 + +Windows 下可在可编辑安装后双击 `启动DiffractScout.bat` 启动界面;或将 CIF 拖到 `quick_export_diffractscout.bat` 进行一次实验室默认导出。GUI 启动器是源码 checkout 的便捷入口,使用 checkout 源码,并按仓库 `.venv\Scripts\python.exe`、当前/激活的 `python`、`py -3` 顺序选择解释器;安装后的 `diffractscout-gui` / `diffractscout gui` 不依赖仓库启动器。 ## 已吸收 CIF2Peaks 桌面能力 @@ -56,6 +60,39 @@ DiffractScout 在可追溯结果包中重实现了 CIF2Peaks 的主要桌面工 `profile_sampled_two_theta_range_deg`、`effective_window_empty`,以及几何 `geometric_d_min_A` 与过滤字段 `d_min_A`。 +## 首次运行快速开始 + +以下命令应在 DiffractScout 源码 checkout 根目录执行。它们会创建虚拟 +环境、安装当前源码、打印版本,并运行离线合成演示和结果包校验。 + +### Bash + +```bash +python3 -m venv .venv +source .venv/bin/activate +python -m pip install -e . +diffractscout --version +diffractscout demo -o outputs/first-run +diffractscout verify outputs/first-run +``` + +### PowerShell + +```powershell +py -3 -m venv .venv +.\.venv\Scripts\Activate.ps1 +python -m pip install -e . +diffractscout --version +diffractscout demo -o outputs/first-run +diffractscout verify outputs/first-run +``` + +成功时应看到 `diffractscout 0.4.0`、`Analyzed phases: 1` 和 `PASS` 等关键 +输出。演示使用合成数据且离线运行,只验证安装与结果包完整性,不证明实验 +科学有效性。重跑时请使用新的输出目录;只有在已有目录先通过 +`diffractscout verify` 且确认它是 DiffractScout 结果包时,才可以显式使用 +`--overwrite`。 + ## 安装 本地 CIF 分析: @@ -68,18 +105,43 @@ python -m pip install -e . Materials Project 支持: +请安装可选的 `mp` extra,并通过环境变量提供你自己的密钥。下方仅使用 +占位符,绝不要提交、粘贴或分享真实密钥;自动化环境优先使用密钥管理器或 +交互式输入,并在使用后清除变量。DiffractScout 只在进程内存中使用密钥, +不会写入结果包。 + +### Bash + ```bash python -m pip install -e ".[mp]" +export MP_API_KEY="replace-with-your-key" +diffractscout discover "Ti-Al-V" -o outputs/ti_al_v_candidates +unset MP_API_KEY ``` -可选依赖: +### PowerShell -```bash -python -m pip install -e ".[figures]" # 可选 matplotlib 渲染路径 / 论文图 -python -m pip install -e ".[gui-dnd]" # 可选 Tk 拖放辅助 -python -m pip install -e ".[mp]" # Materials Project +```powershell +python -m pip install -e ".[mp]" +$env:MP_API_KEY = "replace-with-your-key" +diffractscout discover "Ti-Al-V" -o outputs/ti_al_v_candidates +Remove-Item Env:MP_API_KEY ``` +可选依赖: + +| Extra | 安装 | 用途 | +|---|---|---| +| base | `python -m pip install -e .` | 离线本地 CIF、CLI/API、合成演示和校验 | +| `mp` | `python -m pip install -e ".[mp]"` | Materials Project 检索/下载和可选 provider 元数据;需要自己的 API 密钥 | +| `figures` | `python -m pip install -e ".[figures]"` | 可选 matplotlib 渲染路径和论文制图工具 | +| `gui-dnd` | `python -m pip install -e ".[gui-dnd]"` | 可选 `tkinterdnd2` 文件/文件夹拖放;没有它仍可使用按钮式 GUI | +| `test` | `python -m pip install -e ".[test]"` | pytest、覆盖率、Ruff、YAML 支持和开发检查 | +| `release` | `python -m pip install -e ".[release]"` | 完整本地 release preflight 所需的 `build` 和 `twine` | + +正式版本可安装其 GitHub Release 附带的 wheel;本项目不宣称已有 PyPI +发布版本。 + 开发与测试: ```bash @@ -87,6 +149,10 @@ python -m pip install -e ".[test]" pytest -q ``` +普通开发和测试只需安装 `.[test]`。运行完整的本地 release preflight 前, +请安装测试与发布工具: +`python -m pip install -e ".[test,release]"`。 + ## 离线验证 ```bash @@ -125,10 +191,11 @@ diffractscout analyze D:\path\to\cifs -o D:\results\run_83keV ` ## Materials Project 完整流程 ```powershell -$env:MP_API_KEY = "your-key" +$env:MP_API_KEY = "replace-with-your-key" diffractscout run "Ti-Al-V" -o D:\results\ti_al_v ` --mode near_stable --e-hull-max 0.05 ` --max-subsystem-order 3 --max-subsystems 4096 --max-total 50 +Remove-Item Env:MP_API_KEY ``` 默认下载常规标准晶胞。raw/POSCAR 和 IEEE 格式张量会保留数值及来源,但由于当前 provider 没有持久化足够的上游结构取向,无法验证其到导出 CIF Cartesian 坐标系的变换,因此两者均标记为 `frame_transform_required`,不输出方向模量,直至用户提供经过验证的坐标变换。原胞下载需要同时使用 `--no-elasticity`。软件会在访问数据库前估算化学子体系查询数量,超过 `--max-subsystems`(默认 4096)时停止,避免高元体系产生组合式查询膨胀。 @@ -171,6 +238,12 @@ J_no_LP = I_no_LP / V_cell² 兼容字段 `material_scattering_factor_R_hkl` 和 `material_scattering_factor_R_hkl_no_lp` 对应上述两个 `J` 通道。它们不表示晶体学残差因子、标准化定量物相系数或实验标定散射因子。连续伪 Voigt 谱线用于显示,峰宽和混合参数均由用户指定。 +模式字段 `formula_weight_g_mol` 表示扩展后的晶体学晶胞质量(g/mol):对 +晶胞中所有有占位的原子位点求和,并包含晶体学倍数 `Z`;它不是经验式摩尔 +质量。连续谱线的 `pattern_axis` 是从均匀 `2theta` 网格变换得到的坐标, +不是在均匀 `q` 或 `d` 网格上重新采样,也不施加 Jacobian;规范字段名 +`two_theta_deg`、`d_A`、`q_invA`、`g_invA`、`x_axis_mode` 和 `x` 保持不变。 + 详细公式、单位、弹性 Voigt 约定、坐标系规则、资源上限和排除项见 [docs/SCIENTIFIC_CONTRACTS.md](docs/SCIENTIFIC_CONTRACTS.md)。 ## 验证与 JOSS 状态 diff --git a/docs/API.md b/docs/API.md index a134f8b..ca5e7eb 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,5 +1,10 @@ # Core Python API +The public result schemas and scientific meanings are defined in +[SCIENTIFIC_CONTRACTS.md](SCIENTIFIC_CONTRACTS.md). The CLI/GUI entry points and +validation workflow are documented in the [GUI guide](GUI.md), +[README](../README.md), and [release procedure](RELEASE.md). + ## Local CIF analysis ```python @@ -147,6 +152,16 @@ result = run_pipeline( Downloads above `confirm_above` require `authorize_large_download=True`. Automatic Materials Project elasticity coupling requires a conventional-standard cell. A primitive-cell run must set `include_elasticity=False`. +`run_pipeline(..., include_elasticity=...)` treats the keyword as an optional +override. An omitted override honors `AnalysisSettings`; an explicit boolean +overrides it: + +- when the override is omitted (`None`) and `analysis_settings` is supplied, + the value in `analysis_settings.include_elasticity` is honored; +- an explicit `True` or `False` overrides that setting; +- when both `analysis_settings` and the override are omitted, elasticity remains + enabled by default (`True`). + ## Result records ### `PipelineResult` diff --git a/docs/GUI.md b/docs/GUI.md index ec7125c..056172a 100644 --- a/docs/GUI.md +++ b/docs/GUI.md @@ -11,12 +11,25 @@ diffractscout gui # or: python -m diffractscout gui ``` -On Windows, after an editable or environment install, double-click `启动DiffractScout.bat` in the repository root (it `cd`s to the script directory and tries `py -3 -m diffractscout gui`, then `diffractscout-gui`). +On Windows, after an editable or environment install, double-click +`启动DiffractScout.bat` in the repository root. The repository launcher uses +the checkout source and prefers interpreters in this order: the repository +`.venv\Scripts\python.exe`, the current/active `python`, then `py -3`. There is +no standalone Windows EXE yet, so a Python installation is required. The +repository launcher is only a convenient source-checkout entry point; an +installed `diffractscout-gui` or `diffractscout gui` does not require it. The +optional `scripts/package_windows_portable.py` file documents a future +PyInstaller layout; it does not ship an executable. A normal Python installation with Tk support is required. On Linux, the operating-system package is commonly named `python3-tk` or `tk`. Optional extra `.[gui-dnd]` installs `tkinterdnd2` and enables file/folder drop onto the local CIF list; the button-based workflow remains available without it. +The GUI starts in Chinese (`zh`). Use the language selector in the header to +switch between Chinese and English. Screenshots in this guide are +illustrative and may show English labels even though a fresh launch defaults +to Chinese. + ## Layout and scrolling Dense forms (radiation, Cij, export options) live in **vertically scrollable** columns: use the mouse wheel or the right-hand scrollbar. Primary **Analyze / Run** actions stay **pinned under** the scroll area so they remain visible. The Activity log is in a **resizable vertical split** under the notebook—drag the sash to give the form more height on small screens. Default window size is about `1200×820` with a lower minimum (`900×640`). diff --git a/docs/JOSS_READINESS.md b/docs/JOSS_READINESS.md index 5ca897a..1b5e097 100644 --- a/docs/JOSS_READINESS.md +++ b/docs/JOSS_READINESS.md @@ -1,6 +1,6 @@ # JOSS readiness assessment -Assessment date: **12 August 2026** +Assessment date: **12 August 2026** (static baseline snapshot; not a live status) Software version assessed: **0.4.0 release candidate** @@ -18,6 +18,18 @@ Primary requirements consulted: - JOSS submission and archive sequence: `https://joss.readthedocs.io/en/latest/submitting.html` - JOSS AI policy: `https://joss.readthedocs.io/en/latest/policies.html` +This page records a dated baseline and is not updated automatically. For the +current assessment, run the live readiness script and inspect its generated +JSON/Markdown report, for example: + +```bash +python scripts/joss_readiness.py --stage release --output build/joss-readiness +``` + +The release receipt and engineering checks are software-process evidence only; +they do not establish scientific validity, real-material agreement, or JOSS +acceptance. + ## Current conclusion The repository has an MIT license, installable package metadata, CLI/API/GUI, @@ -91,6 +103,10 @@ schema, provenance, and integrity contracts. They do not establish experimental validity, adoption, or research impact. Only completed and traceable records may be added to `docs/evidence/impact_evidence.json`. +Passing tests, benchmarks, CI, or release-preflight checks therefore remain +engineering readiness signals, not scientific validation or a JOSS acceptance +decision. + The real multiphase case must identify the material, lawful input source, software version, frozen CIF hashes, complete settings, result bundle, actual research decision, and limitations. Online discovery context may be recorded, diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 198ca1a..68ef8b0 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -12,6 +12,13 @@ ## 2. Run local preflight +Normal development only needs `.[test]`. Before a complete local release +preflight, install both the test and release tools: + +```bash +python -m pip install -e ".[test,release]" +``` + ```bash python scripts/check_release.py ``` @@ -137,6 +144,14 @@ The dry run still checks GitHub CLI authentication and repository visibility, th Pushing a version tag matching the package version triggers `.github/workflows/release.yml`. The workflow repeats the release preflight, which builds the wheel and source distribution, validates distribution metadata with Twine, and smoke-tests the built wheel; it then packages the analytic benchmark, offline demo, and readiness report with `scripts/archive_tree.py`, computes `SHA256SUMS.txt`, and creates the GitHub Release. The archive helper uses sorted paths, a timestamp fixed by `SOURCE_DATE_EPOCH`, a single safe root, symbolic-link rejection, and atomic replacement so the scientific evidence archives are reproducible for a fixed source and runtime stack. +When a release is created for the first time, the workflow passes +`--generate-notes` to GitHub, so GitHub automatically generates the release +notes. The workflow does not use `CHANGELOG.md` as a notes file. Maintainers +must review and curate the generated notes against the current-version +`CHANGELOG.md`, before publication when practical or immediately after +publication when that is the available review point. `CHANGELOG.md` remains +the project's versioned history and review reference. + `.github/workflows/monthly-audit.yml` reruns the release and scientific checks on the first day of each month and retains deterministic evidence archives for 90 days. `.github/dependabot.yml` proposes monthly Python and GitHub Actions updates. Timer-triggered audit runs are maintenance evidence; they do not substitute for substantive public commits, validation, support records, issues, pull requests, or releases during the six-month period. After CI passes on the release commit: @@ -145,7 +160,9 @@ After CI passes on the release commit: 2. delete the merged feature branch after confirming the PR head SHA is represented in the default branch; 3. create a new annotated tag matching the package version, for example `v0.4.0`; never move an existing tag; 4. push the tag; -5. create GitHub release notes from `CHANGELOG.md`; +5. review and curate GitHub's automatically generated release notes against the + current-version `CHANGELOG.md` before publication when practical, or + immediately after publication; 6. attach distribution files and checksums when appropriate; 7. for ordinary pre-submission releases, retain the verified GitHub Release artifacts and public CI URL; 8. after successful JOSS review, create the final tag if needed and archive that exact repository state with Zenodo or an equivalent service; diff --git a/docs/SCIENTIFIC_CONTRACTS.md b/docs/SCIENTIFIC_CONTRACTS.md index 29866a3..9d93060 100644 --- a/docs/SCIENTIFIC_CONTRACTS.md +++ b/docs/SCIENTIFIC_CONTRACTS.md @@ -23,6 +23,16 @@ Space-group identity is resolved in the following order and the chosen source is A disagreement between explicit symbol and number, or between the declared group and the independent spglib search, is recorded as a diagnostic. The diffraction calculation uses the resolved Gemmi group; users must review any mismatch before interpreting systematic absences. +### Unit-cell mass and density + +The schema field `formula_weight_g_mol` is the expanded crystallographic +unit-cell mass in g/mol. It sums the masses of all occupied sites in the +expanded unit cell, including the crystallographic multiplicity `Z`; it is not +the empirical formula molar mass. The derived `density_g_cm3` uses this +unit-cell mass and the crystallographic cell volume. These meanings apply to +the existing schema names and should not be relabeled as empirical-formula +quantities. + ## 2. Radiation definition Energy and wavelength are related by @@ -208,6 +218,14 @@ profile is a visualization and interoperability product. It is not a fitted instrument function and contains no inferred axial divergence, spectral doublet, microstrain, crystallite size, detector response, or background. +When `pattern_axis` is `d_spacing`, `q`, or `g`, the profile remains sampled on +the same uniform `2theta` grid and the emitted coordinates are coordinate +transforms of those samples. The profile is not uniformly resampled in `q` or +`d`, and no Jacobian is applied. The canonical columns remain +`two_theta_deg`, `d_A`, `q_invA`, `g_invA`, `x_axis_mode`, `x`, and +`relative_intensity`; `pattern_axis` selects the `x` coordinate for the +continuous CSV/Excel profile only. + ## 6. Resource and query limits Large angular grids, small-$d$ reflection searches, and unrestricted chemical-subsystem expansion can consume substantial memory, API quota, and runtime. DiffractScout applies three explicit guards before the expensive operation begins: diff --git a/examples/README.md b/examples/README.md index ebf1575..a133d15 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,9 +1,22 @@ # Examples -`demo_cifs/` contains synthetic validation fixtures. They are not experimental measurements and must not be cited as material-property data. +`demo_cifs/` contains explicitly synthetic validation fixtures. They are not +experimental measurements and must not be cited as material-property data or +used as evidence of real-material phase identification, elastic behavior, or +instrument agreement. -Run: +After installing the checkout (`python -m pip install -e .`), run the example +analysis and verify the resulting bundle: ```bash -diffractscout analyze examples/demo_cifs -o outputs/demo +diffractscout analyze examples/demo_cifs -o outputs/examples-analyze +diffractscout verify outputs/examples-analyze ``` + +The successful run reports `Analyzed phases: 1`, and the verifier reports +`PASS`. Inspect `phase_summary.csv`, `peak_reference.csv`, `provenance.json`, +`diagnostics.csv`, and `manifest.json` before using any output. The bundle is a +synthetic/offline software example; scientific acceptance still requires +independent real-material validation. Use a new output directory on reruns, or +use `--overwrite` only for an existing bundle that first passes +`diffractscout verify`. diff --git a/pyproject.toml b/pyproject.toml index 4e838db..721e3b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,10 @@ test = [ "PyYAML>=6.0", "tomli>=2.0; python_version < '3.11'", ] +release = [ + "build>=1.2", + "twine>=5.1", +] # Optional display figures and JOSS paper figure regeneration. figures = [ "matplotlib>=3.7", diff --git a/scripts/check_release.py b/scripts/check_release.py index 0abb361..afd7374 100755 --- a/scripts/check_release.py +++ b/scripts/check_release.py @@ -13,11 +13,46 @@ import subprocess import sys import tempfile +from collections.abc import Mapping from pathlib import Path ROOT = Path(__file__).resolve().parents[1] READINESS_SCRIPT = ROOT / "scripts/joss_readiness.py" +DECLARED_ENTRYPOINTS = { + "diffractscout": "diffractscout.cli:main", + "diffractscout-quick-export": "diffractscout.quick_export:main", + "diffractscout-gui": "diffractscout.gui:main", +} + + +def _missing_preflight_dependencies(*, skip_tests: bool, skip_wheel: bool) -> list[str]: + """Return optional tools needed by the selected preflight checks.""" + + required: list[str] = [] + if not skip_wheel: + required.extend(("build", "twine")) + if not skip_tests: + required.append("pytest") + return [name for name in required if importlib.util.find_spec(name) is None] + + +def _ensure_preflight_dependencies(*, skip_tests: bool, skip_wheel: bool) -> None: + """Fail early with a copyable install command when tools are unavailable.""" + + missing = _missing_preflight_dependencies( + skip_tests=skip_tests, + skip_wheel=skip_wheel, + ) + if not missing: + return + missing_text = ", ".join(missing) + raise SystemExit( + "Missing preflight tooling: " + f"{missing_text}. Install it with:\n" + ' python -m pip install -e ".[test,release]"' + ) + def _load_readiness_module(): spec = importlib.util.spec_from_file_location( @@ -73,6 +108,56 @@ def _write_release_acceptance( return path +def _resolve_installed_launchers( + environment_path: Path, + *, + windows: bool | None = None, +) -> dict[str, Path]: + """Return the exact venv launchers for every declared entry point. + + The launcher directory and suffix are deliberately derived from the venv + layout rather than from ``PATH``. This keeps the clean-wheel check bound + to the wheel just installed into this environment. + """ + + is_windows = os.name == "nt" if windows is None else windows + launcher_dir = environment_path / ("Scripts" if is_windows else "bin") + suffix = ".exe" if is_windows else "" + launchers: dict[str, Path] = {} + for name in DECLARED_ENTRYPOINTS: + launcher = launcher_dir / f"{name}{suffix}" + if not launcher.is_file(): + raise SystemExit(f"Missing installed entry-point launcher: {launcher}") + if not is_windows and not os.access(launcher, os.X_OK): + raise SystemExit(f"Installed entry-point launcher is not executable: {launcher}") + launchers[name] = launcher.resolve() + return launchers + + +def _validate_installed_entrypoint_metadata(metadata: Mapping[str, str]) -> None: + """Fail if installed entry-point metadata omits or redirects a launcher.""" + + missing = sorted(set(DECLARED_ENTRYPOINTS) - set(metadata)) + mismatched = sorted( + name + for name, target in DECLARED_ENTRYPOINTS.items() + if name in metadata and metadata[name] != target + ) + if missing or mismatched: + details: list[str] = [] + if missing: + details.append("missing=" + ",".join(missing)) + if mismatched: + details.append( + "mismatched=" + + ",".join( + f"{name} (expected {DECLARED_ENTRYPOINTS[name]!r}, got {metadata[name]!r})" + for name in mismatched + ) + ) + raise SystemExit("Installed wheel entry-point metadata mismatch: " + "; ".join(details)) + + def _clean_wheel_smoke(wheel: Path) -> dict[str, str]: """Install the just-built wheel without source-path leakage and exercise it.""" @@ -103,11 +188,44 @@ def run_clean(*args: str) -> None: print("+", " ".join(command)) subprocess.run(command, cwd=root, check=True, env=clean_environment) + def run_clean_capture(*args: str) -> str: + command = [str(python), *args] + print("+", " ".join(command)) + completed = subprocess.run( + command, + cwd=root, + check=True, + env=clean_environment, + text=True, + capture_output=True, + ) + if completed.stdout: + print(completed.stdout, end="") + if completed.stderr: + print(completed.stderr, end="", file=sys.stderr) + return completed.stdout.strip() + + def run_installed(name: str, *args: str) -> None: + command = [str(launchers[name]), *args] + print("+", " ".join(command)) + subprocess.run(command, cwd=root, check=True, env=clean_environment) + install_args = ["-m", "pip", "install", "--disable-pip-version-check"] if reuse_dependencies: install_args.extend(["--no-index", "--no-deps"]) install_args.append(str(wheel.resolve())) run_clean(*install_args) + launchers = _resolve_installed_launchers(environment_path) + metadata_output = run_clean_capture( + "-c", + ( + "import importlib.metadata,json; " + "entries={item.name:item.value for item in " + "importlib.metadata.distribution('diffractscout').entry_points}; " + "print(json.dumps(entries, sort_keys=True))" + ), + ) + _validate_installed_entrypoint_metadata(json.loads(metadata_output)) run_clean("-m", "pip", "check") run_clean( "-c", @@ -122,9 +240,10 @@ def run_clean(*args: str) -> None: demo = root / "demo" benchmark = root / "benchmark" quick = root / "quick.xlsx" - run_clean("-m", "diffractscout", "demo", "-o", str(demo), "--no-excel") - run_clean("-m", "diffractscout", "verify", str(demo)) - run_clean("-m", "diffractscout", "benchmark", "-o", str(benchmark)) + run_installed("diffractscout", "--version") + run_installed("diffractscout", "demo", "-o", str(demo), "--no-excel") + run_installed("diffractscout", "verify", str(demo)) + run_installed("diffractscout", "benchmark", "-o", str(benchmark)) packaged_cif = ( "import diffractscout,pathlib; " "print(pathlib.Path(diffractscout.__file__).with_name('benchmark_data')/'fcc_al.cif')" @@ -137,13 +256,18 @@ def run_clean(*args: str) -> None: text=True, capture_output=True, ).stdout.strip() - run_clean("-m", "diffractscout", "quick-export", located, "-o", str(quick)) - run_clean("-m", "diffractscout", "verify", str(root / "quick_bundle")) + if not Path(located).is_file(): + raise SystemExit(f"Installed benchmark CIF is missing: {located}") + run_installed("diffractscout-quick-export", located, "-o", str(quick)) + run_installed("diffractscout", "verify", str(root / "quick_bundle")) return { "mode": "system-site-packages" if reuse_dependencies else "isolated-dependencies", "package": str(wheel.resolve()), "source_tree_import": "rejected", - "commands": "pip-check,demo,verify,benchmark,quick-export,verify", + "commands": ( + "pip-check,entrypoint-launchers,entrypoint-metadata,installed-entrypoints," + "version,demo,verify,benchmark,quick-export,verify" + ), } @@ -263,6 +387,10 @@ def main() -> int: help="Empty directory for wheel and sdist output; existing files are never removed.", ) args = parser.parse_args() + _ensure_preflight_dependencies( + skip_tests=args.skip_tests, + skip_wheel=args.skip_wheel, + ) missing = [path.relative_to(ROOT) for path in _required_files() if not path.is_file()] if missing: diff --git a/src/diffractscout/cli.py b/src/diffractscout/cli.py index 3fa2819..917a879 100644 --- a/src/diffractscout/cli.py +++ b/src/diffractscout/cli.py @@ -357,8 +357,7 @@ def main(argv: Sequence[str] | None = None) -> int: if args.command == "gui": from .gui import main as gui_main - gui_main() - return 0 + return int(gui_main() or 0) except ( ValueError, FileNotFoundError, diff --git a/src/diffractscout/composition.py b/src/diffractscout/composition.py index f110f5c..e6995e5 100644 --- a/src/diffractscout/composition.py +++ b/src/diffractscout/composition.py @@ -104,6 +104,71 @@ def _append_unique(store: list[str], values: Iterable[str]) -> None: store.append(normalized) +_ADDITIVE_SEPARATOR_RE = re.compile(r"[+/、和加]|\bwith\b", re.IGNORECASE) +_ADDITIVE_TERM_RE = re.compile(r"[A-Za-z][A-Za-z0-9-]*") + + +def _adjacent_additive_term(text: str, index: int, *, direction: int) -> str: + """Return the nearest lexical term on one side of an additive separator.""" + + step = 1 if direction > 0 else -1 + cursor = index if direction > 0 else index - 1 + while 0 <= cursor < len(text) and text[cursor].isspace(): + cursor += step + if not 0 <= cursor < len(text): + return "" + if text[cursor] in "+/、和加": + return "" + if direction > 0: + match = _ADDITIVE_TERM_RE.match(text, cursor) + return match.group(0) if match else "" + end = cursor + 1 + while cursor >= 0 and (text[cursor].isalnum() or text[cursor] in "-_"): + cursor -= 1 + return text[cursor + 1 : end] + + +def _is_supported_additive_left(term: str) -> bool: + """Return whether a non-slash additive left term has known semantics.""" + + if normalize_element(term): + return True + if any(_alias_has_input_boundaries(term, alias) for alias in ALLOY_ALIASES): + return True + parsed = formula_elements(term) + return len(parsed) >= 2 or (len(parsed) == 1 and bool(re.search(r"\d", term))) + + +def _validate_explicit_additive_syntax(text: str) -> None: + """Reject malformed explicit separators before formula/token fallback.""" + + for match in _ADDITIVE_SEPARATOR_RE.finditer(text): + left = _adjacent_additive_term(text, match.start(), direction=-1) + right = _adjacent_additive_term(text, match.end(), direction=1) + separator = match.group(0) + if not left or not right: + raise ValueError( + "Explicit additive composition has a missing term around " + f"separator {separator!r} in {text!r}." + ) + if separator == "/": + if not normalize_element(left) or not normalize_element(right): + raise ValueError( + "Explicit additive composition slash requires recognized element symbols " + f"on both sides: {left!r}/{right!r}." + ) + elif not _is_supported_additive_left(left): + raise ValueError( + "Explicit additive composition has an unknown or unsupported left " + f"term {left!r}; provide an element, formula, grade, or alloy alias." + ) + elif not normalize_element(right): + raise ValueError( + "Explicit additive composition contains unknown element term(s) " + f"{right!r}; every additive term must be a recognized element symbol." + ) + + def _alias_has_input_boundaries(text: str, alias: str) -> bool: """Return whether a compact alias is standalone in the original input. @@ -192,6 +257,7 @@ def parse_composition_text(text: str) -> ParsedComposition: material_ids = list(dict.fromkeys(material_ids)) _reject_embedded_alias_tokens(normalized_raw) + _validate_explicit_additive_syntax(normalized_raw) for alias, alias_elements in ALLOY_ALIASES.items(): if _alias_has_input_boundaries(normalized_raw, alias): @@ -260,15 +326,56 @@ def parse_composition_text(text: str) -> ParsedComposition: _append_unique(elements, parsed) labels.append(token) - # Explicit additive notation, e.g. Ti-6Al-4V + Cu. + # Explicit additive notation, e.g. Fe+Ni, Fe/Ni, Fe with Ni, or Fe和Ni. + # Capture the complete chain: a right-only match silently dropped the + # first element, while pairwise matching would miss every other element. + invalid_additive_terms: list[str] = [] for match in re.finditer( - r"(?:\+|+|/|、|和|加|with)\s*([A-Z][a-z]?)\b", + r"(? str: + """Normalize a CIF pairing value to a portable, case-insensitive basename.""" + + text = str(value or "").strip() + if not text: + return "" + normalized = text.replace("\\", "/").rstrip("/") + return normalized.rsplit("/", 1)[-1].casefold() + + def _index_pairing_aliases(row: dict[str, str]) -> set[str]: return { - str(row.get(key) or "").strip().lower() + _index_pairing_basename(row.get(key)) for key in ("cif_name", "cif_filename", "paired_cif") - if str(row.get(key) or "").strip() + if _index_pairing_basename(row.get(key)) } @@ -625,7 +635,7 @@ def _index_match_count(index_path: Path, cif_path: Path) -> int | None: rows = list(csv.DictReader(handle)) except OSError: return -1 - target_name = cif_path.name.lower() + target_name = _index_pairing_basename(cif_path) return sum(target_name in _index_pairing_aliases(row) for row in rows) @@ -656,7 +666,7 @@ def _load_from_index(index_path: Path, cif_path: Path) -> ElasticTensor | None: rows = list(csv.DictReader(handle)) except OSError as exc: return _invalid_tensor(f"Could not read elasticity index {index_path.name}: {exc}", path=index_path) - target_name = cif_path.name.lower() + target_name = _index_pairing_basename(cif_path) matches: list[dict[str, str]] = [] for row in rows: aliases = _index_pairing_aliases(row) diff --git a/src/diffractscout/exporters.py b/src/diffractscout/exporters.py index 8baad1a..042c94d 100644 --- a/src/diffractscout/exporters.py +++ b/src/diffractscout/exporters.py @@ -1068,6 +1068,10 @@ def export_result_bundle( "are compatibility aliases for the two volume-normalized theoretical intensity channels; " "they are not crystallographic residual factors or standardized QPA coefficients" ), + "formula_weight_g_mol": ( + "Expanded unit-cell mass in g/mol, calculated from all occupied sites in the " + "crystallographic unit cell (including Z); it is not the empirical formula mass." + ), "elastic_modulus": "E(n) = 1 / (q(n)^T S q(n)) under engineering-shear Voigt convention", "lab_views_schema": ( "When effective_export_lab_views is true, the emitted results.xlsx opens on 推荐峰表 after 使用说明: " @@ -1081,6 +1085,11 @@ def export_result_bundle( "two_theta_deg, d_A, q_invA, g_invA, " "x_axis_mode (settings.pattern_axis), x (selected axis value), and relative_intensity" ), + "pattern_axis_sampling": ( + "Pattern profiles are sampled on a uniform 2theta grid and transformed to the " + "selected d, q, or g coordinate; they are not uniformly resampled in q or d, " + "and no Jacobian is applied." + ), "two_theta_ranges": ( "In phase metadata, two_theta_range_deg is the configured analysis bound; " "profile_sampled_two_theta_range_deg records the first and last emitted profile " @@ -1141,7 +1150,7 @@ def export_result_bundle( for path in sorted(output.rglob("*")): if path.is_symlink(): raise ValueError(f"Result bundles cannot contain symbolic links: {path}") - if not path.is_file() or path.name == "manifest.json": + if not path.is_file() or path == output / "manifest.json": continue relative = path.relative_to(output).as_posix() suffix = path.suffix.lower() diff --git a/src/diffractscout/gui.py b/src/diffractscout/gui.py index b33645f..1cad9e7 100644 --- a/src/diffractscout/gui.py +++ b/src/diffractscout/gui.py @@ -2013,10 +2013,99 @@ def create_app() -> DiffractScoutApp: return DiffractScoutApp() -def main() -> None: - app = create_app() +_GUI_DISPLAY_ERROR_MARKERS = ( + "no display name and no $display environment variable", + "couldn't connect to display", + "could not connect to display", + "can't open display", + "cannot open display", + "unable to connect to display", +) +_GUI_TCL_RESOURCE_FILE_MARKERS = ( + "clamtheme.tcl", + "scrlbar.tcl", + "ttk/fonts.tcl", + "vistatheme.tcl", +) +_GUI_TCL_RESOURCE_NAME_MARKERS = ( + "init.tcl", + "tk.tcl", + "tcl_library", + "package tk", + "package ttk", +) +_GUI_TCL_RESOURCE_FAILURE_MARKERS = ( + "can't find", + "couldn't find", + "cannot find", + "unable to find", + "can't read", + "couldn't read", + "cannot read", + "no such file", + "error reading", + "can't open", + "couldn't open", + "cannot open", +) + + +def _is_tcl_error(exc: BaseException) -> bool: + tcl_error = getattr(tk, "TclError", None) + return tcl_error is not None and isinstance(exc, tcl_error) + + +def _is_gui_display_initialization_error(exc: BaseException) -> bool: + if not _is_tcl_error(exc): + return False + message = str(exc).casefold() + return any(marker in message for marker in _GUI_DISPLAY_ERROR_MARKERS) + + +def _is_tcl_resource_initialization_error(exc: BaseException) -> bool: + if not _is_tcl_error(exc): + return False + message = str(exc).casefold().replace("\\", "/") + has_resource_file = any(marker in message for marker in _GUI_TCL_RESOURCE_FILE_MARKERS) + has_resource_name = any(marker in message for marker in _GUI_TCL_RESOURCE_NAME_MARKERS) + has_resource_failure = any(marker in message for marker in _GUI_TCL_RESOURCE_FAILURE_MARKERS) + return (has_resource_file or has_resource_name) and has_resource_failure + + +def _is_known_gui_startup_tcl_error(exc: BaseException) -> bool: + return _is_gui_display_initialization_error(exc) or _is_tcl_resource_initialization_error(exc) + + +def _print_startup_error(exc: BaseException) -> None: + if _is_gui_display_initialization_error(exc): + guidance = "Ensure Python has Tk support and a graphical display is available." + elif _is_tcl_resource_initialization_error(exc): + guidance = "Install or repair Python's Tcl/Tk runtime and its .tcl resource files." + else: + guidance = "Install Python with Tk support and ensure a graphical display is available." + print( + "ERROR: Could not start the DiffractScout GUI: " + f"{exc}\n" + guidance, + file=sys.stderr, + ) + + +def main() -> int: + try: + app = create_app() + except RuntimeError as exc: + if str(exc) != "Tkinter is unavailable in this Python installation.": + raise + _print_startup_error(exc) + return 2 + except Exception as exc: + if not _is_known_gui_startup_tcl_error(exc): + raise + _print_startup_error(exc) + return 2 app.mainloop() # type: ignore[attr-defined] + return 0 if __name__ == "__main__": - main() + raise SystemExit(main()) diff --git a/src/diffractscout/pipeline.py b/src/diffractscout/pipeline.py index d8f2698..9f08215 100644 --- a/src/diffractscout/pipeline.py +++ b/src/diffractscout/pipeline.py @@ -692,14 +692,12 @@ def _recover_stale_transaction_lock( try: moved_raw = quarantine.read_bytes() if moved_raw != raw: - if not _path_exists(lock_path): - try: - quarantine.replace(lock_path) - except OSError as exc: - _record_transaction_warning( - f"Could not restore changed transaction lock {lock_path}: {exc}", - warning_sink, - ) + _restore_isolated_transaction_lock( + quarantine, + lock_path, + moved_raw, + warning_sink, + ) raise FileExistsError( f"Transaction lock {lock_path} changed while being isolated; " "the lock was preserved." @@ -2322,20 +2320,24 @@ def run_pipeline( discovery_settings: DiscoverySettings | None = None, analysis_settings: AnalysisSettings | None = None, conventional_unit_cell: bool = True, - include_elasticity: bool = True, + include_elasticity: bool | None = None, include_excel: bool = True, overwrite: bool = False, confirm_above: int = 200, authorize_large_download: bool = False, ) -> PipelineResult: - requested_analysis_settings = analysis_settings or AnalysisSettings( - include_elasticity=include_elasticity - ) - if requested_analysis_settings.include_elasticity != include_elasticity: + if analysis_settings is None: + requested_analysis_settings = AnalysisSettings( + include_elasticity=True if include_elasticity is None else include_elasticity + ) + else: + requested_analysis_settings = analysis_settings + if include_elasticity is not None and requested_analysis_settings.include_elasticity != include_elasticity: requested_analysis_settings = replace( requested_analysis_settings, include_elasticity=include_elasticity ) validate_analysis_settings(requested_analysis_settings) + include_elasticity = requested_analysis_settings.include_elasticity if include_elasticity and not conventional_unit_cell: raise ValueError( "Primitive-cell downloads cannot be paired automatically with Materials Project " diff --git a/src/diffractscout/providers/materials_project.py b/src/diffractscout/providers/materials_project.py index dd95ceb..2a865b5 100644 --- a/src/diffractscout/providers/materials_project.py +++ b/src/diffractscout/providers/materials_project.py @@ -154,7 +154,9 @@ def __init__(self, api_key: str) -> None: from mp_api.client import MPRester # type: ignore[import-not-found] except ImportError as exc: raise RuntimeError( - "Materials Project support is optional. Install with: pip install 'diffractscout[mp]'" + "Materials Project support is optional. For an installed package, run " + 'python -m pip install "diffractscout[mp]"; for a source checkout, run ' + 'python -m pip install -e ".[mp]".' ) from exc self._mpr_cls = MPRester self._metadata: dict[str, object] = { diff --git a/src/diffractscout/quick_export.py b/src/diffractscout/quick_export.py index c9ca9b6..9d7af7c 100644 --- a/src/diffractscout/quick_export.py +++ b/src/diffractscout/quick_export.py @@ -447,6 +447,18 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--fwhm", type=float, default=0.15) parser.add_argument("--eta", type=float, default=0.5) parser.add_argument("--no-elasticity", action="store_true") + parser.add_argument( + "--max-profile-points", + type=int, + default=1_000_000, + help="Safety limit for the generated display-profile grid.", + ) + parser.add_argument( + "--max-reflection-estimate", + type=int, + default=2_000_000, + help="Safety limit for reciprocal-lattice candidate generation.", + ) parser.add_argument("--d-min", type=float, default=None, dest="d_min") parser.add_argument("--d-max", type=float, default=None, dest="d_max") parser.add_argument( @@ -502,6 +514,8 @@ def main(argv: Sequence[str] | None = None) -> int: fwhm_deg=args.fwhm, profile_eta=args.eta, include_elasticity=not args.no_elasticity, + max_profile_points=args.max_profile_points, + max_reflection_estimate=args.max_reflection_estimate, d_min_A=args.d_min, d_max_A=args.d_max, profile_model=args.profile_model, diff --git a/src/diffractscout/structure.py b/src/diffractscout/structure.py index 856ee21..31fb2cb 100644 --- a/src/diffractscout/structure.py +++ b/src/diffractscout/structure.py @@ -442,6 +442,24 @@ def _validate_raw_fractional_coordinates(block: gemmi.cif.Block) -> None: ) +def _validate_parsed_elements(*structures: gemmi.SmallStructure) -> None: + """Reject unknown/virtual Gemmi elements before mass or diffraction work.""" + + for structure in structures: + for site in structure.sites: + element = site.element + atomic_number = int(getattr(element, "atomic_number", 0)) + if atomic_number > 0: + continue + label = str(getattr(site, "label", "") or "") + type_symbol = str(getattr(site, "type_symbol", "") or "") + raise ValueError( + f"CIF atom site {label or ''!r} has unknown or virtual " + f"element {type_symbol or element.name!r} (atomic_number={atomic_number}); " + "refusing mass and diffraction output." + ) + + def _dataset_value(dataset: object, name: str) -> object: if isinstance(dataset, dict): return dataset.get(name) @@ -587,6 +605,7 @@ def load_structure(cif_path: str | Path) -> StructureRecord: structure_factor_small = gemmi.make_small_structure_from_block(block) except Exception as exc: raise ValueError(f"Gemmi could not parse a crystal structure from {path.name}: {exc}") from exc + _validate_parsed_elements(small, structure_factor_small) cell = small.cell values = (cell.a, cell.b, cell.c, cell.alpha, cell.beta, cell.gamma) @@ -603,6 +622,7 @@ def load_structure(cif_path: str | Path) -> StructureRecord: small, structure_factor_small = _reparse_with_resolved_space_group( block, space_group ) + _validate_parsed_elements(small, structure_factor_small) # Keep both independent structures in the same resolved setting. The second # object is modified only for Gemmi's crystallographic occupancy convention. small.spacegroup_hm = space_group.xhm() diff --git a/tests/test_cli.py b/tests/test_cli.py index a5ff3ff..9dd6447 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -117,3 +117,18 @@ class Result: diagnostics = [DiagnosticRecord("analysis", "bad.cif", "error", "failed")] assert _pipeline_exit_code(Result()) == 3 + + +def test_gui_constructor_failure_returns_actionable_exit_code( + monkeypatch: pytest.MonkeyPatch, capsys +) -> None: + import diffractscout.gui as gui_module + + def fail() -> object: + raise RuntimeError("Tkinter is unavailable in this Python installation.") + + monkeypatch.setattr(gui_module, "create_app", fail) + assert main(["gui"]) == 2 + error = capsys.readouterr().err + assert error.startswith("ERROR: Could not start the DiffractScout GUI:") + assert "Traceback" not in error diff --git a/tests/test_composition.py b/tests/test_composition.py index 3aa0cac..1e447de 100644 --- a/tests/test_composition.py +++ b/tests/test_composition.py @@ -83,6 +83,72 @@ def test_formula_and_mpids_can_coexist() -> None: assert parsed.material_ids == ("mp-23", "mp-149") +@pytest.mark.parametrize("text", ["Fe+Ni", "Fe/Ni", "Fe with Ni", "Fe和Ni"]) +def test_additive_composition_keeps_both_elements(text: str) -> None: + assert parse_composition_text(text).elements == ("Fe", "Ni") + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("Fe+Ni+Cr", ("Fe", "Ni", "Cr")), + ("Fe with Ni with Cr", ("Fe", "Ni", "Cr")), + ("Fe+Ni/Cr和Mn", ("Fe", "Ni", "Cr", "Mn")), + ], +) +def test_additive_composition_keeps_chained_elements( + text: str, expected: tuple[str, ...] +) -> None: + assert parse_composition_text(text).elements == expected + + +def test_additive_composition_normalizes_and_deduplicates() -> None: + assert parse_composition_text("fe+ni/fe").elements == ("Fe", "Ni") + + +@pytest.mark.parametrize( + "text", + [ + "in/to", + "Fe/Xx", + "Fe+Xx", + "Fe with Xx", + "Fe+Ni+Xx", + "SS304 + Xx", + "Fe50Ni50 + Xx", + "foo+Fe", + "foo with Fe", + "foo和Fe", + "foo加Fe", + "foo/Fe", + "phase/Fe", + "Fe/", + "Fe//Ni", + "Fe/+Ni", + "Fe +", + "Fe with", + ], +) +def test_invalid_additive_does_not_keep_partial_elements(text: str) -> None: + with pytest.raises(ValueError, match="Explicit additive composition"): + parse_composition_text(text) + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("SS304 + Mo", ("Fe", "Cr", "Ni", "Mo")), + ("Fe50Ni50 + Cr", ("Fe", "Ni", "Cr")), + ("Ti6Al4V + Cu", ("Ti", "Al", "V", "Cu")), + ("Ti-6Al-4V + Cu", ("Ti", "Al", "V", "Cu")), + ], +) +def test_additive_composition_keeps_right_side_after_complex_left( + text: str, expected: tuple[str, ...] +) -> None: + assert parse_composition_text(text).elements == expected + + def test_common_unicode_dashes_and_lowercase_chemsys_are_normalized() -> None: alloy = parse_composition_text("Ti–10V–2Fe–3Al") assert set(alloy.elements) == {"Ti", "V", "Fe", "Al"} diff --git a/tests/test_elasticity.py b/tests/test_elasticity.py index 3d3db78..3204584 100644 --- a/tests/test_elasticity.py +++ b/tests/test_elasticity.py @@ -277,6 +277,51 @@ def test_index_row_matching_multiple_aliases_is_counted_once( assert tensor.status == "valid" +@pytest.mark.parametrize( + "paired_path", + [ + r"C:\archive\diffractscout\absolute_alias.cif", + "/var/lib/diffractscout/absolute_alias.cif", + ], +) +def test_index_row_matches_absolute_paths_by_portable_basename( + demo_inputs: Path, tmp_path: Path, paired_path: str +) -> None: + cif = tmp_path / "absolute_alias.cif" + shutil.copy2(demo_inputs / "synthetic_fcc_al.cif", cif) + index = tmp_path / "elasticity_index.csv" + fields = [ + "cif_filename", + "status", + "numerical_cij", + "source_provider", + "coordinate_frame", + *[f"C{i}{j}_GPa" for i in range(1, 7) for j in range(1, 7)], + ] + row = { + "cif_filename": paired_path, + "status": "valid", + "numerical_cij": "true", + "source_provider": "fixture", + "coordinate_frame": CIF_CARTESIAN_FRAME, + **{ + f"C{i}{j}_GPa": "100" if i == j else "0" + for i in range(1, 7) + for j in range(1, 7) + }, + } + with index.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fields) + writer.writeheader() + writer.writerow(row) + + tensor = discover_elastic_tensor(cif) + + assert tensor is not None + assert tensor.status == "valid" + assert tensor.raw_payload_path == index + + def test_conflicting_canonical_and_legacy_index_pairing_fails_closed( demo_inputs: Path, tmp_path: Path ) -> None: diff --git a/tests/test_exporters.py b/tests/test_exporters.py index 99a3fa5..8c5948d 100644 --- a/tests/test_exporters.py +++ b/tests/test_exporters.py @@ -20,6 +20,7 @@ ) from diffractscout.models import AnalysisSettings from diffractscout.pipeline import analyze_cifs +from diffractscout.validation import verify_bundle def test_csv_and_excel_escape_formula_like_external_text(tmp_path: Path) -> None: @@ -51,6 +52,24 @@ def test_empty_csv_keeps_a_stable_header(tmp_path: Path) -> None: assert path.read_text(encoding="utf-8-sig").splitlines() == ["a,b"] +def test_bundle_manifest_lists_nested_manifest_and_verifies(tmp_path: Path) -> None: + bundle = tmp_path / "bundle" + nested_manifest = bundle / "inputs" / "manifest.json" + nested_manifest.parent.mkdir(parents=True) + nested_manifest.write_text('{"source": "nested"}\n', encoding="utf-8") + + export_result_bundle( + bundle, + analyses=[], + settings=AnalysisSettings(include_patterns=False), + include_excel=False, + ) + + manifest = json.loads((bundle / "manifest.json").read_text(encoding="utf-8")) + assert "inputs/manifest.json" in {entry["path"] for entry in manifest["files"]} + assert verify_bundle(bundle)["ok"] + + def test_pattern_axis_coordinates_keep_zero_q_and_g_finite() -> None: assert _pattern_axis_coordinates(0.0, 1.5406, "q") == (None, 0.0, 0.0, 0.0) assert _pattern_axis_coordinates(0.0, 1.5406, "g") == (None, 0.0, 0.0, 0.0) @@ -156,6 +175,13 @@ def test_provenance_records_effective_conditional_output_flags(tmp_path: Path) - assert "results.xlsx" not in (result.output_dir / "README.md").read_text(encoding="utf-8") assert "pattern_profiles.csv" not in (result.output_dir / "README.md").read_text(encoding="utf-8") assert "always" not in payload["definitions"]["pattern_axis_columns"].lower() + formula_weight_definition = payload["definitions"]["formula_weight_g_mol"].lower() + assert "expanded unit-cell mass" in formula_weight_definition + assert "not the empirical formula mass" in formula_weight_definition + sampling = payload["definitions"]["pattern_axis_sampling"].lower() + assert "uniform 2theta grid" in sampling + assert "not uniformly resampled in q or d" in sampling + assert "no jacobian" in sampling def test_bundle_readme_is_conditional_and_names_profile_model() -> None: diff --git a/tests/test_gui.py b/tests/test_gui.py index 5b57c84..53b7e37 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -231,19 +231,130 @@ def test_analysis_form_rejects_unknown_profile_model() -> None: ) +def _is_expected_gui_unavailable(exc: BaseException) -> bool: + if isinstance(exc, RuntimeError): + return str(exc) == "Tkinter is unavailable in this Python installation." + tcl_error = getattr(gui_module.tk, "TclError", None) + if tcl_error is None or not isinstance(exc, tcl_error): + return False + message = str(exc).lower() + return any( + marker in message + for marker in ( + "no display name and no $display environment variable", + "couldn't connect to display", + "can't open display", + "unable to connect to display", + ) + ) + + def _create_test_app(): try: return create_app() except RuntimeError as exc: - if "Tkinter" in str(exc): + if _is_expected_gui_unavailable(exc): pytest.skip("Tkinter unavailable") raise except Exception as exc: - if gui_module.tk is not None and isinstance(exc, gui_module.tk.TclError): + if _is_expected_gui_unavailable(exc): pytest.skip(f"Tk display unavailable: {exc}") raise +def test_gui_main_converts_constructor_failure_to_actionable_exit( + monkeypatch: pytest.MonkeyPatch, capsys +) -> None: + def fail() -> object: + if gui_module.tk is not None: + raise gui_module.tk.TclError( + "no display name and no $DISPLAY environment variable" + ) + raise RuntimeError("Tkinter is unavailable in this Python installation.") + + monkeypatch.setattr(gui_module, "create_app", fail) + assert gui_module.main() == 2 + error = capsys.readouterr().err + assert error.startswith("ERROR: Could not start the DiffractScout GUI:") + assert "Tk support" in error + assert "graphical display" in error + assert "Traceback" not in error + + +@pytest.mark.parametrize( + "message", + ( + "can't find a usable tk.tcl in the following directories", + 'can\'t read "clamTheme.tcl": no such file or directory', + 'couldn\'t read "scrlbar.tcl": no such file or directory', + 'can\'t find "ttk/fonts.tcl"', + 'couldn\'t read "ttk/vistaTheme.tcl": no such file or directory', + ), +) +def test_gui_main_converts_known_tcl_resource_failure_to_actionable_exit( + monkeypatch: pytest.MonkeyPatch, + capsys, + message: str, +) -> None: + if gui_module.tk is None: + pytest.skip("Tkinter unavailable") + + def fail() -> object: + raise gui_module.tk.TclError(message) + + monkeypatch.setattr(gui_module, "create_app", fail) + assert gui_module.main() == 2 + error = capsys.readouterr().err + assert "Tcl/Tk" in error + assert ".tcl resource files" in error + assert "Traceback" not in error + + +def test_gui_main_does_not_swallow_mainloop_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class BrokenApp: + def mainloop(self) -> None: + raise RuntimeError("worker failure") + + monkeypatch.setattr(gui_module, "create_app", lambda: BrokenApp()) + with pytest.raises(RuntimeError, match="worker failure"): + gui_module.main() + + +def test_gui_main_reraises_unexpected_tcl_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + if gui_module.tk is None: + pytest.skip("Tkinter unavailable") + + def fail() -> object: + raise gui_module.tk.TclError("widget setup failed") + + monkeypatch.setattr(gui_module, "create_app", fail) + with pytest.raises(gui_module.tk.TclError, match="widget setup failed"): + gui_module.main() + + +def test_gui_setup_does_not_skip_unexpected_tcl_or_resource_errors() -> None: + if gui_module.tk is None: + pytest.skip("Tkinter unavailable") + for message in ("widget setup failed", "can't find a usable tk.tcl", "ttk/cursors.tcl"): + error = gui_module.tk.TclError(message) + assert not _is_expected_gui_unavailable(error) + + +def test_gui_main_reraises_unexpected_constructor_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def fail() -> object: + raise RuntimeError("unexpected GUI construction failure") + + monkeypatch.setattr(gui_module, "create_app", fail) + with pytest.raises(RuntimeError, match="unexpected GUI construction failure"): + gui_module.main() + + def test_discovery_form_rejects_zero_limit() -> None: with pytest.raises(ValueError, match="max_total"): discovery_settings_from_form( diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index a439a54..c7057c1 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -15,7 +15,12 @@ import diffractscout.exporters as exporters from diffractscout.elasticity_input import parse_cubic_cij -from diffractscout.models import AnalysisSettings, CandidateRecord, DownloadArtifact +from diffractscout.models import ( + AnalysisSettings, + CandidateRecord, + DiscoverySettings, + DownloadArtifact, +) from diffractscout.pipeline import ( _TargetState, _acquire_transaction_lock, @@ -35,6 +40,70 @@ from diffractscout.validation import verify_bundle +_UNSET = object() + + +def _run_include_elasticity_contract( + demo_inputs: Path, + output: Path, + *, + settings: AnalysisSettings | None = None, + include_elasticity: bool | object = _UNSET, +) -> tuple[object, bool]: + observed: dict[str, bool] = {} + + class Provider: + name = "include-elasticity-contract" + + def search_subsystem( + self, chemsys: str, **_kwargs: object + ) -> list[CandidateRecord]: + return [ + CandidateRecord( + material_id="synthetic-isotropic-cubic", + formula="Al", + energy_above_hull_eV_atom=0.0, + is_stable=True, + queried_chemsys=chemsys, + source_provider=self.name, + ) + ] + + def metadata(self) -> dict[str, object]: + return {"provider": self.name} + + def download_candidates( + self, + candidates: object, + _output_dir: Path, + **kwargs: object, + ) -> list[DownloadArtifact]: + observed["include_elasticity"] = bool(kwargs["include_elasticity"]) + candidate = list(candidates)[0] + return [ + DownloadArtifact( + candidate=candidate, + cif_path=demo_inputs / "synthetic_fcc_al.cif", + elasticity_path=demo_inputs / "synthetic_fcc_al_elasticity.json", + status="ok", + ) + ] + + options: dict[str, object] = { + "discovery_settings": DiscoverySettings( + mode="single_chemsys", max_total=1 + ), + "include_excel": False, + "confirm_above": 1, + } + if settings is not None: + options["analysis_settings"] = settings + if include_elasticity is not _UNSET: + options["include_elasticity"] = include_elasticity + result = run_pipeline("Al", Provider(), output, **options) # type: ignore[arg-type] + return result, observed["include_elasticity"] + + def test_local_pipeline_is_self_contained_and_verifiable(demo_inputs: Path, tmp_path: Path) -> None: output = tmp_path / "bundle" result = analyze_cifs([demo_inputs], output) @@ -64,6 +133,45 @@ def test_local_pipeline_is_self_contained_and_verifiable(demo_inputs: Path, tmp_ assert {"Summary", "Phases", "Peaks", "Elasticity", "Candidates", "Downloads", "Patterns"}.issubset(workbook.sheetnames) +def test_run_pipeline_honors_analysis_settings_elasticity_when_override_omitted( + demo_inputs: Path, tmp_path: Path +) -> None: + result, observed = _run_include_elasticity_contract( + demo_inputs, + tmp_path / "settings-disabled", + settings=AnalysisSettings(include_elasticity=False), + ) + + assert observed is False + assert result.analyses[0].metadata["elasticity_requested"] is False + + +def test_run_pipeline_explicit_elasticity_override_wins_over_analysis_settings( + demo_inputs: Path, tmp_path: Path +) -> None: + result, observed = _run_include_elasticity_contract( + demo_inputs, + tmp_path / "override-enabled", + settings=AnalysisSettings(include_elasticity=False), + include_elasticity=True, + ) + + assert observed is True + assert result.analyses[0].metadata["elasticity_requested"] is True + + +def test_run_pipeline_without_settings_preserves_elasticity_default( + demo_inputs: Path, tmp_path: Path +) -> None: + result, observed = _run_include_elasticity_contract( + demo_inputs, + tmp_path / "default-enabled", + ) + + assert observed is True + assert result.analyses[0].metadata["elasticity_requested"] is True + + def test_excel_omission_warning_is_retained_in_pipeline_result( demo_inputs: Path, tmp_path: Path, monkeypatch ) -> None: @@ -614,6 +722,94 @@ def test_stale_lock_recovery_and_live_lock_protection( assert lock.exists() +def test_stale_lock_restore_never_replaces_live_lock_after_vacancy_check( + tmp_path: Path, monkeypatch +) -> None: + import diffractscout.pipeline as pipeline + + target = tmp_path / "stale-restore-race-target" + lock = _lock_path_for(target) + stale_payload = { + "version": 1, + "host": pipeline._lock_host(), + "pid": 424242, + "created_at": 1.0, + } + live_payload = { + "version": 1, + "host": pipeline._lock_host(), + "pid": os.getpid(), + "created_at": 2.0, + } + stale_raw = (json.dumps(stale_payload, sort_keys=True) + "\n").encode() + live_raw = (json.dumps(live_payload, sort_keys=True) + "\n").encode() + lock.write_bytes(stale_raw) + monkeypatch.setattr(pipeline, "_process_is_alive", lambda _pid: False) + + original_read_bytes = Path.read_bytes + changed_read = False + + def report_changed_quarantine(path: Path) -> bytes: + nonlocal changed_read + raw = original_read_bytes(path) + if path.name.startswith(f"{lock.name}.stale-") and not changed_read: + changed_read = True + return raw + b"changed-after-isolation" + return raw + + monkeypatch.setattr(Path, "read_bytes", report_changed_quarantine) + + original_exists = pipeline._path_exists + vacancy_checked = False + + def observe_vacancy(path: Path) -> bool: + nonlocal vacancy_checked + result = original_exists(path) + if path == lock and not result: + vacancy_checked = True + return result + + monkeypatch.setattr(pipeline, "_path_exists", observe_vacancy) + + original_replace = Path.replace + original_link = pipeline.os.link + live_created = False + + def create_live_lock() -> None: + nonlocal live_created + if not live_created: + lock.write_bytes(live_raw) + live_created = True + + def race_on_replace(source: Path, destination: str | Path) -> Path: + if ( + vacancy_checked + and source.name.startswith(f"{lock.name}.stale-") + and Path(destination) == lock + ): + create_live_lock() + return original_replace(source, destination) + + def race_on_link(source: str, destination: str) -> None: + if Path(destination) == lock: + create_live_lock() + return original_link(source, destination) + + monkeypatch.setattr(Path, "replace", race_on_replace) + monkeypatch.setattr(pipeline.os, "link", race_on_link) + warning_sink: list[str] = [] + + with pytest.warns(RuntimeWarning, match="appeared while restoring"): + with pytest.raises(FileExistsError, match="changed while being isolated"): + pipeline._recover_stale_transaction_lock(lock, warning_sink) + + assert lock.read_bytes() == live_raw + quarantines = sorted(tmp_path.glob(f"{lock.name}.stale-*")) + assert len(quarantines) == 1 + assert quarantines[0].read_bytes() == stale_raw + assert any("appeared while restoring" in message for message in warning_sink) + + def test_lock_snapshot_identity_is_rename_stable_and_replacement_sensitive( tmp_path: Path, ) -> None: diff --git a/tests/test_provider_helpers.py b/tests/test_provider_helpers.py index 6e5defc..264b635 100644 --- a/tests/test_provider_helpers.py +++ b/tests/test_provider_helpers.py @@ -1,7 +1,10 @@ +import builtins import json import math from pathlib import Path +import pytest + from diffractscout.elasticity import MP_CONVENTIONAL_CIF_FRAME, MP_IEEE_CONVENTIONAL_FRAME from diffractscout.models import CandidateRecord from diffractscout.providers.materials_project import ( @@ -29,6 +32,25 @@ def _diagonal_matrix(value: float) -> list[list[float]]: return [[value if i == j else 0.0 for j in range(6)] for i in range(6)] +def test_missing_materials_project_dependency_explains_both_install_modes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + original_import = builtins.__import__ + + def fail_mp_api(name: str, *args: object, **kwargs: object) -> object: + if name == "mp_api.client": + raise ImportError("simulated missing mp-api") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fail_mp_api) + with pytest.raises(RuntimeError) as exc_info: + MaterialsProjectProvider("test-key") + + message = str(exc_info.value) + assert 'python -m pip install "diffractscout[mp]"' in message + assert 'python -m pip install -e ".[mp]"' in message + + def test_materials_project_document_normalization() -> None: doc = { "material_id": "mp-123", diff --git a/tests/test_quick_export.py b/tests/test_quick_export.py index 92e5409..b92e65b 100644 --- a/tests/test_quick_export.py +++ b/tests/test_quick_export.py @@ -521,6 +521,33 @@ def test_quick_export_cli_entry(demo_inputs: Path, tmp_path: Path) -> None: assert (tmp_path / "cli_out_bundle" / "manifest.json").is_file() +def test_standalone_quick_export_safety_limits_are_propagated( + demo_inputs: Path, tmp_path: Path +) -> None: + output = tmp_path / "limited" + code = quick_export_main( + [ + str(demo_inputs), + "-o", + str(output), + "--max-profile-points", + "10000", + "--max-reflection-estimate", + "5678", + "--no-elasticity", + "--no-excel", + ] + ) + + assert code == 0 + provenance = json.loads( + (output / "provenance.json").read_text(encoding="utf-8") + ) + settings = provenance["analysis_settings"] + assert settings["max_profile_points"] == 10000 + assert settings["max_reflection_estimate"] == 5678 + + @pytest.mark.parametrize( ("radiation_option", "radiation_value", "expected_mode", "expected_wavelength", "expected_energy"), [ @@ -616,6 +643,8 @@ def test_standalone_quick_export_radiation_options_are_mutually_exclusive() -> N def test_standalone_quick_export_help_is_legacy_windows_console_safe() -> None: help_text = build_quick_export_parser().format_help() assert "K-alpha" in help_text + assert "--max-profile-points" in help_text + assert "--max-reflection-estimate" in help_text help_text.encode("cp936") diff --git a/tests/test_release.py b/tests/test_release.py index 8c2af39..ce9e457 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -1,6 +1,8 @@ from __future__ import annotations import importlib.util +import json +import os from pathlib import Path import pytest @@ -62,3 +64,155 @@ def test_prepare_dist_dir_rejects_symlink_relative_to_repository_root( with pytest.raises(SystemExit, match="must not be a symlink"): module._prepare_dist_dir("dist-link") + + +def test_preflight_dependency_check_reports_copyable_install_command( + monkeypatch, +) -> None: + module = _load_check_release() + + def missing_release_tools(name: str): + return None if name in {"build", "twine"} else object() + + monkeypatch.setattr(module.importlib.util, "find_spec", missing_release_tools) + with pytest.raises(SystemExit) as error: + module._ensure_preflight_dependencies(skip_tests=False, skip_wheel=False) + + message = str(error.value) + assert "build, twine" in message + assert 'python -m pip install -e ".[test,release]"' in message + + +def test_preflight_dependency_check_does_not_require_release_tools_when_wheel_skipped( + monkeypatch, +) -> None: + module = _load_check_release() + + def missing_release_tools(name: str): + return None if name in {"build", "twine"} else object() + + monkeypatch.setattr(module.importlib.util, "find_spec", missing_release_tools) + module._ensure_preflight_dependencies(skip_tests=False, skip_wheel=True) + + +def test_preflight_dependency_check_reports_missing_pytest_when_tests_are_enabled( + monkeypatch, +) -> None: + module = _load_check_release() + + def missing_pytest(name: str): + return None if name == "pytest" else object() + + monkeypatch.setattr(module.importlib.util, "find_spec", missing_pytest) + with pytest.raises(SystemExit, match="Missing preflight tooling: pytest"): + module._ensure_preflight_dependencies(skip_tests=False, skip_wheel=True) + + +def test_resolve_installed_launchers_uses_venv_layout_and_platform_suffix( + tmp_path: Path, + monkeypatch, +) -> None: + module = _load_check_release() + + posix_environment = tmp_path / "posix-venv" + posix_bin = posix_environment / "bin" + posix_bin.mkdir(parents=True) + for name in module.DECLARED_ENTRYPOINTS: + launcher = posix_bin / name + launcher.write_text("#!/bin/sh\n", encoding="utf-8") + launcher.chmod(0o755) + posix = module._resolve_installed_launchers(posix_environment, windows=False) + assert posix == {name: (posix_bin / name).resolve() for name in module.DECLARED_ENTRYPOINTS} + + non_executable = posix_bin / "diffractscout-gui" + non_executable.chmod(0o644) + if os.access(non_executable, os.X_OK): + # Windows does not model POSIX mode bits; force the same OS-level + # result so the resolver's executable check remains covered there. + real_access = module.os.access + monkeypatch.setattr( + module.os, + "access", + lambda path, mode: False + if Path(path) == non_executable + else real_access(path, mode), + ) + with pytest.raises(SystemExit, match="not executable.*diffractscout-gui"): + module._resolve_installed_launchers(posix_environment, windows=False) + + windows_environment = tmp_path / "windows-venv" + windows_scripts = windows_environment / "Scripts" + windows_scripts.mkdir(parents=True) + for name in module.DECLARED_ENTRYPOINTS: + (windows_scripts / f"{name}.exe").write_bytes(b"launcher") + + def reject_posix_permission_probe(path, mode): + raise AssertionError(f"Windows launcher unexpectedly probed with os.access: {path}") + + monkeypatch.setattr(module.os, "access", reject_posix_permission_probe) + windows = module._resolve_installed_launchers(windows_environment, windows=True) + assert windows == { + name: (windows_scripts / f"{name}.exe").resolve() + for name in module.DECLARED_ENTRYPOINTS + } + + (windows_scripts / "diffractscout-gui.exe").unlink() + with pytest.raises(SystemExit, match="diffractscout-gui"): + module._resolve_installed_launchers(windows_environment, windows=True) + + +def test_installed_entrypoint_metadata_contract_requires_declared_targets() -> None: + module = _load_check_release() + module._validate_installed_entrypoint_metadata(module.DECLARED_ENTRYPOINTS) + + missing = dict(module.DECLARED_ENTRYPOINTS) + missing.pop("diffractscout-gui") + with pytest.raises(SystemExit, match="missing=diffractscout-gui"): + module._validate_installed_entrypoint_metadata(missing) + + redirected = dict(module.DECLARED_ENTRYPOINTS) + redirected["diffractscout-gui"] = "diffractscout.cli:main" + with pytest.raises(SystemExit, match="mismatched=diffractscout-gui"): + module._validate_installed_entrypoint_metadata(redirected) + + +def test_release_receipt_records_installed_entrypoint_checks_without_repo_artifacts( + tmp_path: Path, + monkeypatch, +) -> None: + module = _load_check_release() + + class FakeReadiness: + @staticmethod + def release_source_fingerprint() -> dict[str, object]: + return {"ok": True, "sha256": "source-hash", "file_count": 3} + + monkeypatch.setattr(module, "ROOT", tmp_path) + monkeypatch.setattr(module, "_load_readiness_module", lambda: FakeReadiness) + receipt = module._write_release_acceptance( + "0.4.0", + clean_wheel={ + "mode": "isolated-dependencies", + "package": "dist/diffractscout-0.4.0-py3-none-any.whl", + "source_tree_import": "rejected", + "commands": ( + "pip-check,entrypoint-launchers,entrypoint-metadata,installed-entrypoints," + "version,demo,verify,benchmark,quick-export,verify" + ), + }, + preflight_source={"sha256": "source-hash"}, + ) + + assert receipt == tmp_path / "build/release-preflight/release_acceptance.json" + payload = json.loads(receipt.read_text(encoding="utf-8")) + commands = set(payload["clean_wheel"]["commands"].split(",")) + assert { + "pip-check", + "demo", + "verify", + "benchmark", + "quick-export", + "entrypoint-launchers", + "entrypoint-metadata", + "installed-entrypoints", + } <= commands diff --git a/tests/test_repository_quality.py b/tests/test_repository_quality.py index a0444a8..ff0a855 100644 --- a/tests/test_repository_quality.py +++ b/tests/test_repository_quality.py @@ -138,6 +138,22 @@ def test_quick_export_batch_normalizes_first_path_without_delayed_expansion() -> assert "%%~dpI%%~nI_diffractscout.xlsx" in script +def test_gui_batch_launcher_has_safe_python_precedence_and_exit_contract() -> None: + root = Path(__file__).resolve().parents[1] + script = (root / "启动DiffractScout.bat").read_text(encoding="utf-8") + + repository_venv = 'if exist "%~dp0.venv\\Scripts\\python.exe" goto :venv_python' + current_python_probe = 'python -c "import sys"' + current_python_run = 'python "%ENTRY%" gui' + assert repository_venv in script + assert current_python_probe in script + assert "py -3 \"%ENTRY%\" gui" in script + assert "scripts\\diffractscout_entry.py" in script + assert "endlocal & exit /b %EXIT_CODE%" in script + assert script.index(repository_venv) < script.index(current_python_probe) + assert script.index(current_python_probe) < script.index(current_python_run) + + @pytest.mark.skipif(sys.platform != "win32", reason="requires Windows cmd.exe") def test_quick_export_batch_reports_special_character_sibling_path( tmp_path: Path, diff --git a/tests/test_structure.py b/tests/test_structure.py index 4e07438..883bfe2 100644 --- a/tests/test_structure.py +++ b/tests/test_structure.py @@ -334,6 +334,64 @@ def test_unknown_or_nonfinite_fractional_coordinate_fails_before_gemmi( load_structure(cif) +def test_unknown_or_virtual_cif_element_fails_before_mass_and_diffraction( + tmp_path: Path, +) -> None: + cif = tmp_path / "unknown_element.cif" + cif.write_text( + """data_unknown_element +_cell_length_a 4 +_cell_length_b 4 +_cell_length_c 4 +_cell_angle_alpha 90 +_cell_angle_beta 90 +_cell_angle_gamma 90 +_space_group_IT_number 225 +loop_ +_atom_site_label +_atom_site_type_symbol +_atom_site_fract_x +_atom_site_fract_y +_atom_site_fract_z +_atom_site_occupancy +Xx1 Xx 0 0 0 1 +""", + encoding="utf-8", + ) + + with pytest.raises(ValueError, match=r"unknown or virtual.*atomic_number=0"): + load_structure(cif) + + +def test_deuterium_cif_element_remains_valid(tmp_path: Path) -> None: + cif = tmp_path / "deuterium.cif" + cif.write_text( + """data_deuterium +_cell_length_a 4 +_cell_length_b 4 +_cell_length_c 4 +_cell_angle_alpha 90 +_cell_angle_beta 90 +_cell_angle_gamma 90 +_space_group_IT_number 1 +loop_ +_atom_site_label +_atom_site_type_symbol +_atom_site_fract_x +_atom_site_fract_y +_atom_site_fract_z +_atom_site_occupancy +D1 D 0 0 0 1 +""", + encoding="utf-8", + ) + + structure = load_structure(cif) + + assert structure.small_structure.sites[0].element.name == "D" + assert structure.small_structure.sites[0].element.atomic_number == 1 + + def test_invalid_fractional_coordinate_subprocess_exits_without_native_crash( tmp_path: Path, ) -> None: diff --git "a/\345\220\257\345\212\250DiffractScout.bat" "b/\345\220\257\345\212\250DiffractScout.bat" index aa86c13..9ba18ef 100644 --- "a/\345\220\257\345\212\250DiffractScout.bat" +++ "b/\345\220\257\345\212\250DiffractScout.bat" @@ -2,25 +2,43 @@ REM Launch the DiffractScout desktop GUI from the repository (or install) root. setlocal EnableExtensions cd /d "%~dp0" +set "ENTRY=%~dp0scripts\diffractscout_entry.py" +set "EXIT_CODE=1" +if exist "%~dp0.venv\Scripts\python.exe" goto :venv_python + +where python >nul 2>&1 +if errorlevel 1 goto :check_py_launcher +python -c "import sys" >nul 2>&1 +if errorlevel 1 goto :check_py_launcher +goto :current_python + +:check_py_launcher where py >nul 2>&1 -if %ERRORLEVEL%==0 ( - py -3 -m diffractscout gui - if not errorlevel 1 goto :done -) - -where diffractscout-gui >nul 2>&1 -if %ERRORLEVEL%==0 ( - diffractscout-gui - if not errorlevel 1 goto :done -) - -echo ERROR: Could not start DiffractScout GUI. -echo Install with: py -3 -m pip install -e ".[gui-dnd]" -echo Or ensure py -3 -m diffractscout gui works from this directory. -pause -exit /b 1 +if not errorlevel 1 goto :py_launcher + +echo ERROR: Could not start DiffractScout GUI: no usable Python interpreter was found. +echo Install with: python -m pip install -e ".[gui-dnd]" +echo Or install Python and use: py -3 -m pip install -e ".[gui-dnd]" +goto :finish -:done -endlocal -exit /b 0 +:venv_python +"%~dp0.venv\Scripts\python.exe" "%ENTRY%" gui +set "EXIT_CODE=%ERRORLEVEL%" +goto :finish + +:current_python +python "%ENTRY%" gui +set "EXIT_CODE=%ERRORLEVEL%" +goto :finish + +:py_launcher +py -3 "%ENTRY%" gui +set "EXIT_CODE=%ERRORLEVEL%" + +:finish +if "%EXIT_CODE%"=="0" goto :success +echo ERROR: DiffractScout GUI exited with code %EXIT_CODE%. +pause +:success +endlocal & exit /b %EXIT_CODE%