diff --git a/.claude/rules/project-structure.md b/.claude/rules/project-structure.md index 63ed0ed..62d6f1e 100644 --- a/.claude/rules/project-structure.md +++ b/.claude/rules/project-structure.md @@ -12,13 +12,14 @@ src/sm_tias_lightsim/ ├── __init__.py ├── uniformity.py # Rec.709 輝度変換 + CoV/std/max-min(核計算) -├── cli/ # CLI エントリポイント(render / evaluate / visualize) -├── scene/ # YAML → Mitsuba scene(schema / builder / lights / materials / sensors) +├── cli/ # CLI エントリポイント(render / evaluate / visualize / remote) +├── scene/ # YAML → Mitsuba scene(schema / builder / lights / materials / sensors / paths) ├── geometry/ # STL 読込・単位変換・メッシュ分割(loader / transforms / subdivide) ├── render/ # Mitsuba 実行と出力(runner / output) ├── evaluation/ # 評価パイプライン(roi / zones / radial / oetf / normalize / shape_metrics / visualize …) ├── visualize/ # 3D 可視化(baker / projection / viewer) -└── runtime/ # 実行時ヘルパ(llvm_init 等) +├── remote/ # 研究室サーバでの実行(commands / files / transfer.ssh・tmux の呼び出しは cli/remote に寄せる) +└── runtime/ # 実行時ヘルパ(llvm_init / overrides) tests/ # src のサブパッケージ構造をミラー(testing.md 参照) scripts/ # 単発の調査・キャリブ・図生成スクリプト(パッケージ外) diff --git a/CLAUDE.md b/CLAUDE.md index ece1c2c..56d7a8e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ ## 開発進捗 -最新: フェーズ 7(実装)— **[TEST_17](docs/06_TEST/TEST_17_オフ軸LED配置の影響とサイズ依存.md) オフ軸 LED 配置の影響とサイズ依存**を実施.LED を**深さ方向軸の中点の高さ・+X 側の壁面**に置き板中心へ向ける構成(バッフルは数値サイジング・遮蔽レイキャスト 10 条件 100%)で,球 `similar_radial` 5 半径 + 立方体 5 サイズをスイープ.**仮説「大きいほど光源位置の影響が薄まる」は両形状で成立**(m=1 符号付き a1 が最大サイズで最小の約 4 割まで減衰.球 −4.0→−1.6% / 立方体 −7.2→−2.3%).**明るくなるのは LED の反対側**(バッフル遮蔽下では反対側の壁の一次照射キャップが非対称の源).**オフ軸頑健性は球 > 立方体で軸上(TEST_16: 同内面積なら立方体が球の 0.62〜0.89 倍で最良,L=360 で構造 CoV 0.0090=全条件最良)と逆転**——位置ずれの Δ構造CoV は球 ±0.006 以内=実質ゼロ,立方体は最大 2.5 倍悪化.実機設計は「立方体 + 軸上配置」か「球 + 配置自由度」の二択構造.**環境**: mitsuba は SAC の DLL ブロックにより **3.7.1 に固定**(検証済み.テストは `python -m pytest`.ENV_04 参照).**未実施**: ずれ量の感度曲線(軸上→壁面の中間)・非対称ピーク非単調性の原因・フラット天井版・直方体 L×L×D・ノイズフロア.詳細は [docs/PROGRESS.md](docs/PROGRESS.md). +最新: フェーズ 7(実装)— **研究室サーバ(Newton)で計算する仕組みを整備**.`sm-remote` で手元からコード送信・tmux でジョブ起動・結果回収ができ,GPU(cuda_ad_rgb)は手元 CPU の 37〜72 倍で結果も一致(初回セットアップは ENV_02「研究室サーバ編」,日常操作は ENV_04「研究室サーバでの実行」).**環境**: mitsuba は **3.7.1 に固定**(テストは `uv run python -m pytest`).2026-09-13 に開発機の Smart App Control が評価モードからオンに切り替わり mitsuba がブロックされたため,**SAC はオフ**にした(ENV_04 参照).直前の研究ステップは [TEST_17](docs/06_TEST/TEST_17_オフ軸LED配置の影響とサイズ依存.md) オフ軸 LED 配置(**未実施**: ずれ量の感度曲線・非対称ピーク非単調性の原因・フラット天井版・直方体 L×L×D・ノイズフロア).詳細は [docs/PROGRESS.md](docs/PROGRESS.md). 実装済みモジュール: - `src/sm_tias_lightsim/uniformity.py` — Rec.709 輝度変換 + CoV/std/max-min(SmTIAS-Evaluation コピー) @@ -43,14 +43,17 @@ - `src/sm_tias_lightsim/scene/builder.py` — SceneConfig → Mitsuba scene dict(PLY 一時ファイル経由) - `src/sm_tias_lightsim/render/runner.py` — variant 管理 + `mi.render` 実行 - `src/sm_tias_lightsim/render/output.py` — PNG / luminance.npy 出力 -- `src/sm_tias_lightsim/cli/render.py` — `sm-render` CLI(YAML → PNG / luminance.npy) +- `src/sm_tias_lightsim/cli/render.py` — `sm-render` CLI(YAML → PNG / luminance.npy).`--variant` > 環境変数 `SM_RENDER_VARIANT` > YAML の優先順で variant を上書き(手元 llvm・サーバ cuda を同じ YAML で) - `src/sm_tias_lightsim/cli/evaluate.py` — `sm-evaluate` CLI(PNG / luminance.npy → metrics.csv + figures/).`oetf.enabled` 時は ROI 抽出後・指標算出前に sRGB OETF を順方向適用,`normalization.enabled` 時は OETF 前段に linear mean 揃え正規化(OETF と排他).後方互換、シミュ用設定のみ - `src/sm_tias_lightsim/visualize/baker.py` — face 中心の照度ベイク.`mode="direct"`(shadow ray + cosine + 距離減衰、直接光のみ、後方互換)/ `mode="path"`(scalar_rgb・完全シーン・コサイン重点 hemisphere MC、多重反射込み per-face、`exclude_mesh_ids` 対応).`max_depth` 引数で path ベイクの反射回数を render 設定から独立設定(None で render 継承=後方互換) - `src/sm_tias_lightsim/visualize/projection.py` — orthographic センサ投影の純関数(評価板メッシュ特定 / `project_luminance_to_vertices` 頂点 bilinear 投影 / `compute_orthographic_uv` テクスチャ UV / `compute_face_camera_mask` 幾何 Z ベース巻き順非依存の評価面選択 / `compute_inward_face_mask` 法線がシーン中心側を向く内向き面判定=幾何カリング用).`sensors.py` look_at@scale 規約と整合(SPEC_03「画像↔物理の向き」検証済) - `src/sm_tias_lightsim/visualize/viewer.py` — PyVista 3D ビューア.既定=全メッシュ統一ベイク着色(**lit のみ共有 clim**・log/linear・単一カラーバー・`bake_opacity` 半透明・`hide_below` 非表示・`bake_smooth` 平滑化・depth peeling・viridis)+**`cull_outward`(既定 ON)で外向き ≈0 面を非描画**=暗殻の濁り/見かけ段差を解消・clim も可視内向き面に密着;`hide_sensor_marker`/`hide_led_markers` でマーカー個別非表示;`luminance_map` 指定時=評価板 512² テクスチャ投影(E=π·L/ρ で壁と同一 W/m² スケール、内側カメラ向き face のみ、外側ニュートラル、マスク無効時 per-vertex フォールバック).**発表スライド向け表示機能(Step 8-14 期に追加)**: `style`("default"/"slide".slide=白背景・座標軸/bounds 非表示・アンチエイリアス・黒文字スカラーバー `_build_scalar_bar_args`・センサマーカー自動非表示.未知値は warning で default フォールバック)/ `cmap`(None で style 既定を解決:default→viridis・slide→hot=照明均一性評価と同一.未知名は warning で style 既定にフォールバック,`_make_irradiance_texture(cmap_name=)` も同一 cmap)/ `subdivide_max_edge`(m 単位.ベイク前に三角形を均一分割→巨大三角形のブロック感解消)/ `bake_denoise`(`denoise_face_scalars_median` 頂点経由メディアン近似でMCノイズ/firefly除去,エッジ保存.log変換後・smooth前に適用,`_get_triangle_faces` で PolyData/UnstructuredGrid 両対応)/ `bake_smooth_radius`(m 単位.`smooth_face_scalars_radius` ボクセル splat/gather 物理半径ガウス平滑化=メモリ有界・純NumPy・sigma=radius/2.denoise後・bake_smooth前に適用,三角形スケールのチェッカーノイズ除去)/ `backface_cull`(VTK 背面カリング `culling="back"`,opacity=1.0 併用で手前壁が隠れ混色なし) - `src/sm_tias_lightsim/geometry/subdivide.py` — 三角形メッシュ均一分割の純関数(新規).`subdivide_mesh_to_max_edge(vertices, faces, max_edge)`=`trimesh.remesh.subdivide_to_size` で「最大辺長 ≤ max_edge」まで決定論的に三角形分割(同一入力→同一出力,baker と viewer が独立呼び出しでも face 配列一致).単位は呼び出し側座標系に従う(m 系で呼べば m).STL の三角形サイズが極端に不均一(曲面 ~1mm² vs 平面壁 最大 4794mm²)でも均一な空間解像度の per-face ベイクを可能にする - `src/sm_tias_lightsim/cli/visualize.py` — `sm-visualize` CLI(YAML → 3D 表示 / screenshot).`--bake-mode {direct,path}` / `--bake-samples` / `--bake-max-depth`(path ベイクの反射回数、既定 16、render 設定から独立)/ `--bake-cache `(1回計算→以後即時.`--bake-subdivide` 設定値も npz に記録し不一致なら自動再ベイク)/ `--cull-outward|--no-cull-outward`(既定 ON)/ `--opacity` / `--hide-below` / `--bake-smooth` / `--bake-scale {linear,log}` / `--clip-percentile` / `--no-sensor-marker`・`--no-led-markers`・`--no-markers` / `--render `(任意・テクスチャ投影).**発表スライド向け(Step 8-14 期に追加)**: `--style {default,slide}` / `--cmap NAME`(既定: style に応じ viridis/hot)/ `--bake-subdivide MM`(三角形を最大辺長 MM mm 以下に均一分割)/ `--bake-denoise N`(メディアンデノイズ反復)/ `--bake-smooth-radius MM`(物理半径 MM mm のガウス平滑化)/ `--backface-cull`(背面カリング).mm 引数は内部で ×0.001 して m 系で show_scene/baker へ.`--render` 無し=全面統一ベイク(既定) -- `src/sm_tias_lightsim/runtime/llvm_init.py` — LLVM JIT 初期化ヘルパ.`ensure_drjit_libllvm_path()` で `DRJIT_LIBLLVM_PATH` 未設定時に Windows/POSIX 標準パスを自動探索して `os.environ` 設定.`log_variant_status()` で variant 経路(llvm_/cuda_/scalar_)を INFO 明示,要求 llvm_* で実 scalar_* なら WARNING 追加.`runner.py` / `baker.py` から呼ばれ環境変数未設定時のサイレントクラッシュを防ぐ +- `src/sm_tias_lightsim/runtime/llvm_init.py` — LLVM JIT 初期化ヘルパ.`ensure_drjit_libllvm_path()` で `DRJIT_LIBLLVM_PATH` 未設定時に Windows/POSIX 標準パスを自動探索して `os.environ` 設定.`log_variant_status()` で variant 経路(llvm_/cuda_/scalar_)を INFO 明示,要求 llvm_* で実 scalar_* なら WARNING 追加.`runner.py` / `baker.py` から呼ばれ環境変数未設定時のサイレントクラッシュを防ぐ.Ubuntu / Debian の `libLLVM-<版>.so` と `/usr/lib/llvm-*/lib/`(Fedora 形式も)も探索し新しい版を優先 +- `src/sm_tias_lightsim/runtime/overrides.py` — 環境変数による実行時上書き.`SM_RENDER_VARIANT`(sm-render の variant)と `SM_DRJIT_THREADS`(Dr.Jit の CPU スレッド数上限.`runner.py` / `baker.py` が `dr.set_thread_count` で適用.共用サーバの全コア占有を防ぐ).不正値は ValueError +- `src/sm_tias_lightsim/scene/paths.py` — `relative_mesh_path()`:スイープ生成スクリプトが `_tmp` YAML に書くメッシュパスを YAML からの相対パス(POSIX 区切り)にする(手元とサーバで同じ YAML を読むため.別ドライブは警告して絶対パス) +- `src/sm_tias_lightsim/remote/{commands,files,transfer}.py` + `src/sm_tias_lightsim/cli/remote.py` — `sm-remote` CLI(研究室サーバ Newton での実行).push(tar over ssh.`.venv`/`output`/`.git`/`configs/_tmp_*` 除外)/ run(tmux でデタッチ起動.既定 `cuda_ad_rgb`・`--gpu`・`--threads`)/ status / logs / attach / kill / pull(`output/` 配下のみ.要求パス外・不正な名前・リンクを拒否).サーバ上で実行すると止まって案内.使い方は ENV_04「研究室サーバでの実行」 - `src/sm_tias_lightsim/evaluation/dng_loader.py` — DNG 読み込みモジュール.SmTIAS-Evaluation `src/io/dng_loader.py` を SmTIAS-LightSim にコピーしたもの (オリジナルは SmTIAS-Evaluation 側で管理.バグ修正・機能追加はそちらで反映).主要 API: `load_image_dng(path, demosaic, black_level, output_format)`.`output_format` は `rgb_float32` / `luma_float32` / `rgb_uint8`.linear 系出力 (`rgb_float32` / `luma_float32`) は `output_color=raw` でカラーマトリクス非適用・per-channel 比例性保持.`gamma=(1,1) + no_auto_bright=True + use_camera_wb=False + user_wb=[1,1,1,1]` で linear raw を保つ.依存: `rawpy>=0.21.0` - `src/sm_tias_lightsim/evaluation/radial.py` — 20 ビン放射状輝度プロファイル.既存 `calc_radial_profile` に加えて SmTIAS-Evaluation `src/analysis/spatial.py:calc_radial_min_max_ratio` をマージ.動径 min/max 比 (20 点輪帯平均) は画素単位 max/min よりノイズに対しロバスト.nan ビン除外を移植時に補強 (既存 `calc_radial_profile` は空ビンを nan で返す) - `src/sm_tias_lightsim/evaluation/shape_metrics.py` — 輝度分布「形」評価指標モジュール(新規).現状 CoV/勾配/C/P/動径 min/max は不均一性の絶対量しか測れず,Y 軸単調勾配と中央ピーク楕円を構造的に区別できない問題を解決する 5 つの純関数: `calc_intensity_centroid`(輝度重心,5 値)/ `calc_angular_radial_decomposition`(極座標分解,angular_ratio が 0=同心円〜1=方向依存,3 値)/ `calc_ssim`(構造的類似度,scikit-image 依存,shape_reference 指定時のみ)/ `calc_ncc`(正規化相関係数,絶対輝度不変)/ `calc_intensity_pca`(weighted PCA で主軸角度と eccentricity,4 値).mask 省略可能,完全均一画像で angular_ratio/NCC は NaN を返す.`MetricsConfig.shape_metrics=true` / `shape_reference` フィールドで有効化,後方互換保持.依存追加: `scikit-image>=0.21.0`.詳細: [TECH_11 形状指標の見直し](docs/05_TECH/TECH_11_形状指標の見直しと新指標導入.md) diff --git "a/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" "b/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" index 0a5f5c5..6195b67 100644 --- "a/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" +++ "b/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" @@ -171,3 +171,177 @@ 3. コード側で `mitsuba.set_variant('cuda_ad_rgb')` に切替え 詳細は Mitsuba 3 公式ドキュメントを参照. + +## 研究室サーバ編 (Lab Server Setup) + +研究室の GPU サーバでレンダを回すための初回セットアップ.編集は手元(Windows)で行い,`sm-remote` でコードを送ってサーバの tmux 上で実行し,結果を持ち帰る(日常の操作は [ENV_04 開発コマンド](ENV_04_開発コマンド.md) の「研究室サーバでの実行」). +GitHub の認証情報はサーバに置かない(計算して結果を受け取るだけなので,サーバでは git を使わず常に手元から送る). + +| Host 名 | アドレス | 状態(2026-09-13 時点) | +| --- | --- | --- | +| `Newton` | `Newton.local`(IPv4 `192.168.23.14`) | Ubuntu 22.04,EPYC 64 スレッド,RTX A6000×3.鍵認証・uv・Python 3.12・`uv sync` 済み.通常はここを使う.学外からは `Newton-via-lab`(下記「学外から接続する」) | +| `Einstein` | `192.168.23.13` | パスワードは通るがホームディレクトリが無い(既知事項参照).使用不可 | +| `Archimedes` | `192.168.23.15` | パスワードログインのみ(鍵は未登録) | + +### SSH 接続設定 (SSH Config) + +ssh は Windows 11 標準の OpenSSH(`C:\Windows\System32\OpenSSH\ssh.exe`)を使う.PowerShell からも Git Bash からも使える.Windows には rsync が無いため,転送は `sm-remote`(tar over ssh)で行う. + +`C:\Users\<ユーザー名>\.ssh\config` に Host を登録する(例): + +```text +Host Newton + HostName Newton.local + User hasegawa + IdentityFile ~/.ssh/id_ed25519 + +Host Einstein + HostName 192.168.23.13 + User hasegawa + IdentityFile ~/.ssh/id_ed25519 + +Host Archimedes + HostName 192.168.23.15 + User hasegawa + IdentityFile ~/.ssh/id_ed25519 +``` + +### 鍵の生成と登録 (SSH Key) + +鍵が無ければ生成する(Git Bash.パスフレーズなし): + +```bash +ssh-keygen -t ed25519 -N "" -f ~/.ssh/id_ed25519 +``` + +Windows には `ssh-copy-id` が無いので,PowerShell から公開鍵を流し込んで登録する(パスワードを 1 回聞かれる.重複登録はしない): + +```powershell +Get-Content $env:USERPROFILE\.ssh\id_ed25519.pub | ssh Newton 'k=$(cat); mkdir -p ~/.ssh && chmod 700 ~/.ssh && touch ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys && (grep -qxF "$k" ~/.ssh/authorized_keys || echo "$k" >> ~/.ssh/authorized_keys)' +``` + +`ssh Newton hostname` がパスワード無しで通れば OK. + +### uv と Python 3.12 の導入 (uv & Python) + +サーバにログインして実行する: + +```bash +curl -LsSf https://astral.sh/uv/install.sh | env UV_NO_MODIFY_PATH=1 sh +~/.local/bin/uv python install 3.12 +``` + +- `UV_NO_MODIFY_PATH=1` はシェルの設定ファイルを書き換えないため.Ubuntu の `~/.profile` は `~/.local/bin` があればログイン時に PATH へ足すので,対話ログインでは `uv` で呼べる +- ただし `ssh Newton 'コマンド'` の非ログイン実行では `~/.profile` が読まれず `uv` が PATH に入らない.`sm-remote` は `~/.local/bin/uv` を絶対パスで呼ぶ(`--uv` / 環境変数 `SM_REMOTE_UV` で変更可).ジョブ内では uv のディレクトリを PATH に足すので,スクリプトが呼ぶ `uv run sm-render` も動く +- tmux は `/usr/bin/tmux` に入っている(`sm-remote run` が使う) + +### コードの転送 (Code Transfer) + +手元のリポジトリで実行する: + +```powershell +uv run sm-remote push +``` + +- 送り先: `Newton:~/lab/SmTIAS-LightSim`(`--host` / `--remote-dir`,または環境変数 `SM_REMOTE_HOST` / `SM_REMOTE_DIR` で変更可) +- 送らないもの: `.venv/`(Windows 用)・`output/`・`.git/`・`__pycache__/`・`.pytest_cache/`・`.ruff_cache/`・`configs/_tmp_*`(スイープ生成物.約 450 MB.必要なら `--include` / `--only` で個別に送る) +- gitignore 対象でも送るもの: `lightsim/`・`reference_measurement/`・`REFERENCE/`(実測参照データ.サーバでの評価に使う) +- 全体で約 160 MB.初回(2026-09-13,tar + ssh の手動転送)は LAN で 39 秒,以後の `sm-remote push` は 10 秒前後だった.日本語ファイル名(`configs/meshes/*.stl`)もそのまま展開される + +### 依存の同期 (uv sync) + +```bash +ssh Newton 'cd ~/lab/SmTIAS-LightSim && ~/.local/bin/uv sync' +``` + +以後の `uv run` は実行のたびに `uv.lock` との差分を自動で同期するので,`pyproject.toml` / `uv.lock` を変えて push した後も明示の `uv sync` は通常不要. + +### 動作確認 (Verification) + +サーバにログインして実行する: + +```bash +cd ~/lab/SmTIAS-LightSim +uv run python -c "import mitsuba as mi; mi.set_variant('cuda_ad_rgb'); print(mi.__version__, mi.variant())" +uv run python -c "import mitsuba as mi; mi.set_variant('llvm_ad_rgb'); print(mi.__version__, mi.variant())" +``` + +`3.7.1 cuda_ad_rgb` / `3.7.1 llvm_ad_rgb` と表示されれば OK.全テストは手元から次のように回せる(2026-09-13 に全テスト通過を確認済み): + +```powershell +uv run sm-remote run --name pytest-all --yaml-variant -- python -m pytest -m "" +uv run sm-remote logs pytest-all -f +``` + +PowerShell で `-m '""'` と書くと `""` の 2 文字がそのまま渡り,pytest が「Wrong expression passed to '-m'」で止まる.空文字は `-m ""` と書く. + +### VS Code から接続する(任意)(VS Code Remote) + +サーバのファイルを見たり,ログを追ったりするだけなら VS Code の Remote - SSH 拡張でも接続できる. + +1. 拡張 `ms-vscode-remote.remote-ssh` を入れる(`code --install-extension ms-vscode-remote.remote-ssh`) +2. `F1` →「Remote-SSH: Connect to Host...」→ `Newton` を選ぶ(`~/.ssh/config` の Host 名がそのまま出る) +3. フォルダ `~/lab/SmTIAS-LightSim` を開く + +- **サーバ上では編集しない**.`sm-remote push` は手元の作業ツリーで上書き展開するので,サーバで直接直した内容は次の push で消える.編集は常に手元で行う +- サーバの端末で `sm-remote` を実行すると,自分自身へ ssh しようとするので止まって案内が出る.サーバ上で直接回すときは `tmux new -s <名前>` の中で `uv run ...` を実行する + +### 学外から接続する(研究室 PC 経由)(Off-campus Access) + +Newton は研究室 LAN 内のアドレスなので,学外からは直接つながらない.研究室の自分の PC(`DESKTOP-1QLOO91`.サーバとは別)が Tailscale に登録されているので,これを SSH の踏み台(ProxyJump)にする.Newton 側の設定は不要(手元の鍵がそのまま Newton まで届く). + +```text +手元の PC ──Tailscale──▶ 研究室 PC(100.89.10.108)──研究室 LAN──▶ Newton +``` + +研究室 PC(リモートデスクトップで操作.**実行前に `hostname` で `DESKTOP-1QLOO91` と出ることを確かめる**)の管理者 PowerShell で,OpenSSH Server を入れて Tailscale からの接続だけを受け付ける: + +```powershell +Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0 +Start-Service sshd +Set-Service -Name sshd -StartupType Automatic +Set-NetFirewallRule -Name OpenSSH-Server-In-TCP -RemoteAddress 100.64.0.0/10 +``` + +手元の公開鍵(`~/.ssh/id_ed25519.pub` の中身)を研究室 PC に登録する.研究室 PC のアカウント(`papin`)は管理者なので,Windows の OpenSSH の規則により鍵はユーザーの `.ssh` ではなく `C:\ProgramData\ssh\administrators_authorized_keys` に置き,権限を Administrators と SYSTEM だけに絞る(絞らないと sshd がファイルを無視する): + +```powershell +$key = '<手元の id_ed25519.pub の中身>' +Add-Content -Path C:\ProgramData\ssh\administrators_authorized_keys -Value $key +icacls C:\ProgramData\ssh\administrators_authorized_keys /inheritance:r /grant "Administrators:F" /grant "SYSTEM:F" +``` + +手元の `~/.ssh/config` に追記する: + +```text +Host labpc + HostName 100.89.10.108 + User papin + +Host Newton-via-lab + HostName Newton.local + User hasegawa + ProxyJump labpc +``` + +`ssh labpc hostname` と `ssh Newton-via-lab hostname` がパスワード無しで通れば OK(2026-09-13 に確認.Newton まで 2 秒).`sm-remote` は接続先を変えるだけで同じように使える: + +```powershell +$env:SM_REMOTE_HOST = "Newton-via-lab" # この PowerShell の間だけ有効.毎回 --host Newton-via-lab を付けてもよい +uv run sm-remote status +``` + +- 研究室にいるときは従来どおり `Newton` を使う(踏み台を経由しない分速い) +- 手で `ssh Newton-via-lab 'cat ...' > ファイル` のように出力を受け取るとき,応答が返らず止まることがあった(`hostname` のような短いコマンドは通る.原因は未確認).`ssh -n`(標準入力を閉じる)を付けると通った.`sm-remote` は標準入力を閉じて ssh を呼ぶので影響しない +- Tailscale が直接つながらず中継サーバ(DERP)経由になっていると転送が遅い(`tailscale ping desktop-1qloo91` で `via DERP` と出る).学外からの push は `--only src` のように変えた部分だけにする +- 失敗パターン: 研究室 PC と手元 PC の管理者 PowerShell はプロンプトが同じ(`PS C:\Windows\system32>`)ため,鍵の登録を手元 PC で実行してしまい,研究室 PC で `Permission denied (publickey,...)` になった.sshd のログ(`Get-WinEvent -LogName OpenSSH/Operational`)には理由が出ず `Connection reset ... [preauth]` だけだった +- 研究室 PC で SSH サーバを動かしてよいかは研究室のルールに照らして確認する(上の設定で受け付けるのは Tailscale の中=自分のアカウントの機器からだけ) + +### 既知事項 (Known Issues) + +- Einstein はホームディレクトリ `/home/hasegawa` が無い.ログイン時に「Could not chdir to home directory」「mkdir: cannot create directory '/home/hasegawa': Permission denied」が出て,鍵登録・VS Code Remote ができない.管理者によるホーム作成待ち(Newton では sudo グループだが,Einstein の権限は未確認) +- Archimedes は鍵が未登録.使う場合は「鍵の生成と登録」の手順で登録する +- GPU は他ユーザーが 1・2 番を使っていることがある(研究室の利用ルールは特に無い).`sm-remote status` で空きを見てから `--gpu` で指定する +- LLVM: Ubuntu では `/usr/lib/x86_64-linux-gnu/libLLVM-15.so`(`libLLVM-<版>.so` 形式)で提供され,`libLLVM-C.so` は無い.`runtime/llvm_init.py` はこの形式と `/usr/lib/llvm-*/lib/` も探索する(以前は見つけられず「libLLVM-C.so が見つかりません」と誤警告していた.Dr.Jit 自身は見つけるので `llvm_ad_rgb` は元から動いていた) +- 共用サーバの CPU: `llvm_ad_rgb` は既定で全 64 スレッドを使う(loadavg が 33 から 87 に上がるのを実測).CPU で回すときは `sm-remote run --threads N`(環境変数 `SM_DRJIT_THREADS`)で上限を付ける(`--threads 16` で CPU 使用率 1698% を実測) +- サーバの `uv run` は毎回 `build_system.requires = ["uv-build>=0.11.7,<0.12.0"]` does not contain the current uv version 0.12.13 という警告を出す.ビルドバックエンド(uv_build)の版とサーバの uv 本体の版の食い違いを知らせるだけで,動作には影響しない diff --git "a/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" "b/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" index 5746689..e3ba27a 100644 --- "a/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" +++ "b/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" @@ -14,9 +14,10 @@ | コマンド | 役割 | 実装 | | --- | --- | --- | -| `uv run sm-render ` | シーン YAML をレンダリングし PNG / luminance.npy を出力 | `cli/render.py` | +| `uv run sm-render ` | シーン YAML をレンダリングし PNG / luminance.npy を出力(`--variant` で Mitsuba variant を上書き可) | `cli/render.py` | | `uv run sm-evaluate <入力> ` | PNG / luminance.npy を評価し metrics.csv + figures/ を出力 | `cli/evaluate.py` | | `uv run sm-visualize ` | シーンを 3D 表示 / スクリーンショット出力 | `cli/visualize.py` | +| `uv run sm-remote <サブコマンド>` | 研究室サーバでの計算(コード送信・tmux でのジョブ起動・状態確認・結果取得).下記「研究室サーバでの実行」参照 | `cli/remote.py` | 各コマンドの詳細オプションは `--help` で確認する(例: `uv run sm-visualize --help`). @@ -54,6 +55,13 @@ > 必ず通し,過去レンダとの比較が続く実験中ならアンカー再レンダで等価性を確認すること. > なお `pytest.exe` ランチャも同時にブロックされたため,テスト実行は `uv run python -m pytest` を使う. +> ⚠ 2026-09-13,開発機の Smart App Control が**評価モードからオンに切り替わり**,mitsuba 3.7.1 の +> `mitsuba_ext.cp312-win_amd64.pyd` までブロックされて import 不能になった(処理中に出た通知のボタン操作が契機とみられる. +> CodeIntegrity イベント 3118 に `PreviousEnablementState = 2` が記録されている).SAC にはファイル単位の例外が無く, +> 評価モードにも戻せず,同じファイルの再インストール(ハードリンク・コピーとも)でも直らなかったため, +> **ユーザー判断で SAC をオフにして解消した**(2026-08-30 の「SAC 無効化は不採用」の判断を変更). +> 上記の vtk / mitsuba の固定は再現性のため据え置く.SAC が有効な別の Windows 機で環境を作る場合は同じブロックが起こりうる. + ## Lint / フォーマット (Lint & Format) 規約の詳細は `.claude/rules/coding-style.md` を参照.ruff を唯一の lint / format ツールとする. @@ -64,6 +72,60 @@ uv run ruff format . # フォーマット適用 ``` +## 研究室サーバでの実行 (Lab Server) + +研究室の GPU サーバ(既定 `Newton`)で計算するときは `sm-remote` を使う.コマンドはすべて手元(PowerShell / Git Bash)で実行する.初回の環境構築は `docs/02_ENV/ENV_02_環境構築手順.md` の「研究室サーバ編」を参照. + +```powershell +# コードを送る(Newton:~/lab/SmTIAS-LightSim へ上書き展開) +uv run sm-remote push +uv run sm-remote push --include configs/_tmp_offaxis # 除外対象の _tmp も一緒に送る +uv run sm-remote push --only configs/_tmp_offaxis # _tmp だけ送る +uv run sm-remote push --prune # 送信後,手元で消した src/ tests/ scripts/ のファイルをサーバからも消す +uv run sm-remote push --dry-run # 送る一覧と合計サイズだけ表示する + +# ジョブを起動する(tmux で継続実行.-- の後ろが uv run に渡るコマンド) +uv run sm-remote status # 実行中ジョブ・最近のログ・GPU の空き・負荷 +uv run sm-remote run --name offaxis --gpu 0 -- python scripts/sweep_offaxis_led.py +uv run sm-remote run --name r100 --gpu 0 -- sm-render --scene configs/scene_sphere_r100.yaml --output output/r100 +uv run sm-remote run --name cpu --yaml-variant --threads 16 -- sm-render --scene configs/scene_baseline.yaml --output output/cpu + +# 様子を見る・止める +uv run sm-remote logs offaxis # ログ末尾(-n 行数) +uv run sm-remote logs offaxis -f # 追従(Ctrl+C で抜ける.ジョブは継続) +uv run sm-remote attach offaxis # tmux に接続(Ctrl+B → D で離脱.PowerShell から使う) +uv run sm-remote kill offaxis # ジョブを止める + +# 結果を持ち帰る(サーバの output/ → 手元の同じ場所.同名ファイルは上書き) +uv run sm-remote pull offaxis_led_sweep +uv run sm-remote pull _remote_logs # ジョブログ一式 +``` + +- `run` はサーバのプロジェクトルートで `~/.local/bin/uv run ` を tmux セッション(名前 = `--name`.既定は起動時刻)でデタッチ起動する.接続が切れても継続し,出力は `output/_remote_logs/.log` に開始行・終了コード行付きで追記される.同名のジョブが実行中ならエラーになる +- `run` は既定で `SM_RENDER_VARIANT=cuda_ad_rgb` を渡す(sm-render が YAML の variant より優先して使う.GPU は手元の CPU の 37〜72 倍速く,Newton の `llvm_ad_rgb` と結果が一致することを 2026-09-13 に確認済み).YAML の variant のまま回すときは `--yaml-variant` +- レンダ結果は,同じコード・同じ YAML・同じ seed でも**実行ごとに画素単位で変わる**(GPU・CPU とも.2026-09-13 に Newton で確認).同じシーンオブジェクトを 2 回レンダした差は丸め誤差(並列加算の順序)だけで平均も一致するが,シーンを読み込み直すと約 97% の画素が MC ノイズ程度に変わる(平均の差 3〜7e-4).コーネルボックスでは読み込み直しても丸め誤差だけなので,このシーン固有の現象.読み込み時に並列で作られる加速構造かサンプリング用の表の丸め差が,最大 16 回の反射で経路の分岐として増幅されていると推定している(どちらかは未切り分け).sm-render は毎回シーンを読み込むので,1 回ごとに別のノイズ実現と考える.集計指標の条件比較には問題ないが,画素単位で過去のレンダと突き合わせる用途には向かない +- CPU(`llvm_*` / `scalar_*`)で回すときは `--threads N` で Dr.Jit のスレッド数に上限を付ける(共用サーバの 64 スレッドを占有しないため) +- GPU で回すときの CPU 負荷は小さい(球 10 条件のスイープで CPU は平均 156%・最大 342%=64 スレッド中 1.5〜3.4 スレッド分.2026-09-13 実測).スイープ全体の時間の大半は CPU 側の形状生成・シーン読み込み・評価で GPU 使用率は平均 3% なので,1 本のスイープに GPU は 1 枚(`--gpu`)で足りる +- GPU は `status` で空きを見てから `--gpu` で指定する(未指定だと CUDA は 0 番を使う).その他の環境変数は `--env KEY=VALUE` で渡せる +- サーバで pytest を回すときは `--yaml-variant` を付ける(variant の上書きをテストに持ち込まないため): `uv run sm-remote run --name pytest-all --yaml-variant -- python -m pytest -m ""` +- 各サブコマンドの詳細は `uv run sm-remote <サブコマンド> --help`.接続先は `--host` / `--remote-dir` で変えられる + +**注意**: + +- **ジョブの実行中に push しない**.tar の上書き展開なので,実行中のジョブが途中から新しいコードを読み込み,結果が混ざるおそれがある(push 時にサーバで tmux セッションが動いていれば警告が出る) +- push は上書き展開だけで,**手元で削除・改名したファイルはサーバに残る**.src/ tests/ scripts/ は `--prune` で掃除できる(output/・configs/ はサーバ側の生成物があるので対象外) +- pull は手元の同名ファイルを上書きする.ジョブ実行中に pull すると途中の状態を持ち帰る(tar が「読み取り中に変化した」と警告する) +- 2026-09-13 以前に生成した `configs/_tmp_*` の YAML は Windows の絶対パスを含み,サーバでは読めない(push 時に警告が出る).生成スクリプトを `--prepare-only` で再実行すると YAML からの相対パスで作り直せる.スイープスクリプト自体をサーバで実行する場合は YAML がサーバ上で作り直されるので問題ない +- Windows で YAML とメッシュが別のドライブにあると相対パスにできないため,生成スクリプトは警告ログを出して絶対パスを書き込む(手元ではそのまま動く).その YAML は push 時に Windows 絶対パスとして警告されるので,サーバへ持ち出すときはメッシュを同じドライブ(通常は `configs/meshes/`)に置いて作り直す +- pull はサーバから受け取った tar を検査し,要求したパスの外に出る名前(`..`・`.`・空の要素・バックスラッシュ・ドライブ文字)とリンク(シンボリック・ハード)を含むと展開を中止する.中止までに展開したファイルは残る.通常の出力で起きることはなく,起きたらサーバ側の `output/` を確認する +- Git Bash では `~` や `/` で始まる引数が手元で展開・変換される.`--remote-dir '~/lab/X'` のように引用符で囲む(`/` 始まりの絶対パスをそのまま渡したいときは `MSYS_NO_PATHCONV=1` を付ける) +- `sm-remote.exe` が Smart App Control にブロックされた場合は `uv run python -m sm_tias_lightsim.cli.remote <サブコマンド> ...` で同じことができる +- `sm-remote` はサーバ上(VS Code Remote の端末など)で実行すると,自分自身へ ssh しようとするので止まって案内を出す(このマシンのホスト名が接続先と一致するか,リポジトリの場所が接続先のプロジェクトルートと一致したとき).サーバ上で直接回すときは tmux の中で `uv run ...` を実行する.判定が誤りなら環境変数 `SM_REMOTE_ALLOW_SELF=1` で無効にできる + ## 環境変数 (Environment Variables) -- `DRJIT_LIBLLVM_PATH` — Mitsuba の LLVM JIT に必要(未設定時は `runtime/llvm_init.py` が標準パスを自動探索).詳細は `docs/02_ENV/ENV_02_環境構築手順.md` を参照. +- `DRJIT_LIBLLVM_PATH` — Mitsuba の LLVM JIT に必要(未設定時は `runtime/llvm_init.py` が標準パスを自動探索.Ubuntu の `libLLVM-<版>.so` 形式も探す).詳細は `docs/02_ENV/ENV_02_環境構築手順.md` を参照. +- `SM_RENDER_VARIANT` — sm-render が使う Mitsuba variant を YAML の `render.variant` より優先して上書きする(`sm-render --variant` がさらに優先).スイープスクリプトは sm-render をサブプロセスで呼ぶので,環境変数にしておけば配下のレンダすべてに効く.`sm-remote run` が既定で `cuda_ad_rgb` を設定する. +- `SM_DRJIT_THREADS` — Dr.Jit の CPU スレッド数の上限(未設定なら全論理コア).`render/runner.py`・`visualize/baker.py` が起動時に `dr.set_thread_count` で適用する.`sm-remote run --threads` で設定する.1 以上の整数以外(`0`・負数・整数でない文字列)はエラーになる(sm-render は終了コード 1.黙って全コアで回すことはしない). +- `SM_REMOTE_HOST` / `SM_REMOTE_DIR` / `SM_REMOTE_UV` / `SM_REMOTE_SSH` / `SM_REMOTE_VARIANT` — `sm-remote` の既定値の上書き.それぞれ接続先ホスト(既定 `Newton`)・サーバ上のプロジェクトルート(既定 `~/lab/SmTIAS-LightSim`)・サーバ上の uv(既定 `~/.local/bin/uv`)・手元の ssh(Windows の既定は `C:\Windows\System32\OpenSSH\ssh.exe`)・`run` が渡す variant(既定 `cuda_ad_rgb`). +- `SM_REMOTE_ALLOW_SELF` — `1` にすると,`sm-remote` の「サーバ上で実行されている」検出を無効にする(誤検出したときの逃げ道). diff --git "a/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" "b/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" index a2a551d..46f2071 100644 --- "a/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" +++ "b/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" @@ -62,12 +62,18 @@ │ ├── builder.py # YAML → Mitsuba scene dict 変換の本体 │ ├── lights.py # LED 光源 (point / area / spot) のシーン要素生成 │ ├── materials.py # BSDF(拡散・誘電体・薄膜散乱等)の生成 -│ └── sensors.py # カメラセンサ (perspective / orthographic) の生成 +│ ├── sensors.py # カメラセンサ (perspective / orthographic) の生成 +│ └── paths.py # YAML からの相対メッシュパス(スイープ生成スクリプト用) ├── render/ │ ├── runner.py # mi.set_variant + mi.render の実行 │ └── output.py # HDR film → PNG / luminance.npy 出力 ├── runtime/ -│ └── llvm_init.py # LLVM JIT 初期化(DRJIT_LIBLLVM_PATH 自動探索・variant 経路ログ) +│ ├── llvm_init.py # LLVM JIT 初期化(DRJIT_LIBLLVM_PATH 自動探索・variant 経路ログ) +│ └── overrides.py # 環境変数による実行時上書き(SM_RENDER_VARIANT / SM_DRJIT_THREADS) +├── remote/ +│ ├── commands.py # サーバで実行するシェルコマンドの組み立て(純関数) +│ ├── files.py # push の送信ファイル選定・除外規則・prune 対象の算出 +│ └── transfer.py # tar over ssh の送受信(pull 展開の安全検査) ├── uniformity.py # SmTIAS-Evaluation からコピー(Rec.709, CoV, max/min) ├── evaluation/ │ ├── roi.py # 画像 ROI 抽出(image_pixel 型) @@ -85,7 +91,8 @@ └── cli/ ├── render.py # `sm-render` エントリポイント ├── evaluate.py # `sm-evaluate` エントリポイント - └── visualize.py # `sm-visualize` エントリポイント + ├── visualize.py # `sm-visualize` エントリポイント + └── remote.py # `sm-remote` エントリポイント(研究室サーバでの実行) ``` ## 各層の責務 (Module Responsibilities) @@ -100,7 +107,12 @@ | `geometry/subdivide` | 三角形メッシュ → 最大辺長以下に均一分割した三角形メッシュ | 決定論的(同一入力→同一出力).ベイクの空間解像度を均一化 | | `render/runner` | Mitsuba scene + パラメータ → 輝度配列 | spp / max_depth / variant 切替 | | `render/output` | 輝度配列 → PNG / luminance.npy ファイル | tone mapping 等の選択肢を持つ | -| `runtime/llvm_init` | LLVM JIT 環境初期化 | `DRJIT_LIBLLVM_PATH` 自動探索 + variant 経路の INFO/WARNING ログ | +| `runtime/llvm_init` | LLVM JIT 環境初期化 | `DRJIT_LIBLLVM_PATH` 自動探索(Windows / Ubuntu・Debian の `libLLVM-<版>.so` / Fedora 形式)+ variant 経路の INFO/WARNING ログ | +| `runtime/overrides` | 環境変数による実行時上書きの解決と適用 | `SM_RENDER_VARIANT`(variant.`sm-render --variant` が優先)/ `SM_DRJIT_THREADS`(Dr.Jit スレッド数上限.不正値は ValueError) | +| `scene/paths` | メッシュの絶対パス → YAML からの相対パス(POSIX 区切り) | 手元とサーバで同じ YAML を読めるようにする.別ドライブで相対化できないときは警告して絶対パス | +| `remote/commands` | 設定 → サーバで実行するシェルコマンド文字列 | 純関数.クォート・ジョブ名/GPU の検証・終了コード 90〜93 | +| `remote/files` | 手元の作業ツリー → 送信ファイル一覧 / prune 対象 | 除外規則.output/ は送らず,prune は src/ tests/ scripts/ のみ | +| `remote/transfer` | ファイル一覧 ⇄ tar ストリーム | pull 展開は要求パス外・不正な名前・リンクを拒否(パストラバーサル対策) | | `evaluation/roi` | 画像 + ROI 定義 → ROI 内の輝度配列 | image_pixel 型(scene_face は将来実装) | | `uniformity` | 輝度配列 → 均一性指標 dict | SmTIAS-Evaluation 仕様準拠 | | `evaluation/zones` | 輝度配列 → 中心 / 中間 / 周辺の各統計 | 距離正規化を内部で行う | @@ -114,6 +126,7 @@ | `visualize/projection` | orthographic センサ + 評価板メッシュ → 頂点 / テクスチャ投影 | 純関数.画像 ↔ 物理の向き整合 | | `visualize/viewer` | シーン + 照度 → PyVista インタラクティブ 3D ウィンドウ | メッシュ着色 / 光源マーカー / センサ視線 / スライド表示スタイル | | `cli/render`, `cli/evaluate`, `cli/visualize` | 各ステップの CLI ラッパー | 引数パース・ファイル I/O | +| `cli/remote` | 研究室サーバでの実行(push / run / status / logs / attach / kill / pull) | ssh の起動・サーバ上での実行検出.使い方は ENV_04「研究室サーバでの実行」 | ## エントリポイント (CLI Entry Points) @@ -124,6 +137,7 @@ sm-render = "sm_tias_lightsim.cli.render:main" sm-evaluate = "sm_tias_lightsim.cli.evaluate:main" sm-visualize = "sm_tias_lightsim.cli.visualize:main" +sm-remote = "sm_tias_lightsim.cli.remote:main" ``` 使い方: @@ -141,8 +155,15 @@ # 3D 照度ビューア(キャリブレーション中の調査ツール) uv run sm-visualize --scene configs/scene_baseline.yaml # インタラクティブウィンドウを開く uv run sm-visualize --scene configs/scene_baseline.yaml --screenshot vis.png # PNG 保存のみ + +# 研究室サーバ(既定 Newton)で回す.手元から送って tmux で実行し,結果を持ち帰る +uv run sm-remote push +uv run sm-remote run --name baseline --gpu 0 -- sm-render --scene configs/scene_baseline.yaml --output output/baseline/ +uv run sm-remote pull baseline ``` +`sm-render --variant cuda_ad_rgb`(または環境変数 `SM_RENDER_VARIANT`)で YAML の `render.variant` を上書きできる.`sm-remote run` は既定で `cuda_ad_rgb` を渡す.詳細は ENV_04「研究室サーバでの実行」を参照. + `sm-visualize` は上記の他に,ベイク方式(`--bake-mode direct|path`)・平滑化(`--bake-smooth` / `--bake-smooth-radius` / `--bake-denoise`)・三角形分割(`--bake-subdivide`)・キャッシュ(`--bake-cache`)・発表スライド向け表示(`--style slide` / `--cmap`)・マーカー非表示(`--no-markers` 等)など多数のオプションを持つ.全フラグは `uv run sm-visualize --help` を参照. ## ディレクトリ構成(プロジェクトルート) diff --git "a/docs/06_TEST/TEST_15_\343\203\220\343\203\203\343\203\225\343\203\253\345\260\216\345\205\245\343\201\250\347\220\203\345\215\212\345\276\204\343\202\271\343\202\244\343\203\274\343\203\227.md" "b/docs/06_TEST/TEST_15_\343\203\220\343\203\203\343\203\225\343\203\253\345\260\216\345\205\245\343\201\250\347\220\203\345\215\212\345\276\204\343\202\271\343\202\244\343\203\274\343\203\227.md" index 95f7c49..ff4ca26 100644 --- "a/docs/06_TEST/TEST_15_\343\203\220\343\203\203\343\203\225\343\203\253\345\260\216\345\205\245\343\201\250\347\220\203\345\215\212\345\276\204\343\202\271\343\202\244\343\203\274\343\203\227.md" +++ "b/docs/06_TEST/TEST_15_\343\203\220\343\203\203\343\203\225\343\203\253\345\260\216\345\205\245\343\201\250\347\220\203\345\215\212\345\276\204\343\202\271\343\202\244\343\203\274\343\203\227.md" @@ -330,6 +330,10 @@ uv run python scripts/sweep_sphere_radius.py --variant board_baffle --cavity similar_g2 --radii 100 170 250 uv run python scripts/sweep_sphere_radius.py --variant board_baffle --cavity similar_radial +# 研究室サーバ(GPU)で回す場合(手元から.結果は --dest で別の場所に持ち帰ると本文の出力を上書きしない) +uv run sm-remote run --name sphere-radial --gpu 0 -- python scripts/sweep_sphere_radius.py --cavity similar_radial +uv run sm-remote pull sphere_radius_sweep/board_baffle_similar_radial --dest output/_newton_recheck + # 可視化(--cavity ごとに出力先が分かれる) uv run python scripts/build_radius_sweep_visual.py --cavity similar_radial uv run python scripts/build_radial_comparison.py --cavity similar_radial @@ -341,6 +345,24 @@ `board_baffle_similar_radial/`=10 条件).設計比較図は `cavity_comparison.png`, 接合の断面 before-after は `cavity_design_comparison.png` と `rim_tangent_comparison.png`. +## 再検証 — 研究室サーバでの回し直し (Re-verification on Lab Server) + +2026-09-13,`similar_radial` の 10 条件を現在のコード(mitsuba 3.7.1・`cuda_ad_rgb`)で研究室サーバ(Newton)で回し直し,本文の値(2026-08-31.mitsuba 3.8.0・手元の `llvm_ad_rgb`)と突き合わせた. + +| 指標 | 本文の値との差(10 条件の範囲) | +| --- | --- | +| 輪帯 min/max | −0.29〜+0.18% | +| 中心/周辺比 | −0.02〜+0.03% | +| 勾配 | −0.38〜+0.28% | +| ROI 平均 | +0.07〜+0.12%(全条件で同じ向き) | +| 画素 CoV | +2.0〜+4.2%(全条件で同じ向き) | + +- **本文の結論(形の指標による比較)は変わらない**.形の指標は ±0.4% 以内で一致する +- 平均と画素 CoV の系統的なずれは **mitsuba のバージョン差**(本文は 3.8.0,現在は 3.7.1 に固定)による.形状(頂点が完全一致)と YAML(メッシュパス以外一致)は同じで,TEST_16 の手元アンカー(立方体 L=120 を同じ Windows で 3.8.0 と 3.7.1)でも平均 +0.16%・CoV +1.8% と同じ向きにずれる.画素 CoV はもともと MC ノイズ支配で結論に使っていない(結果 2) +- 過去のレンダと画素 CoV・絶対輝度を突き合わせるときは mitsuba のバージョンを揃えること +- 所要: 10 条件のスイープ全体で 5 分 49 秒(本文の手元実行は 2 時間 13 分) +- 回し直しの出力は手元の `output/_newton_recheck/` に持ち帰った(本文の `output/sphere_radius_sweep/` は上書きしていない) + ## 参照 (References) - [TEST_11_球体筐体実験](TEST_11_球体筐体実験.md): 球ロフト筐体の起点と一次照射キャップの特定 diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 020e3b0..243bb0f 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -93,3 +93,8 @@ - **TEST_17 オフ軸 LED 配置の影響とサイズ依存 — 頑健性は球が勝ち軸上の優劣と逆転 (2026-09-01)**: ユーザー発案「光源が軸からずれた場合の影響と,大きいほど影響が薄まるかの検証」.LED を**深さ方向軸の中点の高さ・+X 側の壁面**に置き板中心へ向ける構成(球 similar_radial 5 半径 + 立方体 5 サイズ・材質/レンダ据え置き)を新設した.円板は壁に対し約 41〜44° 傾くため縁が壁を貫かない分だけ浮かせ,バッフルは軸対称でないので**板 41×41 × LED 円板の全直線がバッフル平面を横切る位置から数値サイジング**(+遮蔽レイキャスト 10 条件 100%.`verify_baffle_occlusion` の LED サンプルを円板の傾きに追従するよう一般化).立方体 L=120 のみバッフルが壁に 2.35mm 食い込む(板の手前縁への視線が壁とほぼ平行なため円板が壁まで届く必要がある=実物積分球の「壁付きバッフル板」と同じ解釈で殻厚以内を許容).**結果 ①(仮説成立)**: 非対称(m=1 符号付き a1・重心オフセット)はサイズとともに減衰し最大サイズで最小の約 4 割(球 -4.0→-1.6% / 立方体 -7.2→-2.3%.ノイズ対照 b1 ≤0.3% の 10〜40 倍で確実に実構造).ただしゼロにはならない.**結果 ②**: a1 は全条件で負=**明るいのは LED の反対側**.バッフル遮蔽下では一次照射キャップ(TEST_11)が反対側の壁にでき,板がそれを非対称に見込むため.**結果 ③(軸上と逆転)**: オフ軸頑健性は**球 > 立方体**.位置ずれの Δ構造CoV は球 ±0.006 以内=実質ゼロ,立方体 +0.010〜+0.033(L=160 で軸上の 2.5 倍).球の「壁上ランバート光源→球面直接照度が一様」という古典的性質の名残.**設計上の含意**: 「立方体 + 軸上配置」か「球 + 配置自由度」の二択構造.**未解決**: 非対称ピークが最小サイズでなく 2 番目(球 r=100 / 立方体 L=160)に来る非単調性の原因,ずれ量の感度曲線(軸上→壁面の中間位置).成果物: `scripts/sweep_offaxis_led.py`・`build_offaxis_comparison.py`,`output/offaxis_led_sweep/`(10 条件 + summary.csv + `offaxis_comparison.png`),記録 [TEST_17](06_TEST/TEST_17_オフ軸LED配置の影響とサイズ依存.md). - **発表スライド素材の作成と TEST_16 の 10 条件化 (2026-09-01)**: TEST_15〜16 の発表準備として,スライド用のクリーン図一式を `output/_slide_panels/` に生成するスクリプトを追加した(`build_slide_test15_geometry.py`=構成断面(注釈付き/文字なし・透過)・半径入れ子断面(直径 φ 表記)・接合比較,`build_slide_test15_results.py`=輪帯ピーク正規化の輝度マップ 5/10 条件版・輪帯プロファイル(英語軸ラベル),`build_slide_test16_geometry.py`=立方体版の構成断面・サイズ入れ子).あわせてユーザー要望「球と同数の 10 条件でまとまりを良くする」を受け,**立方体スイープに L=220 を追加**(内面積 291,000mm²=球 φ270 と φ340 の中間.watertight・遮蔽 55,473 本 100% を事前検証.構造 CoV 0.0147・輪帯 min/max 0.944・対球比 0.73・m=4 は −0.004%=すべて既存カーブに単調に整合).`sweep_cube_size.py` の既定グリッドを 10 条件に更新し,TEST_16・TEST_02 の表と記述を 10 条件に改訂.表記の知見: 直径は図面では「φ176」(単位なしが JIS 流),スライド・文書では「φ176 mm」も一般的=どちらでも可で一貫性が重要. + +- **研究室サーバ(Newton)で計算する仕組みを整備 — GPU で手元の 37〜72 倍 (2026-09-13)**: 手元 Windows の CPU レンダ(1 条件 5〜10 分)を研究室の GPU サーバで回せるようにした.初回セットアップ(SSH 鍵認証 → uv でユーザー領域に Python 3.12 → 転送 → `uv sync`)を ENV_02「研究室サーバ編」に,日常の操作(送信・tmux でのジョブ起動・状態確認・ログ・結果の持ち帰り)を `sm-remote` CLI として実装し ENV_04 にまとめた.使えるのは Newton のみ(Einstein はホームディレクトリが無く鍵登録不可,Archimedes は鍵未登録).**結果**: 代表 2 シーン(spp=1024)で cuda_ad_rgb は手元 llvm の 37〜72 倍(立方体 L240 285 s→7 s,球 r135 558 s→8 s).Newton の llvm と平均の相対差 1e-5・CoV 差 5e-5 以下で一致したため,サーバでは cuda を既定にした.**設計判断**: ① GitHub 認証をサーバに置かない(ユーザー判断: 計算して結果を受け取るだけ)→ 手元の作業ツリーをそのまま送るので未コミットの実験設定も回せる / ② Windows に rsync が無いため Python の tarfile で送受信(PowerShell と Git Bash で同じ挙動)/ ③ 同じ YAML のまま手元は llvm・サーバは cuda で回せるよう variant を環境変数で上書きし,共用サーバの 64 スレッドを占有しないよう Dr.Jit のスレッド数上限を追加(llvm で loadavg 33→87 を実測).**失敗パターン・発見**: ① スイープ生成スクリプトが `_tmp` YAML に Windows の絶対パスを書いておりサーバで読めない → YAML からの相対パスに変更 / ② `llvm_init` が Ubuntu の `libLLVM-15.so` 形式を探せず誤警告 → 探索対象に追加 / ③ Phase 2 のテストで pull 展開のパストラバーサル対策の穴(`..`・バックスラッシュ・ハードリンク経由で `src/` を上書きできる)が見つかり,`/consult`(advisor: Fable)の判断で名前の構造検査とリンクの一律拒否に強化 / ④ 作業中に Smart App Control が評価モードからオンに切り替わり(処理中の通知のボタン操作が契機とみられる),mitsuba 3.7.1 の `mitsuba_ext` まで import 不能になった.ファイル単位の例外が無く再インストールでも直らないため,ユーザー判断で SAC をオフにした(08-30 の「SAC 無効化は不採用」を変更.経緯は ENV_04).**解決(同日)**: 球 r135 だけ手元の過去結果と CoV が 0.0864 vs 0.0835 と食い違った件は,TEST_15 の similar_radial 10 条件を Newton で回し直して切り分けた.形状(頂点 21 万個が完全一致.STL のハッシュ差はヘッダだけ)と YAML(メッシュパス以外一致)は同じで,差は全条件で同じ向き(平均 +0.1%・画素 CoV +2〜4%).TEST_16 の手元アンカー(立方体 L=120 を同じ Windows で 3.8.0 と 3.7.1)でも平均 +0.16%・CoV +1.8% と同じずれ方をするため,**原因は mitsuba 3.8.0(8/31 の記録)→ 3.7.1(9/1 固定)のバージョン差**.形の指標(輪帯 min/max・C/P・勾配)は ±0.4% 以内で一致し,TEST_15 の結論は不変.過去レンダと画素 CoV を突き合わせるときは mitsuba のバージョンを揃えること. + - **学外からの接続**: Tailscale に登録済みの研究室 PC(サーバとは別)に OpenSSH Server を入れて Tailscale からのみ受け付け,SSH の踏み台(ProxyJump)にして Newton に入れるようにした(Newton 側は無変更.大学 VPN やサブネットルーターより影響範囲が自分の PC に閉じる).失敗パターン: 研究室 PC は管理者アカウントのため鍵を `C:\ProgramData\ssh\administrators_authorized_keys` に置く必要があるうえ,両 PC の管理者 PowerShell のプロンプトが同一で鍵を手元 PC に登録してしまった → `hostname` で確かめてから実行する(手順は ENV_02「学外から接続する」). + - **レンダは画素単位では再現しない(GPU・CPU とも)**: 最終確認で基準シーンを同条件で繰り返すと,約 97% の画素が MC ノイズ程度に異なった(平均の差 3〜7e-4・CoV の差 1e-5).切り分け実験: ① 同じシーンオブジェクトを 2 回レンダ → 丸め誤差だけ(並列加算の順序.平均は一致)/ ② シーンを読み込み直す → ノイズ程度の差 / ③ コーネルボックスは読み込み直しても丸め誤差だけ.=このシーン固有で,読み込み時に並列で作られる加速構造かサンプリング用の表の丸め差が,最大 16 回の反射で経路の分岐として増幅されると推定(どちらかは未切り分け).過去の「seed 固定で決定論的」という前提は厳密には成り立たないが,集計指標の比較には影響しない.裏返せば,同じ条件の繰り返しで未実施の**ノイズフロア**を測れる. + - **サーバでのスイープ実測**: 球 10 条件のスイープ全体が 5 分 49 秒(手元 2 時間 13 分).レンダ+評価だけなら 1 条件約 10 秒で 79 倍.GPU 1 枚(`--gpu 0`)で CPU は平均 156%・最大 342%(64 スレッド中)と共用サーバへの負荷は小さく,GPU 使用率の平均は 3%=時間の大半は CPU 側の形状生成・シーン読み込み・評価(GPU を増やしても速くならない).研究室 PC 経由の pull は 31.8 MB を 16 秒. diff --git a/pyproject.toml b/pyproject.toml index d5f639c..a70a702 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ sm-render = "sm_tias_lightsim.cli.render:main" sm-evaluate = "sm_tias_lightsim.cli.evaluate:main" sm-visualize = "sm_tias_lightsim.cli.visualize:main" +sm-remote = "sm_tias_lightsim.cli.remote:main" [build-system] requires = ["uv_build>=0.11.7,<0.12.0"] diff --git a/scripts/sanity_led_groups.py b/scripts/sanity_led_groups.py index 8d6534c..918aded 100644 --- a/scripts/sanity_led_groups.py +++ b/scripts/sanity_led_groups.py @@ -38,6 +38,8 @@ import yaml +from sm_tias_lightsim.scene.paths import relative_mesh_path + # --------------------------------------------------------------------------- # 定数 # --------------------------------------------------------------------------- @@ -65,12 +67,13 @@ with TEMPLATE_YAML.open(encoding="utf-8") as f: data = yaml.safe_load(f) - # geometry.meshes の STL 相対パスを絶対化(_tmp_sanity からも解決できるように) + # geometry.meshes の STL 相対パスを一時 YAML(TMP_DIR)からの相対パスに付け替える + # (_tmp_sanity から解決でき,かつ Windows で生成した YAML を研究室サーバでも読めるように) template_base = TEMPLATE_YAML.parent for item in data.get("geometry", {}).get("meshes", []): rel_path = item.get("path") if rel_path and not Path(rel_path).is_absolute(): - item["path"] = str((template_base / rel_path).resolve()) + item["path"] = relative_mesh_path(template_base / rel_path, TMP_DIR) # lights を指定グループのみに絞り込む prefix = f"led_{group}_" diff --git a/scripts/sweep_blend_bsdf.py b/scripts/sweep_blend_bsdf.py index 33e076f..e395e9d 100644 --- a/scripts/sweep_blend_bsdf.py +++ b/scripts/sweep_blend_bsdf.py @@ -35,6 +35,8 @@ import yaml +from sm_tias_lightsim.scene.paths import relative_mesh_path + # --------------------------------------------------------------------------- # 定数 # --------------------------------------------------------------------------- @@ -121,12 +123,13 @@ with TEMPLATE_YAML.open(encoding="utf-8") as f: data = yaml.safe_load(f) - # geometry.meshes の STL 相対パスを絶対化(一時 YAML の配置場所が変わっても解決可能にする) + # geometry.meshes の STL 相対パスを一時 YAML(TMP_DIR)からの相対パスに付け替える + # (絶対パスだと Windows で生成した YAML を研究室サーバで読めない) template_base = TEMPLATE_YAML.parent for item in data.get("geometry", {}).get("meshes", []): rel_path = item.get("path") if rel_path and not Path(rel_path).is_absolute(): - item["path"] = str((template_base / rel_path).resolve()) + item["path"] = relative_mesh_path(template_base / rel_path, TMP_DIR) # printer_white の blendbsdf パラメータを書き換える printer_white = data["materials"]["printer_white"] diff --git a/scripts/sweep_bsdf_grid_fix.py b/scripts/sweep_bsdf_grid_fix.py index 10d2049..f14467b 100644 --- a/scripts/sweep_bsdf_grid_fix.py +++ b/scripts/sweep_bsdf_grid_fix.py @@ -48,6 +48,7 @@ from sm_tias_lightsim.evaluation.roi_presets import SIM_ROI, extract_roi_xywh # noqa: E402 from sm_tias_lightsim.evaluation.shape_metrics import calc_ncc # noqa: E402 +from sm_tias_lightsim.scene.paths import relative_mesh_path # noqa: E402 ROOT = Path(__file__).resolve().parents[1] @@ -124,9 +125,10 @@ config["materials"]["printer_white"] = material config["name"] = f"bsdf_grid_{job_name(rho, omega, alpha)}" # メッシュの相対パスは YAML 自身のディレクトリ基準で解決されるため, - # テンプレート(configs/ 直下)から別ディレクトリへ移す時点で絶対パス化する. + # テンプレート(configs/ 直下)から別ディレクトリへ移す時点で,書き出し先 YAML からの + # 相対パスに付け替える(絶対パスだと Windows で生成した YAML を研究室サーバで読めない). for mesh in config["geometry"]["meshes"]: - mesh["path"] = str((TEMPLATE_YAML.parent / mesh["path"]).resolve()) + mesh["path"] = relative_mesh_path(TEMPLATE_YAML.parent / mesh["path"], dest.parent) dest.parent.mkdir(parents=True, exist_ok=True) dest.write_text(yaml.safe_dump(config, allow_unicode=True, sort_keys=False), encoding="utf-8") diff --git a/scripts/sweep_cube_size.py b/scripts/sweep_cube_size.py index f066fee..a061c79 100644 --- a/scripts/sweep_cube_size.py +++ b/scripts/sweep_cube_size.py @@ -63,6 +63,8 @@ read_metrics_csv, ) +from sm_tias_lightsim.scene.paths import relative_mesh_path # noqa: E402 + ROOT = Path(__file__).resolve().parents[1] TMP_DIR = ROOT / "configs" / "_tmp_cube_size" @@ -169,7 +171,9 @@ 材質・センサ・レンダ設定・LED の半径/radiance は一切変更しない.変えるのは 筐体メッシュ(球 → 立方体)・LED の Z・LED の向き・バッフルだけ. - メッシュの相対パスは YAML 自身のディレクトリ基準で解決されるため絶対パス化する. + メッシュの相対パスは YAML 自身のディレクトリ基準で解決されるため,書き出し先の + YAML からの相対パスに付け替える(絶対パスだと Windows で生成した YAML を + 研究室サーバで読めない). """ config = copy.deepcopy(yaml.safe_load(TEMPLATE_YAML.read_text(encoding="utf-8"))) config["name"] = f"cube_size_{RUN_DIR}_{job_name(size)}" @@ -178,16 +182,17 @@ f"(天井 z={RIM_Z:g}・底面 z={RIM_Z - size:g})." f"LED は底面中心に密着し評価板向き(+Z).バッフルで直接光を遮断." ) + yaml_dir = dest.parent for mesh in config["geometry"]["meshes"]: if mesh["id"] == "sphere_housing": mesh["id"] = "cube_housing" - mesh["path"] = str(mesh_path.resolve()) + mesh["path"] = relative_mesh_path(mesh_path, yaml_dir) else: - mesh["path"] = str((TEMPLATE_YAML.parent / mesh["path"]).resolve()) + mesh["path"] = relative_mesh_path(TEMPLATE_YAML.parent / mesh["path"], yaml_dir) config["geometry"]["meshes"].append( { "id": "baffle", - "path": str(baffle_path.resolve()), + "path": relative_mesh_path(baffle_path, yaml_dir), "unit": "mm", "material": "printer_white", } diff --git a/scripts/sweep_led_distribution.py b/scripts/sweep_led_distribution.py index e22bc4e..1b80dfb 100644 --- a/scripts/sweep_led_distribution.py +++ b/scripts/sweep_led_distribution.py @@ -40,6 +40,8 @@ import yaml +from sm_tias_lightsim.scene.paths import relative_mesh_path + ROOT = Path(__file__).resolve().parents[1] TEMPLATE_YAML = ROOT / "configs" / "scene_baseline_spot.yaml" EVAL_CONFIG_YAML = ROOT / "configs" / "eval_baseline_dng_target_shape.yaml" @@ -76,12 +78,13 @@ with TEMPLATE_YAML.open(encoding="utf-8") as f: data = yaml.safe_load(f) - # geometry.meshes の STL 相対パスを絶対化 + # geometry.meshes の STL 相対パスを一時 YAML(TMP_DIR)からの相対パスに付け替える + # (絶対パスだと Windows で生成した YAML を研究室サーバで読めない) template_base = TEMPLATE_YAML.parent for item in data.get("geometry", {}).get("meshes", []): rel_path = item.get("path") if rel_path and not Path(rel_path).is_absolute(): - item["path"] = str((template_base / rel_path).resolve()) + item["path"] = relative_mesh_path(template_base / rel_path, TMP_DIR) # 全 LED の cutoff_angle と beam_width を書き換え (beam = cutoff/3 で相似) beam_width_deg = cutoff_deg / 3.0 diff --git a/scripts/sweep_max_depth.py b/scripts/sweep_max_depth.py index 802b50f..b7106ae 100644 --- a/scripts/sweep_max_depth.py +++ b/scripts/sweep_max_depth.py @@ -37,6 +37,8 @@ import yaml +from sm_tias_lightsim.scene.paths import relative_mesh_path + # --------------------------------------------------------------------------- # 定数 # --------------------------------------------------------------------------- @@ -64,12 +66,13 @@ with TEMPLATE_YAML.open(encoding="utf-8") as f: data = yaml.safe_load(f) - # geometry.meshes の STL 相対パスを絶対化 + # geometry.meshes の STL 相対パスを一時 YAML(TMP_DIR)からの相対パスに付け替える + # (絶対パスだと Windows で生成した YAML を研究室サーバで読めない) template_base = TEMPLATE_YAML.parent for item in data.get("geometry", {}).get("meshes", []): rel_path = item.get("path") if rel_path and not Path(rel_path).is_absolute(): - item["path"] = str((template_base / rel_path).resolve()) + item["path"] = relative_mesh_path(template_base / rel_path, TMP_DIR) # render.max_depth を書き換え data["render"]["max_depth"] = depth diff --git a/scripts/sweep_offaxis_led.py b/scripts/sweep_offaxis_led.py index 567d999..463e862 100644 --- a/scripts/sweep_offaxis_led.py +++ b/scripts/sweep_offaxis_led.py @@ -64,6 +64,8 @@ center_z as sphere_center_z, ) +from sm_tias_lightsim.scene.paths import relative_mesh_path # noqa: E402 + ROOT = Path(__file__).resolve().parents[1] TMP_DIR = ROOT / "configs" / "_tmp_offaxis" @@ -267,16 +269,19 @@ f"評価板中心 (0,0,{BOARD_SURFACE_Z:g}) へ向ける(傾き {led['tilt_deg']:.1f}°)." f"バッフルで直接光を遮断." ) + # メッシュは書き出し先 YAML からの相対パスで書く(絶対パスだと Windows で生成した + # YAML を研究室サーバで読めない) + yaml_dir = dest.parent for mesh in config["geometry"]["meshes"]: if mesh["id"] == "sphere_housing": if shape == "cube": mesh["id"] = "cube_housing" - mesh["path"] = str(mesh_path.resolve()) + mesh["path"] = relative_mesh_path(mesh_path, yaml_dir) else: - mesh["path"] = str((TEMPLATE_YAML.parent / mesh["path"]).resolve()) + mesh["path"] = relative_mesh_path(TEMPLATE_YAML.parent / mesh["path"], yaml_dir) config["geometry"]["meshes"].append({ "id": "baffle", - "path": str(baffle_path.resolve()), + "path": relative_mesh_path(baffle_path, yaml_dir), "unit": "mm", "material": "printer_white", }) diff --git a/scripts/sweep_reflectance.py b/scripts/sweep_reflectance.py index 6e2c275..e55a8b3 100644 --- a/scripts/sweep_reflectance.py +++ b/scripts/sweep_reflectance.py @@ -30,6 +30,8 @@ import yaml +from sm_tias_lightsim.scene.paths import relative_mesh_path + ROOT = Path(__file__).resolve().parents[1] TEMPLATE_YAML = ROOT / "configs" / "scene_baseline.yaml" EVAL_CONFIG_YAML = ROOT / "configs" / "eval_baseline_dng_target_shape.yaml" @@ -65,12 +67,13 @@ with TEMPLATE_YAML.open(encoding="utf-8") as f: data = yaml.safe_load(f) - # geometry.meshes の STL 相対パスを絶対化 + # geometry.meshes の STL 相対パスを一時 YAML(TMP_DIR)からの相対パスに付け替える + # (絶対パスだと Windows で生成した YAML を研究室サーバで読めない) template_base = TEMPLATE_YAML.parent for item in data.get("geometry", {}).get("meshes", []): rel_path = item.get("path") if rel_path and not Path(rel_path).is_absolute(): - item["path"] = str((template_base / rel_path).resolve()) + item["path"] = relative_mesh_path(template_base / rel_path, TMP_DIR) # materials.printer_white.reflectance を書き換え data["materials"]["printer_white"]["reflectance"] = [reflectance, reflectance, reflectance] diff --git a/scripts/sweep_sphere_radius.py b/scripts/sweep_sphere_radius.py index 949ea03..c027031 100644 --- a/scripts/sweep_sphere_radius.py +++ b/scripts/sweep_sphere_radius.py @@ -62,6 +62,7 @@ from generate_sphere_loft_housing import build_loft_housing # noqa: E402 from sm_tias_lightsim.evaluation.roi_presets import SIM_ROI, extract_roi_xywh # noqa: E402 +from sm_tias_lightsim.scene.paths import relative_mesh_path # noqa: E402 ROOT = Path(__file__).resolve().parents[1] @@ -238,10 +239,13 @@ 材質・センサ・レンダ設定・LED の半径/radiance は一切変更しない.変えるのは 筐体メッシュ・LED の Z・LED の向き(構成による)・バッフルの有無だけ. - メッシュの相対パスは YAML 自身のディレクトリ基準で解決されるため絶対パス化する. + メッシュの相対パスは YAML 自身のディレクトリ基準で解決されるため,書き出し先の + YAML からの相対パスに付け替える(絶対パスだと Windows で生成した YAML を + 研究室サーバで読めない). """ config = copy.deepcopy(yaml.safe_load(TEMPLATE_YAML.read_text(encoding="utf-8"))) config["name"] = f"sphere_radius_{variant}_{cavity}_{job_name(radius)}" + yaml_dir = dest.parent if variant == "board_baffle": aim = "壁面に密着し評価板向き(+Z).バッフルで直接光を遮断" else: @@ -254,13 +258,13 @@ ) for mesh in config["geometry"]["meshes"]: if mesh["id"] == "sphere_housing": - mesh["path"] = str(mesh_path.resolve()) + mesh["path"] = relative_mesh_path(mesh_path, yaml_dir) else: - mesh["path"] = str((TEMPLATE_YAML.parent / mesh["path"]).resolve()) + mesh["path"] = relative_mesh_path(TEMPLATE_YAML.parent / mesh["path"], yaml_dir) if baffle_path is not None: config["geometry"]["meshes"].append({ "id": "baffle", - "path": str(baffle_path.resolve()), + "path": relative_mesh_path(baffle_path, yaml_dir), "unit": "mm", "material": "printer_white", }) diff --git a/src/sm_tias_lightsim/cli/remote.py b/src/sm_tias_lightsim/cli/remote.py new file mode 100644 index 0000000..b52d8b4 --- /dev/null +++ b/src/sm_tias_lightsim/cli/remote.py @@ -0,0 +1,1008 @@ +"""sm-remote コマンドラインエントリポイント(研究室サーバでの計算支援). + +手元(Windows の PowerShell / Git Bash)から 1 コマンドで以下を行う. + +- push : コードを研究室サーバへ送る(tar over ssh.rsync 不要) +- run : サーバの tmux セッションでコマンドを起動する(接続が切れても継続) +- status : 実行中ジョブ・最近のジョブログ・GPU・負荷を表示する +- logs : ジョブログを表示する(-f で追従) +- attach : ジョブの tmux セッションに接続する +- kill : ジョブ(tmux セッション)を終了する +- pull : output/ のサブディレクトリを持ち帰る + +接続先は --host / --remote-dir,または環境変数 SM_REMOTE_HOST / SM_REMOTE_DIR で上書きできる. +""" + +from __future__ import annotations + +import argparse +import codecs +import contextlib +import os +import socket +import subprocess +import sys +import tarfile +import time +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +from sm_tias_lightsim.remote.commands import ( + EXIT_NO_SESSION, + EXIT_NOT_FOUND, + EXIT_REMOTE_DIR_MISSING, + EXIT_SESSION_EXISTS, + REMOTE_LOG_DIR, + build_attach_check_command, + build_attach_command, + build_job_script, + build_kill_command, + build_list_files_command, + build_logs_command, + build_pull_command, + build_push_command, + build_remove_files_command, + build_run_command, + build_ssh_argv, + build_status_command, + normalize_output_subpath, + parse_env_assignment, + validate_gpu_spec, + validate_job_name, +) +from sm_tias_lightsim.remote.files import ( + EXCLUDED_DIR_NAMES, + PRUNE_DIRS, + PushEntry, + collect_push_entries, + compute_prune_targets, + find_project_root, + find_yaml_with_windows_paths, +) +from sm_tias_lightsim.remote.transfer import extract_tar_stream, write_tar_stream +from sm_tias_lightsim.runtime.overrides import THREADS_ENV_VAR, VARIANT_ENV_VAR + +DEFAULT_HOST = "Newton" +DEFAULT_REMOTE_DIR = "~/lab/SmTIAS-LightSim" +# 非ログインの `ssh host 'cmd'` では ~/.profile が読まれず uv が PATH に入らないため絶対パスで呼ぶ +DEFAULT_UV = "~/.local/bin/uv" +# サーバでは GPU が手元の CPU(llvm)より 37〜72 倍速く,結果も一致する(2026-09-13 Newton で実測) +DEFAULT_VARIANT = "cuda_ad_rgb" + +ENV_HOST = "SM_REMOTE_HOST" +ENV_REMOTE_DIR = "SM_REMOTE_DIR" +ENV_UV = "SM_REMOTE_UV" +ENV_SSH = "SM_REMOTE_SSH" +ENV_VARIANT = "SM_REMOTE_VARIANT" +# サーバ上での実行検出(detect_running_on_server)を無効にする逃げ道 +ENV_ALLOW_SELF = "SM_REMOTE_ALLOW_SELF" + +_MAX_LISTED_FILES = 50 +_PROGRESS_INTERVAL_S = 0.5 + +_EPILOG = """\ +使用例: + uv run sm-remote push # コードを送る + uv run sm-remote push --include configs/_tmp_offaxis # 除外対象の _tmp も一緒に送る + uv run sm-remote push --only configs/_tmp_offaxis # _tmp だけ送る + uv run sm-remote run --name offaxis --gpu 0 -- python scripts/sweep_offaxis_led.py + uv run sm-remote run --name r100 --gpu 0 -- sm-render --scene configs/x.yaml --output output/x + uv run sm-remote status + uv run sm-remote logs offaxis -f + uv run sm-remote pull offaxis_led_sweep # output/offaxis_led_sweep を持ち帰る +""" + + +@dataclass(frozen=True) +class RemoteSettings: + """接続先の設定. + + Attributes: + host: SSH ホスト名(~/.ssh/config の Host). + remote_dir: サーバ上のプロジェクトルート. + uv_path: サーバ上の uv 実行ファイル. + ssh: 手元の ssh 実行ファイル. + """ + + host: str + remote_dir: str + uv_path: str + ssh: str + + +def default_ssh_executable(environ: Mapping[str, str], os_name: str = os.name) -> str: + """既定の ssh 実行ファイルを返す. + + Windows では System32 の OpenSSH を優先する.Git Bash から起動すると PATH 上の + Git 同梱 ssh(MSYS 版)が先に見つかり,PowerShell からの起動と挙動が揃わないため. + + Args: + environ: 参照する環境変数. + os_name: `os.name` 相当(テスト用に差し替え可能). + + Returns: + ssh 実行ファイルのパスまたはコマンド名. + """ + if os_name == "nt": + system_root = environ.get("SystemRoot") or environ.get("SYSTEMROOT") or r"C:\Windows" + candidate = Path(system_root) / "System32" / "OpenSSH" / "ssh.exe" + if candidate.is_file(): + return str(candidate) + return "ssh" + + +def resolve_settings( + host: str | None, + remote_dir: str | None, + uv_path: str | None, + environ: Mapping[str, str], +) -> RemoteSettings: + """引数 > 環境変数 > 既定値 の順で接続先の設定を決める. + + Args: + host: --host の値(未指定なら None). + remote_dir: --remote-dir の値(未指定なら None). + uv_path: --uv の値(未指定なら None). + environ: 参照する環境変数. + + Returns: + 解決済みの設定. + """ + + def pick(arg: str | None, key: str, default: str) -> str: + if arg and arg.strip(): + return arg.strip() + return environ.get(key, "").strip() or default + + return RemoteSettings( + host=pick(host, ENV_HOST, DEFAULT_HOST), + remote_dir=pick(remote_dir, ENV_REMOTE_DIR, DEFAULT_REMOTE_DIR), + uv_path=pick(uv_path, ENV_UV, DEFAULT_UV), + ssh=pick(None, ENV_SSH, default_ssh_executable(environ)), + ) + + +def _short_hostname(name: str) -> str: + """ホスト名を比較用に正規化する(小文字化してドメイン部を除く.IPv4 はそのまま).""" + name = name.strip().lower() + if name.replace(".", "").isdigit(): + return name + return name.split(".", 1)[0] + + +def _expand_remote_dir(remote_dir: str, home: Path) -> Path | None: + """remote_dir を「このマシンがサーバだったら」の前提で展開する(~user 形式は None).""" + if remote_dir == "~": + return home + if remote_dir.startswith("~/"): + return home / remote_dir[2:] + if remote_dir.startswith("~"): + return None + path = Path(remote_dir) + # ssh 越しの相対パスはホーム基準で解釈される + return path if path.is_absolute() else home / path + + +def detect_running_on_server( + host: str, + remote_dir: str, + *, + environ: Mapping[str, str], + local_hostname: str, + project_root: Path | None, + home: Path | None, + os_name: str = os.name, +) -> str | None: + """sm-remote が接続先サーバ自身の上で実行されていないかを判定する. + + サーバ上(VS Code Remote の端末等)で実行すると ssh で自分自身へつなぎに行き, + ホスト鍵の確認プロンプト等で混乱するため,ssh を起動する前に止める + (2026-09-13 に Newton で発生).次のどちらかで「サーバ上」とみなす. + + - このマシンのホスト名が接続先と一致する(大文字小文字とドメイン部を無視するので, + ssh config の Host 名 `Newton` と実ホスト名 `newton` / `newton.local` のどれでも一致する) + - POSIX で,いまいるリポジトリのルートが接続先のプロジェクトルート(~ 展開後)と一致する + (Host 名が実ホスト名と異なるエイリアスの場合の保険) + + Windows ではプロジェクトルートの比較をしない(手元の PC を誤検出しないため). + + Args: + host: 接続先ホスト(--host / SM_REMOTE_HOST / 既定). + remote_dir: 接続先のプロジェクトルート. + environ: 参照する環境変数(SM_REMOTE_ALLOW_SELF=1 なら判定しない). + local_hostname: このマシンのホスト名. + project_root: いまいるリポジトリのルート(リポジトリ外なら None). + home: このマシンのホームディレクトリ(不明なら None). + os_name: `os.name` 相当(テスト用に差し替え可能). + + Returns: + サーバ上と判定した理由(日本語).サーバ上でなければ None. + """ + if environ.get(ENV_ALLOW_SELF, "").strip().lower() in ("1", "true", "yes"): + return None + if local_hostname and _short_hostname(local_hostname) == _short_hostname(host): + return f"このマシンのホスト名 {local_hostname} が接続先 {host} と一致" + if os_name == "nt" or project_root is None or home is None: + return None + remote_path = _expand_remote_dir(remote_dir, home) + if remote_path is not None and remote_path.resolve() == project_root.resolve(): + return f"このリポジトリ {project_root} が接続先のプロジェクトルート {remote_dir} と一致" + return None + + +def _detect_on_this_machine(settings: RemoteSettings) -> str | None: + """このマシンの情報を集めて detect_running_on_server を呼ぶ.""" + try: + project_root: Path | None = find_project_root(Path.cwd()) + except ValueError: + project_root = None + try: + home: Path | None = Path.home() + except RuntimeError: + home = None + return detect_running_on_server( + settings.host, + settings.remote_dir, + environ=os.environ, + local_hostname=socket.gethostname(), + project_root=project_root, + home=home, + ) + + +def build_job_env( + *, + gpu: str | None, + variant: str | None, + threads: int | None, + extra: Sequence[str], +) -> dict[str, str]: + """ジョブに渡す環境変数を組み立てる. + + Args: + gpu: CUDA_VISIBLE_DEVICES に渡す GPU 番号(None なら設定しない). + variant: SM_RENDER_VARIANT に渡す variant(None なら YAML のまま). + threads: SM_DRJIT_THREADS に渡すスレッド数(None なら制限しない). + extra: 追加の `KEY=VALUE`(後勝ち). + + Returns: + 環境変数の辞書(挿入順=ログに表示する順). + + Raises: + ValueError: GPU 指定・スレッド数・KEY=VALUE が不正な場合. + """ + # パイプ越しでも Python の print がすぐログに出るようにする(既定はブロックバッファ) + env = {"PYTHONUNBUFFERED": "1"} + if gpu is not None: + env["CUDA_VISIBLE_DEVICES"] = validate_gpu_spec(gpu) + if variant: + env[VARIANT_ENV_VAR] = variant + if threads is not None: + if threads < 1: + raise ValueError(f"--threads は 1 以上を指定してください(指定 {threads}).") + env[THREADS_ENV_VAR] = str(threads) + for item in extra: + key, value = parse_env_assignment(item) + env[key] = value + return env + + +def _default_job_name() -> str: + """既定のジョブ名(起動時刻)を返す.""" + return datetime.now().strftime("job-%Y%m%d-%H%M%S") + + +def _format_size(num_bytes: float) -> str: + """バイト数を読みやすい単位で返す.""" + for unit in ("B", "KB", "MB", "GB"): + if num_bytes < 1024 or unit == "GB": + return f"{num_bytes:.0f} {unit}" if unit == "B" else f"{num_bytes:.1f} {unit}" + num_bytes /= 1024 + return f"{num_bytes:.1f} GB" + + +def _write_stdout(text: str) -> None: + """標準出力へ書く(出力先の文字コードで表せない文字は置換する). + + リモートの出力は UTF-8 で届くが,リダイレクト時の Windows の標準出力は cp932 のことがある. + """ + try: + sys.stdout.write(text) + except UnicodeEncodeError: + encoding = sys.stdout.encoding or "utf-8" + sys.stdout.write(text.encode(encoding, errors="replace").decode(encoding, errors="replace")) + sys.stdout.flush() + + +class _ProgressPrinter: + """転送量を標準エラーの 1 行に上書き表示する(端末でないときは何もしない).""" + + def __init__(self, label: str) -> None: + self._label = label + self._enabled = sys.stderr.isatty() + self._last = 0.0 + self._printed = False + + def __call__(self, done: int, total: int) -> None: + if not self._enabled: + return + now = time.monotonic() + if now - self._last < _PROGRESS_INTERVAL_S and done != total: + return + self._last = now + if total > 0: + text = f"{self._label} {_format_size(done)} / {_format_size(total)}" + text += f" ({100.0 * done / total:.0f}%)" + else: + text = f"{self._label} {_format_size(done)}" + sys.stderr.write("\r" + text.ljust(60)) + sys.stderr.flush() + self._printed = True + + def finish(self) -> None: + if self._printed: + sys.stderr.write("\n") + sys.stderr.flush() + self._printed = False + + +def _ssh_argv(settings: RemoteSettings, command: str, *, tty: bool = False) -> list[str]: + """接続先の設定で,リモートコマンドを実行する ssh の引数リストを返す.""" + return build_ssh_argv(settings.ssh, settings.host, command, tty=tty) + + +def _host_option(settings: RemoteSettings) -> str: + """案内に表示する sm-remote コマンドへ付ける ` --host ...`(既定のホストなら空)を返す.""" + return "" if settings.host == DEFAULT_HOST else f" --host {settings.host}" + + +def _popen(argv: list[str], **kwargs: object) -> subprocess.Popen[bytes]: + """ssh を起動する(見つからなければ対処を添えて ValueError にする).""" + try: + return subprocess.Popen(argv, **kwargs) # noqa: S603 + except FileNotFoundError as exc: + msg = f"ssh が見つかりません: {argv[0]}(環境変数 {ENV_SSH} で実行ファイルを指定できます)" + raise ValueError(msg) from exc + + +def _run_ssh( + argv: list[str], *, capture: bool = False, input_bytes: bytes | None = None +) -> tuple[int, bytes]: + """ssh を実行して終了を待つ. + + 標準入力は input_bytes が無ければ /dev/null 相当にする(手元の入力を ssh が + 吸い込まないように). + + Returns: + (終了コード, 標準出力.capture=False なら空). + """ + stdin = subprocess.PIPE if input_bytes is not None else subprocess.DEVNULL + proc = _popen(argv, stdin=stdin, stdout=subprocess.PIPE if capture else None) + out, _ = proc.communicate(input=input_bytes) + return proc.returncode, out or b"" + + +def _report_common_failure(rc: int, settings: RemoteSettings) -> None: + """ssh・リモートスクリプトに共通の失敗理由を日本語で補足する.""" + if rc == EXIT_REMOTE_DIR_MISSING: + print( + f"エラー: サーバに {settings.remote_dir} がありません." + "先に sm-remote push してください.", + file=sys.stderr, + ) + elif rc == 255: + print( + f"エラー: {settings.host} へ ssh 接続できませんでした" + "(~/.ssh/config・鍵・ネットワークを確認).", + file=sys.stderr, + ) + elif rc != 0: + print(f"エラー: リモートコマンドが失敗しました(終了コード {rc}).", file=sys.stderr) + + +def _print_list(title: str, items: Sequence[str]) -> None: + """一覧を先頭 _MAX_LISTED_FILES 件まで表示する.""" + print(title) + for item in items[:_MAX_LISTED_FILES]: + print(f" {item}") + if len(items) > _MAX_LISTED_FILES: + print(f" ...ほか {len(items) - _MAX_LISTED_FILES} 件") + + +# --------------------------------------------------------------------------- +# push +# --------------------------------------------------------------------------- + + +def _stream_push(argv: list[str], entries: Sequence[PushEntry], *, compress: bool) -> int: + """tar ストリームを ssh の標準入力へ流し,ssh の終了コードを返す.""" + proc = _popen(argv, stdin=subprocess.PIPE) + progress = _ProgressPrinter("送信中") + try: + write_tar_stream(entries, proc.stdin, compress=compress, progress=progress) + except OSError as exc: + progress.finish() + # ssh が先に終了してパイプが閉じている.後始末の close 失敗は原因ではないので無視する + with contextlib.suppress(OSError): + proc.stdin.close() + rc = proc.wait() + print(f"エラー: 送信が中断されました(ssh 終了コード {rc})— {exc}", file=sys.stderr) + return rc or 1 + progress.finish() + proc.stdin.close() + return proc.wait() + + +def _find_prune_targets(settings: RemoteSettings, local_arcnames: set[str]) -> list[str] | None: + """サーバの src/ tests/ scripts/ にあって手元に無いファイルを求める(失敗時 None).""" + command = build_list_files_command(settings.remote_dir, PRUNE_DIRS, sorted(EXCLUDED_DIR_NAMES)) + rc, out = _run_ssh(_ssh_argv(settings, command), capture=True) + if rc != 0: + _report_common_failure(rc, settings) + return None + # Linux のファイル名は任意のバイト列なので,往復で失われないよう surrogateescape で扱う + remote_files = [p.decode("utf-8", "surrogateescape") for p in out.split(b"\0") if p] + try: + return compute_prune_targets(remote_files, local_arcnames) + except RuntimeError as exc: + # 不変条件違反(誤削除の危険)なので削除せず,他の失敗経路と同じく None で返す + print( + f"エラー: サーバの一覧に想定外のパスが含まれたため削除を中止しました: {exc}", + file=sys.stderr, + ) + return None + + +def _prune_remote(settings: RemoteSettings, local_arcnames: set[str]) -> int: + """サーバの src/ tests/ scripts/ にあって手元に無いファイルを削除し,終了コードを返す.""" + targets = _find_prune_targets(settings, local_arcnames) + if targets is None: + return 1 + if not targets: + print("--prune: 削除対象はありません.") + return 0 + _print_list(f"--prune: サーバから {len(targets)} 件を削除します:", targets) + payload = b"".join(t.encode("utf-8", "surrogateescape") + b"\0" for t in targets) + remove = build_remove_files_command(settings.remote_dir) + rc, _ = _run_ssh(_ssh_argv(settings, remove), input_bytes=payload) + if rc != 0: + _report_common_failure(rc, settings) + return rc + + +def _cmd_push(args: argparse.Namespace, settings: RemoteSettings) -> int: + """push サブコマンド: コードをサーバへ送る.""" + if args.include and args.only: + raise ValueError("--include と --only は同時に指定できません.") + if args.prune and args.only: + raise ValueError( + "--prune は --only と同時に指定できません(削除判定に手元の全体が要るため)." + ) + + root = args.local_dir.resolve() if args.local_dir else find_project_root(Path.cwd()) + entries = collect_push_entries( + root, include_base=not args.only, extra_paths=args.include or args.only + ) + if not entries: + raise ValueError("送信するファイルがありません.") + total = sum(entry.size for entry in entries) + print( + f"送信対象: {len(entries)} ファイル / {_format_size(total)}" + f"({root} → {settings.host}:{settings.remote_dir})" + ) + + windows_yaml = find_yaml_with_windows_paths(entries) + if windows_yaml: + _print_list( + "警告: 次の YAML は Windows の絶対パスを含むためサーバでは読めません" + "(生成スクリプトを --prepare-only で再実行すると YAML 相対パスで作り直せます):", + windows_yaml, + ) + + if args.dry_run: + for entry in entries: + print(f" {_format_size(entry.size):>10} {entry.arcname}") + if args.prune: + targets = _find_prune_targets(settings, {e.arcname for e in entries}) + if targets is None: + return 1 + _print_list(f"--prune で削除されるファイル: {len(targets)} 件", targets) + print("(--dry-run のため送信していません)") + return 0 + + command = build_push_command(settings.remote_dir, compress=args.compress) + start = time.monotonic() + rc = _stream_push(_ssh_argv(settings, command), entries, compress=args.compress) + if rc != 0: + _report_common_failure(rc, settings) + return rc + print(f"送信完了: {time.monotonic() - start:.1f} 秒") + + if args.prune: + return _prune_remote(settings, {e.arcname for e in entries}) + return 0 + + +# --------------------------------------------------------------------------- +# run / status / logs / attach / kill +# --------------------------------------------------------------------------- + + +def _cmd_run(args: argparse.Namespace, settings: RemoteSettings) -> int: + """run サブコマンド: サーバの tmux セッションでジョブを起動する.""" + command = list(args.command) + if command and command[0] == "--": + command = command[1:] + if not command: + raise ValueError( + "実行するコマンドを指定してください(例: sm-remote run -- sm-render --scene ...)." + ) + name = validate_job_name(args.name or _default_job_name()) + if args.yaml_variant: + variant = None + else: + variant = args.variant or os.environ.get(ENV_VARIANT, "").strip() or DEFAULT_VARIANT + env = build_job_env(gpu=args.gpu, variant=variant, threads=args.threads, extra=args.env) + script = build_job_script( + settings.remote_dir, + name, + command, + uv_path=settings.uv_path, + use_uv=not args.no_uv, + env=env, + ) + remote_command = build_run_command(settings.remote_dir, name, script) + if args.dry_run: + print(remote_command) + return 0 + + rc, _ = _run_ssh(_ssh_argv(settings, remote_command)) + if rc == EXIT_SESSION_EXISTS: + print( + f"エラー: 同名のジョブ(tmux セッション)'{name}' が実行中です." + "--name で別名を付けるか,終了を待ってください.", + file=sys.stderr, + ) + return rc + if rc != 0: + _report_common_failure(rc, settings) + return rc + + host_opt = _host_option(settings) + print(f"ジョブを起動しました: {name}({settings.host})") + print(f" 環境変数: {' '.join(f'{k}={v}' for k, v in env.items())}") + print(f" ログ : {settings.remote_dir}/{REMOTE_LOG_DIR}/{name}.log") + print(f" 状態確認: uv run sm-remote status{host_opt}") + print(f" ログ追従: uv run sm-remote logs {name} -f{host_opt}") + return 0 + + +def _cmd_status(args: argparse.Namespace, settings: RemoteSettings) -> int: + """status サブコマンド: 実行中ジョブ・最近のログ・GPU・負荷を表示する.""" + command = build_status_command(settings.remote_dir) + rc, out = _run_ssh(_ssh_argv(settings, command), capture=True) + _write_stdout(out.decode("utf-8", errors="replace")) + if rc != 0: + _report_common_failure(rc, settings) + return rc + + +def _follow(argv: list[str]) -> int: + """ssh の出力を逐次表示し続ける(Ctrl+C で終了). + + mitsuba の進捗バーは改行せず \\r で上書きするため,行単位ではなく受け取った分から表示する. + ssh の標準入力はパイプにして開いたままにし,終了時に閉じてリモートの tail を止める. + """ + proc = _popen(argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE) + decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + interrupted = False + try: + while chunk := proc.stdout.read1(65536): + _write_stdout(decoder.decode(chunk)) + except KeyboardInterrupt: + interrupted = True + print("\n(追従を終了しました.ジョブは継続しています)", file=sys.stderr) + finally: + # stdin を閉じるとリモートの cat が EOF を受けて tail を止める.既に ssh が + # 終了していれば close は失敗しうるが,後始末なので無視する + with contextlib.suppress(OSError): + proc.stdin.close() + rc = proc.wait() + # Ctrl+C は追従の正常な終わらせ方(Windows では ssh も同時に止まり非 0 を返す) + return 0 if interrupted else rc + + +def _cmd_logs(args: argparse.Namespace, settings: RemoteSettings) -> int: + """logs サブコマンド: ジョブログの末尾を表示する(-f で追従).""" + if args.lines < 0: + raise ValueError("--lines は 0 以上を指定してください.") + command = build_logs_command( + settings.remote_dir, args.name, lines=args.lines, follow=args.follow + ) + argv = _ssh_argv(settings, command) + if args.follow: + rc = _follow(argv) + else: + rc, out = _run_ssh(argv, capture=True) + _write_stdout(out.decode("utf-8", errors="replace")) + if rc == EXIT_NOT_FOUND: + print(f"エラー: ジョブ '{args.name}' のログがサーバにありません.", file=sys.stderr) + elif rc != 0: + _report_common_failure(rc, settings) + return rc + + +def _cmd_attach(args: argparse.Namespace, settings: RemoteSettings) -> int: + """attach サブコマンド: ジョブの tmux セッションに接続する. + + ジョブが終わっていると tmux の `no sessions` だけが出て分かりにくいので, + 先に別の ssh(非対話)でセッションの有無を確かめ,無ければログの最終行を添えて案内する. + """ + check = build_attach_check_command(settings.remote_dir, args.name) + rc, out = _run_ssh(_ssh_argv(settings, check), capture=True) + if rc == EXIT_NO_SESSION: + last = out.decode("utf-8", errors="replace").strip() + detail = f"({last})" if last else "" + host_opt = _host_option(settings) + print( + f"ジョブ '{args.name}' は既に終了しています{detail}." + f"結果は `uv run sm-remote logs {args.name}{host_opt}` で確認できます." + ) + return rc + if rc == EXIT_NOT_FOUND: + print( + f"エラー: ジョブ '{args.name}' はありません(tmux セッションもログも見つかりません).", + file=sys.stderr, + ) + return rc + if rc != 0: + _report_common_failure(rc, settings) + return rc + + command = build_attach_command(args.name) + print( + "tmux に接続します.デタッチ(ジョブは継続)は Ctrl+B → D." + "Ctrl+C はジョブ自体を止めるので注意.", + file=sys.stderr, + ) + proc = _popen(_ssh_argv(settings, command, tty=True)) + return proc.wait() + + +def _cmd_kill(args: argparse.Namespace, settings: RemoteSettings) -> int: + """kill サブコマンド: ジョブの tmux セッションを終了する.""" + command = build_kill_command(settings.remote_dir, args.name) + rc, _ = _run_ssh(_ssh_argv(settings, command)) + if rc == EXIT_NO_SESSION: + print(f"エラー: ジョブ '{args.name}' は実行中ではありません.", file=sys.stderr) + elif rc != 0: + _report_common_failure(rc, settings) + else: + print(f"ジョブ '{args.name}' を終了しました.") + return rc + + +# --------------------------------------------------------------------------- +# pull +# --------------------------------------------------------------------------- + + +def _cmd_pull(args: argparse.Namespace, settings: RemoteSettings) -> int: + """pull サブコマンド: output/ のサブディレクトリを持ち帰る.""" + rel_paths = list(dict.fromkeys(normalize_output_subpath(p) for p in args.paths)) + dest = args.dest.resolve() if args.dest else find_project_root(Path.cwd()) + command = build_pull_command(settings.remote_dir, rel_paths, compress=args.compress) + print(f"取得: {settings.host}:{settings.remote_dir}/{{{', '.join(rel_paths)}}} → {dest}") + + proc = _popen( + _ssh_argv(settings, command), + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + ) + progress = _ProgressPrinter("受信中") + start = time.monotonic() + try: + files, num_bytes = extract_tar_stream(proc.stdout, dest, rel_paths, progress=progress) + except (tarfile.TarError, OSError) as exc: + progress.finish() + # ssh が既に終わっていれば(リモート側の失敗)その終了コードで原因を示す + ssh_finished = proc.poll() is not None + if not ssh_finished: + proc.kill() + rc = proc.wait() + if ssh_finished and rc == EXIT_NOT_FOUND: + print( + "エラー: 指定したパスがサーバにありません(上のメッセージ参照).", file=sys.stderr + ) + elif ssh_finished and rc in (EXIT_REMOTE_DIR_MISSING, 255): + _report_common_failure(rc, settings) + else: + print(f"エラー: 受信・展開に失敗しました — {exc}", file=sys.stderr) + return rc if ssh_finished and rc > 0 else 1 + progress.finish() + # 終端ブロック後のパディングを読み切らないと ssh が書き込みで止まり wait が戻らない + while proc.stdout.read(65536): + pass + rc = proc.wait() + if rc == 1: + # GNU tar の終了コード 1 は「読み取り中にファイルが変化した」等の警告(ジョブ実行中など) + print( + "警告: 一部のファイルが読み取り中に変化しました(ジョブが実行中の可能性).", + file=sys.stderr, + ) + elif rc != 0: + _report_common_failure(rc, settings) + return rc + print( + f"取得完了: {files} ファイル / {_format_size(num_bytes)}" + f"({time.monotonic() - start:.1f} 秒)" + ) + return 0 + + +# --------------------------------------------------------------------------- +# 引数 +# --------------------------------------------------------------------------- + + +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """コマンドライン引数をパースして返す. + + Args: + argv: 引数リスト.None のとき sys.argv[1:] を使用. + + Returns: + パース済み Namespace. + """ + parser = argparse.ArgumentParser( + prog="sm-remote", + description="研究室サーバでの計算を支援する(コード送信・ジョブ起動・状態確認・結果取得).", + epilog=_EPILOG, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + common = argparse.ArgumentParser(add_help=False) + common.add_argument( + "--host", + default=None, + metavar="HOST", + help=( + "SSH ホスト名(~/.ssh/config の Host)." + f"既定: 環境変数 {ENV_HOST} または {DEFAULT_HOST}." + ), + ) + common.add_argument( + "--remote-dir", + default=None, + metavar="DIR", + help=( + f"サーバ上のプロジェクトルート.既定: 環境変数 {ENV_REMOTE_DIR} または " + f"{DEFAULT_REMOTE_DIR}(Git Bash では ~ が手元で展開されるので引用符で囲む)." + ), + ) + sub = parser.add_subparsers(dest="subcommand", required=True, metavar="SUBCOMMAND") + + p_push = sub.add_parser( + "push", + parents=[common], + help="コードをサーバへ送る", + description=( + "プロジェクトを tar over ssh でサーバへ送る..venv / output / .git / キャッシュ / " + "configs/_tmp_* は送らない.上書き展開のため,手元で消したファイルはサーバに残る" + "(src/ tests/ scripts/ は --prune で掃除できる)." + ), + ) + p_push.add_argument( + "--include", + action="append", + default=[], + metavar="PATH", + help="除外対象(configs/_tmp_* 等)を追加で送る.複数指定可.", + ) + p_push.add_argument( + "--only", + action="append", + default=[], + metavar="PATH", + help="指定したパスだけを送る(コード本体は送らない).複数指定可.", + ) + p_push.add_argument( + "--prune", + action="store_true", + help="送信後,src/ tests/ scripts/ のうち手元に無いファイルをサーバから削除する.", + ) + p_push.add_argument("--compress", "-z", action="store_true", help="gzip 圧縮して送る.") + p_push.add_argument( + "--dry-run", + action="store_true", + help="送信せず,対象ファイルの一覧と合計サイズだけ表示する.", + ) + p_push.add_argument( + "--local-dir", + type=Path, + default=None, + metavar="DIR", + help="送信元のプロジェクトルート(既定: カレントから上方向に探索).", + ) + + p_run = sub.add_parser( + "run", + parents=[common], + help="サーバの tmux セッションでジョブを起動する", + description=( + "サーバのプロジェクトルートで `uv run ` を tmux セッション(デタッチ)で" + f"起動する.出力は {REMOTE_LOG_DIR}/.log に追記され,接続が切れても継続する." + "コマンドは -- の後に書く." + ), + ) + p_run.add_argument( + "--name", + "-n", + default=None, + metavar="NAME", + help="ジョブ名(tmux セッション名・ログ名).英数字・_・-.既定: job-YYYYmmdd-HHMMSS.", + ) + p_run.add_argument( + "--gpu", + default=None, + metavar="IDS", + help=( + "CUDA_VISIBLE_DEVICES に渡す GPU 番号(例: 0 / 0,2)." + "status で空きを確認してから指定する." + ), + ) + p_run.add_argument( + "--variant", + default=None, + metavar="VARIANT", + help=( + f"{VARIANT_ENV_VAR} としてジョブに渡す Mitsuba variant" + "(sm-render が YAML より優先して使う)." + f"既定: 環境変数 {ENV_VARIANT} または {DEFAULT_VARIANT}." + ), + ) + p_run.add_argument( + "--yaml-variant", + action="store_true", + help="variant を上書きせず YAML の render.variant を使う(pytest を回すとき等).", + ) + p_run.add_argument( + "--threads", + type=int, + default=None, + metavar="N", + help=( + f"{THREADS_ENV_VAR} としてジョブに渡す CPU スレッド数上限" + "(llvm / scalar で共用サーバの全コアを占有しないため)." + ), + ) + p_run.add_argument( + "--env", + action="append", + default=[], + metavar="KEY=VALUE", + help="ジョブに追加で渡す環境変数.複数指定可.", + ) + p_run.add_argument( + "--no-uv", action="store_true", help="uv run を付けずにコマンドをそのまま実行する." + ) + p_run.add_argument( + "--uv", + default=None, + metavar="PATH", + help=f"サーバ上の uv の場所.既定: 環境変数 {ENV_UV} または {DEFAULT_UV}.", + ) + p_run.add_argument( + "--dry-run", action="store_true", help="実行せず,サーバへ送るシェルコマンドを表示する." + ) + p_run.add_argument( + "command", + nargs=argparse.REMAINDER, + metavar="-- COMMAND", + help="サーバで実行するコマンド(uv run の後ろに付く部分).", + ) + + sub.add_parser( + "status", + parents=[common], + help="実行中ジョブ・最近のジョブログ・GPU・負荷を表示する", + ) + + p_logs = sub.add_parser("logs", parents=[common], help="ジョブログを表示する") + p_logs.add_argument("name", metavar="NAME", help="ジョブ名.") + p_logs.add_argument( + "--lines", "-n", type=int, default=40, metavar="N", help="表示する末尾の行数(既定 40)." + ) + p_logs.add_argument( + "--follow", + "-f", + action="store_true", + help="追記を追従表示する(Ctrl+C で終了.ジョブは継続).", + ) + + p_attach = sub.add_parser( + "attach", parents=[common], help="ジョブの tmux セッションに接続する(PowerShell 推奨)" + ) + p_attach.add_argument("name", metavar="NAME", help="ジョブ名.") + + p_kill = sub.add_parser("kill", parents=[common], help="ジョブ(tmux セッション)を終了する") + p_kill.add_argument("name", metavar="NAME", help="ジョブ名.") + + p_pull = sub.add_parser( + "pull", + parents=[common], + help="output/ のサブディレクトリを持ち帰る", + description=( + "サーバの output/ を tar over ssh で受け取り,手元の同じ場所に展開する" + "(同名ファイルは上書き)." + ), + ) + p_pull.add_argument( + "paths", + nargs="+", + metavar="PATH", + help="output/ 配下のパス(例: offaxis_led_sweep / output/_remote_logs).複数指定可.", + ) + p_pull.add_argument( + "--dest", + type=Path, + default=None, + metavar="DIR", + help="展開先(既定: 手元のプロジェクトルート.output/ がその下にできる).", + ) + p_pull.add_argument("--compress", "-z", action="store_true", help="gzip 圧縮して受け取る.") + + return parser.parse_args(argv) + + +_HANDLERS: dict[str, Callable[[argparse.Namespace, RemoteSettings], int]] = { + "push": _cmd_push, + "run": _cmd_run, + "status": _cmd_status, + "logs": _cmd_logs, + "attach": _cmd_attach, + "kill": _cmd_kill, + "pull": _cmd_pull, +} + + +def main(argv: list[str] | None = None) -> int: + """sm-remote コマンドのメインエントリポイント. + + Args: + argv: 引数リスト.None のとき sys.argv[1:] を使用. + + Returns: + 終了コード(成功時 0.ssh・リモートの失敗時はその終了コード,入力エラーは 1). + """ + args = _parse_args(argv) + try: + settings = resolve_settings( + args.host, args.remote_dir, getattr(args, "uv", None), os.environ + ) + on_server = _detect_on_this_machine(settings) + if on_server is not None: + print( + f"エラー: ここは接続先サーバ({settings.host})です({on_server}).\n" + " sm-remote は手元の PC の PowerShell 等から実行してください.\n" + " サーバ上で直接回すなら,tmux の中で uv run ... を実行してください" + f"(例: tmux new -s job → cd {settings.remote_dir} && uv run sm-render ...).\n" + f" 判定が誤りなら環境変数 {ENV_ALLOW_SELF}=1 で無効にできます.", + file=sys.stderr, + ) + return 1 + return _HANDLERS[args.subcommand](args, settings) + except ValueError as exc: + print(f"エラー: {exc}", file=sys.stderr) + return 1 + except KeyboardInterrupt: + print("\n中断しました.", file=sys.stderr) + return 130 + + +if __name__ == "__main__": + # uv の生成するランチャ(sm-remote.exe)が Windows の Smart App Control に + # ブロックされた場合の代替として `uv run python -m sm_tias_lightsim.cli.remote` を + # 使えるようにする + sys.exit(main()) diff --git a/src/sm_tias_lightsim/cli/render.py b/src/sm_tias_lightsim/cli/render.py index c29f659..5512dde 100644 --- a/src/sm_tias_lightsim/cli/render.py +++ b/src/sm_tias_lightsim/cli/render.py @@ -52,6 +52,15 @@ metavar="OUTPUT_DIR", help="出力先ディレクトリ(存在しない場合は親ごと作成する).", ) + parser.add_argument( + "--variant", + default=None, + metavar="VARIANT", + help=( + "Mitsuba variant を上書きする(例: cuda_ad_rgb)." + "未指定なら環境変数 SM_RENDER_VARIANT,それも無ければ YAML の render.variant を使う." + ), + ) return parser.parse_args(argv) @@ -90,6 +99,17 @@ print(f"エラー: シーン YAML の読み込みに失敗しました — {exc}", file=sys.stderr) return 1 + # 同じ YAML を手元(llvm)とサーバ(cuda)で使い回すため,variant だけ実行側から差し替える + # 優先順位: --variant > 環境変数 SM_RENDER_VARIANT > YAML の render.variant + from sm_tias_lightsim.runtime.overrides import resolve_render_variant + + variant = resolve_render_variant(config.render.variant, args.variant) + if variant != config.render.variant: + print(f"variant を上書き: {config.render.variant} → {variant}") + config = config.model_copy( + update={"render": config.render.model_copy(update={"variant": variant})} + ) + try: output_dir.mkdir(parents=True, exist_ok=True) except OSError as exc: diff --git a/src/sm_tias_lightsim/remote/__init__.py b/src/sm_tias_lightsim/remote/__init__.py new file mode 100644 index 0000000..e01dedd --- /dev/null +++ b/src/sm_tias_lightsim/remote/__init__.py @@ -0,0 +1,8 @@ +"""remote パッケージ — 研究室サーバでの計算支援(コード送信・ジョブ起動・結果取得). + +- `commands`: サーバで実行するシェルコマンド文字列を組み立てる純関数 +- `files`: push で送るファイルの選定(除外規則・追加指定・削除対象の算出) +- `transfer`: tar ストリームの書き出し・展開(ssh の標準入出力につなぐ) + +CLI(`sm-remote`)は `cli/remote.py`. +""" diff --git a/src/sm_tias_lightsim/remote/commands.py b/src/sm_tias_lightsim/remote/commands.py new file mode 100644 index 0000000..c607c1e --- /dev/null +++ b/src/sm_tias_lightsim/remote/commands.py @@ -0,0 +1,517 @@ +"""研究室サーバ上で実行するシェルコマンド文字列を組み立てる純関数群. + +`ssh ''` で送る文字列を作る.リモートのログインシェル(Ubuntu では bash)が +解釈する前提で POSIX sh の範囲で書き,ジョブ本体だけは `${PIPESTATUS[0]}` を使うため +`bash -c` で包む.ユーザー由来の値(パス・コマンド引数)はすべて `shlex.quote` でクォートし, +ジョブ名・GPU 指定・環境変数名は書式を検証してから埋め込む. + +Windows から ssh へ渡す引数に改行を含めないため,複数の文は `; ` で 1 行につなぐ. +リモート側が出すメッセージは ASCII に限る(ssh の stderr は Windows コンソールへ素通しされ, +コードページによっては日本語が化けるため.日本語の説明は呼び出し側の CLI が出す). +""" + +from __future__ import annotations + +import re +import shlex +from collections.abc import Mapping, Sequence +from pathlib import PurePosixPath + +REMOTE_OUTPUT_DIR = "output" +REMOTE_LOG_DIR = "output/_remote_logs" + +# リモートスクリプトが返す終了コード.ssh 自身の 255・tar の 1/2 と衝突しない値にする +EXIT_REMOTE_DIR_MISSING = 90 +EXIT_SESSION_EXISTS = 91 +EXIT_NOT_FOUND = 92 +EXIT_NO_SESSION = 93 + +# tmux のセッション名に使えない "." ":" を避け,ログのファイル名にもそのまま使える文字に限る +_JOB_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$") +_GPU_SPEC_RE = re.compile(r"^\d+(,\d+)*$") +_ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:") + +_NOW = "\"$(date '+%Y-%m-%d %H:%M:%S')\"" +_STATUS_LOG_COUNT = 10 + + +def quote_remote_path(path: str) -> str: + """リモートのパスをシェル用にクォートする. + + 先頭の `~` はリモートの `$HOME` に展開させる必要があるため,`~/...` は + `"$HOME"/'...'` の形に変換する(丸ごとクォートするとチルダ展開が起きない). + + Args: + path: リモートのパス(`~/...`・絶対パス・ホーム基準の相対パス). + + Returns: + シェルにそのまま埋め込める文字列. + + Raises: + ValueError: 空文字,または `~ユーザー名/...` 形式の場合. + """ + if not path or not path.strip(): + raise ValueError("リモートパスが空です.") + if path == "~": + return '"$HOME"' + if path.startswith("~/"): + rest = path[2:] + return f'"$HOME"/{shlex.quote(rest)}' if rest else '"$HOME"' + if path.startswith("~"): + msg = ( + f"~ユーザー名 形式のリモートパスには対応していません: {path!r}" + "(~/... か絶対パスを指定)" + ) + raise ValueError(msg) + return shlex.quote(path) + + +def validate_host(host: str) -> str: + """SSH ホスト名を検証して返す(ssh のオプションとして解釈されるのを防ぐ). + + Args: + host: `~/.ssh/config` の Host 名,またはホスト名・IP. + + Returns: + 検証済みのホスト名. + + Raises: + ValueError: 空・`-` 始まり・空白を含む場合. + """ + if not host or host.startswith("-") or any(c.isspace() for c in host): + raise ValueError(f"SSH ホスト名が不正です: {host!r}") + return host + + +def validate_job_name(name: str) -> str: + """ジョブ名(tmux セッション名・ログのファイル名)を検証して返す. + + Args: + name: ジョブ名. + + Returns: + 検証済みのジョブ名. + + Raises: + ValueError: 英数字で始まり英数字・`_`・`-` のみ(64 文字以内)でない場合. + """ + if not _JOB_NAME_RE.match(name): + msg = f"ジョブ名が不正です: {name!r}(英数字で始まり,英数字・_・- のみ,64 文字以内)" + raise ValueError(msg) + return name + + +def validate_gpu_spec(spec: str) -> str: + """CUDA_VISIBLE_DEVICES に渡す GPU 番号の指定を検証して返す. + + Args: + spec: `0` や `0,2` のようなカンマ区切りの GPU 番号. + + Returns: + 検証済みの指定文字列. + + Raises: + ValueError: 書式が不正な場合. + """ + if not _GPU_SPEC_RE.match(spec): + raise ValueError(f"GPU 指定が不正です: {spec!r}(例: 0 / 0,2)") + return spec + + +def parse_env_assignment(text: str) -> tuple[str, str]: + """`KEY=VALUE` 形式の環境変数指定を分解する. + + Args: + text: `KEY=VALUE` 形式の文字列(VALUE は空でもよい). + + Returns: + (KEY, VALUE). + + Raises: + ValueError: `=` が無い,または KEY が環境変数名として不正な場合. + """ + key, sep, value = text.partition("=") + if not sep or not _ENV_KEY_RE.match(key): + raise ValueError(f"環境変数の指定が不正です: {text!r}(KEY=VALUE 形式)") + return key, value + + +def build_ssh_argv(ssh: str, host: str, remote_command: str, *, tty: bool = False) -> list[str]: + """ssh を起動する引数リストを返す. + + Args: + ssh: ssh 実行ファイル. + host: 接続先ホスト. + remote_command: リモートで実行するシェルコマンド(1 引数として渡す). + tty: True なら `-t` で疑似端末を割り当てる(tmux attach 用). + + Returns: + subprocess に渡す引数リスト. + """ + argv = [ssh] + if tty: + argv.append("-t") + argv += [validate_host(host), remote_command] + return argv + + +def _cd_command(remote_dir: str) -> str: + """リモートディレクトリへ移動し,無ければ専用の終了コードで抜ける文を返す.""" + quoted = quote_remote_path(remote_dir) + return ( + f"cd {quoted} || {{ echo 'sm-remote: remote directory not found:' {quoted} >&2; " + f"exit {EXIT_REMOTE_DIR_MISSING}; }}" + ) + + +def _job_log_path(name: str) -> str: + """ジョブログのパス(プロジェクトルート基準.クォート前)を返す.name は検証済みとする.""" + return f"{REMOTE_LOG_DIR}/{name}.log" + + +def _tmux_target(name: str) -> str: + """tmux の `-t` に渡すセッション指定をクォートして返す.name は検証済みとする. + + `=` を前置して完全一致にする(付けないと tmux は前方一致で別のジョブを選びうる). + """ + return shlex.quote(f"={name}") + + +def build_push_command(remote_dir: str, *, compress: bool) -> str: + """標準入力の tar ストリームをリモートディレクトリへ展開するコマンドを返す. + + 展開前に tmux セッションが動いていれば警告を出す(実行中のジョブが途中から + 新しいコードを読み込み,結果が混ざるおそれがあるため). + + Args: + remote_dir: 展開先(リモートのプロジェクトルート). + compress: True なら gzip 圧縮ストリームとして展開する. + + Returns: + リモートで実行するシェルコマンド. + """ + quoted = quote_remote_path(remote_dir) + flags = "-xzf" if compress else "-xf" + warn = ( + "if tmux ls >/dev/null 2>&1; then " + "echo 'sm-remote: warning: tmux sessions are running" + " (running jobs may pick up the new code):' >&2; tmux ls >&2; fi" + ) + return f"{warn}; mkdir -p {quoted} && tar {flags} - -C {quoted}" + + +def build_list_files_command( + remote_dir: str, dirs: Sequence[str], skip_dir_names: Sequence[str] +) -> str: + """リモートの指定ディレクトリ配下のファイル一覧を NUL 区切りで出力するコマンドを返す. + + Args: + remote_dir: リモートのプロジェクトルート. + dirs: 一覧を取るディレクトリ(プロジェクトルート基準). + skip_dir_names: 探索しないディレクトリ名(`__pycache__` 等). + + Returns: + リモートで実行するシェルコマンド. + """ + names = " ".join(shlex.quote(d) for d in dirs) + prune = " -o ".join(f"-name {shlex.quote(n)}" for n in skip_dir_names) + return ( + f"{_cd_command(remote_dir)}; for d in {names}; do " + f'if [ -d "$d" ]; then find "$d" -type d \\( {prune} \\) -prune -o -type f -print0; fi; ' + "done" + ) + + +def build_remove_files_command(remote_dir: str) -> str: + """標準入力の NUL 区切りパス一覧をリモートで削除するコマンドを返す. + + Args: + remote_dir: リモートのプロジェクトルート(パスはこの基準の相対パス). + + Returns: + リモートで実行するシェルコマンド. + """ + return f"{_cd_command(remote_dir)}; xargs -0 rm -f --" + + +def build_job_script( + remote_dir: str, + name: str, + command: Sequence[str], + *, + uv_path: str, + use_uv: bool, + env: Mapping[str, str], +) -> str: + """tmux セッション内で bash が実行するジョブ本体のスクリプトを返す. + + 出力は `output/_remote_logs/.log` に追記し(前回の同名ジョブのログも残す), + 開始行と終了コード行で挟む.標準入力は /dev/null にする(入力待ちで止まらないように). + uv のディレクトリを PATH に足すのは,スイープスクリプトが `uv run sm-render` を + 名前で呼ぶため(非ログインの ssh では ~/.local/bin が PATH に入らない). + + Args: + remote_dir: リモートのプロジェクトルート. + name: ジョブ名. + command: 実行するコマンドと引数. + uv_path: リモートの uv 実行ファイル. + use_uv: True なら `uv run` を前置する. + env: ジョブに渡す環境変数. + + Returns: + bash スクリプト(1 行). + + Raises: + ValueError: コマンドが空,またはジョブ名・環境変数名が不正な場合. + """ + if not command: + raise ValueError("実行するコマンドが空です.") + log = _job_log_path(validate_job_name(name)) + for key in env: + if not _ENV_KEY_RE.match(key): + raise ValueError(f"環境変数名が不正です: {key!r}") + + uv_quoted = quote_remote_path(uv_path) + run_argv = ([uv_quoted, "run"] if use_uv else []) + [shlex.quote(arg) for arg in command] + display = shlex.join( + [f"{k}={v}" for k, v in env.items()] + (["uv", "run"] if use_uv else []) + list(command) + ) + statements = [ + _cd_command(remote_dir), + f"log={shlex.quote(log)}", + f"printf '=== [%s] start on %s: %s\\n' {_NOW} \"$(hostname)\" {shlex.quote(display)}" + ' >> "$log"', + f'export PATH="$(dirname -- {uv_quoted}):$PATH"', + *[f"export {k}={shlex.quote(v)}" for k, v in env.items()], + f'{" ".join(run_argv)} < /dev/null 2>&1 | tee -a "$log"', + "rc=${PIPESTATUS[0]}", + f'printf \'=== [%s] exit %s\\n\' {_NOW} "$rc" >> "$log"', + 'exit "$rc"', + ] + return "; ".join(statements) + + +def build_run_command(remote_dir: str, name: str, job_script: str) -> str: + """ジョブ本体を tmux の新規セッション(デタッチ状態)で起動するコマンドを返す. + + 同名セッションが既にあれば起動せず `EXIT_SESSION_EXISTS` で終わる. + tmux のサーバはデーモン化して ssh の入出力を閉じるため,ssh はすぐ戻り, + 接続が切れてもジョブは継続する. + + Args: + remote_dir: リモートのプロジェクトルート. + name: ジョブ名(tmux セッション名). + job_script: `build_job_script` が返したスクリプト. + + Returns: + リモートで実行するシェルコマンド. + """ + name = validate_job_name(name) + target = _tmux_target(name) + inner = "bash -c " + shlex.quote(job_script) + statements = [ + _cd_command(remote_dir), + f"mkdir -p {REMOTE_LOG_DIR} || exit 1", + f"if tmux has-session -t {target} 2>/dev/null; then " + f"echo 'sm-remote: tmux session already exists:' {shlex.quote(name)} >&2; " + f"exit {EXIT_SESSION_EXISTS}; fi", + f'tmux new-session -d -s {shlex.quote(name)} -c "$PWD" {shlex.quote(inner)}', + ] + return "; ".join(statements) + + +def _last_line_command(path_expr: str) -> str: + """ログの最後の 1 行を表示するパイプラインを返す. + + mitsuba の進捗バーは \\r で上書きするので,最後の \\r 以降だけを 1 行として見せる. + """ + return f"tail -n 1 {path_expr} | tr '\\r' '\\n' | grep -v '^$' | tail -n 1 | cut -c1-100" + + +def build_status_command(remote_dir: str) -> str: + """tmux セッション・最近のジョブログ・GPU・負荷を表示するコマンドを返す. + + Args: + remote_dir: リモートのプロジェクトルート. + + Returns: + リモートで実行するシェルコマンド. + """ + last_line = _last_line_command('"$f"') + log_loop = ( + f"for f in $(ls -t {REMOTE_LOG_DIR}/*.log 2>/dev/null | head -n {_STATUS_LOG_COUNT}); do " + 'n=$(basename "$f" .log); ' + 'if tmux has-session -t "=$n" 2>/dev/null; then s=RUNNING; else s=ended; fi; ' + f"last=$({last_line}); " + 'printf \'%-28s %-8s %s\\n\' "$n" "$s" "$last"; ' + "done" + ) + gpu = ( + "if command -v nvidia-smi >/dev/null 2>&1; then " + "nvidia-smi --query-gpu=index,name,utilization.gpu,memory.used,memory.total" + " --format=csv,noheader; " + "else echo '(nvidia-smi not found)'; fi" + ) + statements = [ + _cd_command(remote_dir), + "echo '== tmux sessions (running jobs) =='", + "tmux ls 2>/dev/null || echo '(none)'", + "echo", + f"echo '== recent job logs ({REMOTE_LOG_DIR}) =='", + log_loop, + "echo", + "echo '== GPU (index, name, util, mem used, mem total) =='", + gpu, + "echo", + "echo '== load average =='", + "uptime", + ] + return "; ".join(statements) + + +def build_logs_command(remote_dir: str, name: str, *, lines: int, follow: bool) -> str: + """ジョブログの末尾を表示する(follow なら追従する)コマンドを返す. + + 追従モードでは,ssh の標準入力が閉じたら(手元で Ctrl+C・終了したら)tail を止める. + 疑似端末なしの ssh では切断しても tail に SIGHUP が届かず,ログが伸びない限り + リモートに tail が残り続けるため. + + Args: + remote_dir: リモートのプロジェクトルート. + name: ジョブ名. + lines: 表示する末尾の行数. + follow: True なら追記を追従する. + + Returns: + リモートで実行するシェルコマンド. + """ + log = shlex.quote(_job_log_path(validate_job_name(name))) + check = ( + f"[ -f {log} ] || {{ echo 'sm-remote: log not found:' {log} >&2; exit {EXIT_NOT_FOUND}; }}" + ) + if follow: + tail = f'tail -n {int(lines)} -F {log} & t=$!; cat >/dev/null; kill "$t" 2>/dev/null' + else: + tail = f"tail -n {int(lines)} {log}" + return "; ".join([_cd_command(remote_dir), check, tail]) + + +def build_attach_command(name: str) -> str: + """ジョブの tmux セッションに接続するコマンドを返す(ssh -t で使う). + + Args: + name: ジョブ名. + + Returns: + リモートで実行するシェルコマンド. + """ + return f"tmux attach-session -t {_tmux_target(validate_job_name(name))}" + + +def build_attach_check_command(remote_dir: str, name: str) -> str: + """attach の前に,ジョブの tmux セッションがまだあるかを確かめるコマンドを返す. + + セッションがあれば 0 で終わる.無ければログの最終行(`=== [...] exit N` 等)を + 標準出力に出して `EXIT_NO_SESSION`,ログも無ければ `EXIT_NOT_FOUND` で終わる. + `ssh -t` の対話セッションは出力を受け取れないため,事前チェックは別の ssh で行う. + + Args: + remote_dir: リモートのプロジェクトルート. + name: ジョブ名. + + Returns: + リモートで実行するシェルコマンド. + """ + name = validate_job_name(name) + log = shlex.quote(_job_log_path(name)) + statements = [ + f"if tmux has-session -t {_tmux_target(name)} 2>/dev/null; then exit 0; fi", + _cd_command(remote_dir), + f"if [ -f {log} ]; then {_last_line_command(log)}; exit {EXIT_NO_SESSION}; fi", + f"exit {EXIT_NOT_FOUND}", + ] + return "; ".join(statements) + + +def build_kill_command(remote_dir: str, name: str) -> str: + """ジョブの tmux セッションを終了し,ログにその旨を追記するコマンドを返す. + + tmux がセッションを閉じるとジョブの bash は SIGHUP で終わり,自分では終了行を + 書けないため,終了前にここで追記する. + + Args: + remote_dir: リモートのプロジェクトルート. + name: ジョブ名. + + Returns: + リモートで実行するシェルコマンド. + """ + name = validate_job_name(name) + target = _tmux_target(name) + log = shlex.quote(_job_log_path(name)) + statements = [ + f"tmux has-session -t {target} 2>/dev/null || " + f"{{ echo 'sm-remote: no such tmux session:' {shlex.quote(name)} >&2; " + f"exit {EXIT_NO_SESSION}; }}", + f"if cd {quote_remote_path(remote_dir)} 2>/dev/null && [ -f {log} ]; then " + f"printf '=== [%s] killed by sm-remote kill\\n' {_NOW} >> {log}; fi", + f"tmux kill-session -t {target}", + ] + return "; ".join(statements) + + +def normalize_output_subpath(text: str) -> str: + """pull 対象の指定を `output/<サブパス>` 形式に正規化する. + + `offaxis_led_sweep`・`output/offaxis_led_sweep`・`output\\offaxis_led_sweep`(Windows 区切り) + をいずれも `output/offaxis_led_sweep` にする.output/ の外や output/ 全体は指定できない. + + Args: + text: ユーザーが指定したパス. + + Returns: + リモートのプロジェクトルート基準の相対パス(POSIX 区切り). + + Raises: + ValueError: 空・絶対パス・`..` を含む・output/ 全体を指す場合. + """ + cleaned = text.strip().replace("\\", "/") + if not cleaned: + raise ValueError("取得するパスが空です.") + if cleaned.startswith("/") or _WINDOWS_DRIVE_RE.match(cleaned): + raise ValueError(f"絶対パスは指定できません: {text!r}(output/ 配下の相対パスを指定)") + parts = [p for p in PurePosixPath(cleaned).parts if p not in ("", ".")] + if ".." in parts: + raise ValueError(f"'..' を含むパスは指定できません: {text!r}") + if parts and parts[0] == REMOTE_OUTPUT_DIR: + parts = parts[1:] + if not parts: + raise ValueError("output/ 全体は指定できません.サブディレクトリを指定してください.") + return "/".join([REMOTE_OUTPUT_DIR, *parts]) + + +def build_pull_command(remote_dir: str, rel_paths: Sequence[str], *, compress: bool) -> str: + """指定パスを tar ストリームとして標準出力へ書き出すコマンドを返す. + + どれか 1 つでも存在しなければ tar を始める前に `EXIT_NOT_FOUND` で終わる. + + Args: + remote_dir: リモートのプロジェクトルート. + rel_paths: `normalize_output_subpath` で正規化済みのパス. + compress: True なら gzip 圧縮する. + + Returns: + リモートで実行するシェルコマンド. + """ + if not rel_paths: + raise ValueError("取得するパスが指定されていません.") + quoted = " ".join(shlex.quote(p) for p in rel_paths) + flags = "-czf" if compress else "-cf" + statements = [ + _cd_command(remote_dir), + f'for p in {quoted}; do [ -e "$p" ] || ' + f"{{ echo 'sm-remote: not found on remote:' \"$p\" >&2; exit {EXIT_NOT_FOUND}; }}; done", + f"tar {flags} - {quoted}", + ] + return "; ".join(statements) diff --git a/src/sm_tias_lightsim/remote/files.py b/src/sm_tias_lightsim/remote/files.py new file mode 100644 index 0000000..f06437c --- /dev/null +++ b/src/sm_tias_lightsim/remote/files.py @@ -0,0 +1,233 @@ +"""push で送るファイルの選定(除外規則・追加指定・サーバ側の削除対象の算出). + +既定では,Windows 専用の仮想環境・計算結果・キャッシュ・スイープの一時生成物を除いた +プロジェクト全体を送る.gitignore 対象でも実測参照データ(lightsim/・reference_measurement/・ +REFERENCE/)はサーバでの評価に要るので送る. +""" + +from __future__ import annotations + +import fnmatch +import os +import re +from collections.abc import Callable, Collection, Iterable, Iterator, Sequence +from dataclasses import dataclass +from pathlib import Path, PurePosixPath + +# どの階層にあっても送らないディレクトリ名(.venv は Windows 用でサーバでは使えない) +EXCLUDED_DIR_NAMES = frozenset({".git", ".venv", "__pycache__", ".pytest_cache", ".ruff_cache"}) +# プロジェクト直下でだけ除外するエントリ(サーバ側の計算結果を上書きしないため output/ は送らない) +EXCLUDED_ROOT_ENTRIES = frozenset({"output"}) +# プロジェクトルート基準のパターン(スイープ生成物.約 450 MB あり必要時だけ --include で送る) +EXCLUDED_GLOBS = ("configs/_tmp_*",) +# push --prune でサーバ側の古いファイルを消してよいディレクトリ(コードのみ.output/・configs/ は +# サーバ側で生成されたファイルがありうるので対象外) +PRUNE_DIRS = ("src", "tests", "scripts") + +# シーン YAML の `path: C:\...` / `- path: 'D:/...'` のような Windows 絶対パス +_WINDOWS_ABS_PATH_RE = re.compile(r"^\s*(?:-\s*)?path:\s*['\"]?[A-Za-z]:[\\/]", re.MULTILINE) + + +@dataclass(frozen=True) +class PushEntry: + """送信するファイル 1 件. + + Attributes: + path: 手元の実ファイル. + arcname: リモートのプロジェクトルート基準の相対パス(POSIX 区切り). + size: バイト数(進捗表示用). + """ + + path: Path + arcname: str + size: int + + +def find_project_root(start: Path) -> Path: + """start から上方向にたどり,SmTIAS-LightSim のプロジェクトルートを探す. + + Args: + start: 探索を始めるディレクトリ(通常はカレントディレクトリ). + + Returns: + pyproject.toml と src/sm_tias_lightsim/ を持つディレクトリ. + + Raises: + ValueError: 見つからない場合. + """ + start = start.resolve() + for directory in (start, *start.parents): + if (directory / "pyproject.toml").is_file() and ( + directory / "src" / "sm_tias_lightsim" + ).is_dir(): + return directory + msg = ( + f"{start} から上にプロジェクトルート(pyproject.toml と src/sm_tias_lightsim/)が" + "見つかりません.リポジトリ内で実行するか --local-dir / --dest で指定してください." + ) + raise ValueError(msg) + + +def _is_cache_path(rel_path: str) -> bool: + """パスのどこかに除外ディレクトリ名(キャッシュ・.git 等)を含むか.""" + return any(part in EXCLUDED_DIR_NAMES for part in PurePosixPath(rel_path).parts) + + +def is_excluded(rel_path: str) -> bool: + """プロジェクトルート基準の相対パスが既定の push から除外されるかを返す. + + Args: + rel_path: POSIX 区切りの相対パス(ファイルでもディレクトリでもよい). + + Returns: + 除外対象なら True. + """ + parts = PurePosixPath(rel_path).parts + if not parts: + return False + if _is_cache_path(rel_path) or parts[0] in EXCLUDED_ROOT_ENTRIES: + return True + prefixes = ("/".join(parts[:i]) for i in range(1, len(parts) + 1)) + return any( + fnmatch.fnmatchcase(prefix, pattern) for prefix in prefixes for pattern in EXCLUDED_GLOBS + ) + + +def _walk_files(root: Path, start: Path, skip: Callable[[str], bool]) -> Iterator[PushEntry]: + """start 配下のファイルを,skip で除外しつつ決定的な順で列挙する.""" + for dirpath, dirnames, filenames in os.walk(start): + dir_rel = Path(dirpath).relative_to(root).as_posix() + prefix = "" if dir_rel == "." else f"{dir_rel}/" + dirnames[:] = sorted(d for d in dirnames if not skip(prefix + d)) + for filename in sorted(filenames): + rel = prefix + filename + path = Path(dirpath) / filename + if skip(rel) or not path.is_file(): + continue + yield PushEntry(path=path, arcname=rel, size=path.stat().st_size) + + +def resolve_extra_path(root: Path, text: str) -> tuple[Path, str]: + """--include / --only で指定されたパスを検証し,実体と相対パスを返す. + + Args: + root: プロジェクトルート(解決済み). + text: 指定されたパス(相対ならプロジェクトルート基準). + + Returns: + (実体の絶対パス, プロジェクトルート基準の POSIX 相対パス). + + Raises: + ValueError: プロジェクト外・プロジェクト全体・output/ 配下・存在しない場合. + """ + candidate = Path(text) + if not candidate.is_absolute(): + candidate = root / candidate + resolved = candidate.resolve() + try: + rel = resolved.relative_to(root) + except ValueError as exc: + raise ValueError(f"プロジェクト外のパスは送れません: {text}") from exc + if not rel.parts: + msg = "プロジェクト全体は --include / --only ではなく通常の push で送ってください." + raise ValueError(msg) + if rel.parts[0] in EXCLUDED_ROOT_ENTRIES: + msg = f"output/ 配下は push できません(サーバ側の計算結果を上書きしうるため): {text}" + raise ValueError(msg) + if not resolved.exists(): + raise ValueError(f"パスが存在しません: {text}") + return resolved, rel.as_posix() + + +def collect_push_entries( + root: Path, *, include_base: bool, extra_paths: Sequence[str] = () +) -> list[PushEntry]: + """送信するファイルの一覧を作る. + + Args: + root: プロジェクトルート. + include_base: True なら既定の除外規則を適用したプロジェクト全体を含める. + extra_paths: 追加で送るパス(configs/_tmp_* 等).既定の除外規則のうち + グロブ(configs/_tmp_*)は適用しないが,キャッシュ類は除く. + + Returns: + arcname 順に並べた送信対象(同じ arcname は 1 件にまとめる). + + Raises: + ValueError: extra_paths に不正なパスがある場合. + """ + root = root.resolve() + entries: dict[str, PushEntry] = {} + if include_base: + for entry in _walk_files(root, root, is_excluded): + entries[entry.arcname] = entry + for text in extra_paths: + path, rel = resolve_extra_path(root, text) + if path.is_file(): + entries[rel] = PushEntry(path=path, arcname=rel, size=path.stat().st_size) + continue + for entry in _walk_files(root, path, _is_cache_path): + entries[entry.arcname] = entry + return [entries[name] for name in sorted(entries)] + + +def find_yaml_with_windows_paths(entries: Iterable[PushEntry]) -> list[str]: + """メッシュ等の path に Windows の絶対パスを含む YAML を返す(サーバでは読めない). + + configs/_tmp_*/ の古い生成物は絶対パスで書かれているため,送る前に警告する目的. + 読めないファイルは判定対象外にする(警告用の補助チェックで,送信自体は妨げない). + + Args: + entries: 送信対象. + + Returns: + 該当する YAML の arcname 一覧. + """ + hits: list[str] = [] + for entry in entries: + if not entry.arcname.endswith((".yaml", ".yml")): + continue + try: + text = entry.path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + if _WINDOWS_ABS_PATH_RE.search(text): + hits.append(entry.arcname) + return hits + + +def compute_prune_targets( + remote_files: Iterable[str], + local_arcnames: Collection[str], + prune_dirs: Sequence[str] = PRUNE_DIRS, +) -> list[str]: + """サーバにあって手元に無い(=削除・改名された)ファイルを求める. + + Args: + remote_files: サーバの prune_dirs 配下のファイル一覧(プロジェクトルート基準). + local_arcnames: 手元から送ったファイルの arcname 集合. + prune_dirs: 削除してよいディレクトリ. + + Returns: + 削除対象の相対パス(ソート済み).キャッシュ類は含めない. + + Raises: + RuntimeError: prune_dirs 外・絶対パス・`..` を含むパスを受け取った場合 + (リモートの一覧コマンドの不変条件違反.誤削除を防ぐため中断する). + """ + local = set(local_arcnames) + targets: list[str] = [] + for rel in remote_files: + path = PurePosixPath(rel) + if ( + path.is_absolute() + or ".." in path.parts + or len(path.parts) < 2 + or path.parts[0] not in prune_dirs + ): + raise RuntimeError(f"想定外のリモートパスを受信しました(削除を中止): {rel!r}") + normalized = path.as_posix() + if _is_cache_path(normalized) or normalized in local: + continue + targets.append(normalized) + return sorted(targets) diff --git a/src/sm_tias_lightsim/remote/transfer.py b/src/sm_tias_lightsim/remote/transfer.py new file mode 100644 index 0000000..cb07523 --- /dev/null +++ b/src/sm_tias_lightsim/remote/transfer.py @@ -0,0 +1,177 @@ +"""tar ストリームの書き出しと展開(ssh の標準入出力につなぐ). + +Windows には rsync が無いため,Python の tarfile でストリームを作り +`ssh 'tar -x ...'` の標準入力へ流す(push),または `ssh 'tar -c ...'` の +標準出力を展開する(pull).手元の tar に依存しないので PowerShell からも Git Bash からも +同じに動く. + +- 書き出しは PAX 形式(日本語ファイル名を UTF-8 で確実に運ぶ) +- 展開は,メンバー名の構造検証(`..`・`.`・空の要素・バックスラッシュ・ドライブ文字を拒否)→ + リンク(シンボリック・ハード)の一律拒否 → 要求したパスとの前方一致 → Python 3.12 の + `filter="data"`(展開先の外・特殊ファイル)の順に確かめ,どれかに当たれば中断する +""" + +from __future__ import annotations + +import re +import tarfile +from collections.abc import Callable, Sequence +from pathlib import Path +from typing import BinaryIO + +from sm_tias_lightsim.remote.files import PushEntry + +# (処理済みバイト数, 総バイト数.不明なら 0) +ProgressCallback = Callable[[int, int], None] + +_ENCODING = "utf-8" +# Windows のドライブ指定(`C:` 形式).os.path.join がこれを見ると展開先を無視して付け替える +_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:") + + +def _normalize_tarinfo(info: tarfile.TarInfo) -> tarfile.TarInfo: + """所有者とパーミッションを送信元 OS に依存しない値にそろえる. + + Windows の stat は所有者を持たず実行ビットも拡張子から推測した値なので, + 展開結果がそれに引きずられないよう固定する(実行ビットが立っているものだけ 755). + """ + info.uid = info.gid = 0 + info.uname = info.gname = "" + info.mode = 0o755 if info.isdir() or info.mode & 0o111 else 0o644 + return info + + +def write_tar_stream( + entries: Sequence[PushEntry], + fileobj: BinaryIO, + *, + compress: bool, + progress: ProgressCallback | None = None, +) -> int: + """送信対象を tar ストリームとして fileobj へ書き出す. + + fileobj は閉じない(呼び出し側が ssh の標準入力を閉じて EOF を伝える). + + Args: + entries: 送信対象. + fileobj: 書き込み先のバイナリストリーム(ssh の標準入力). + compress: True なら gzip 圧縮する. + progress: ファイル 1 件ごとに呼ぶ進捗コールバック(任意). + + Returns: + 書き出したファイルの合計バイト数(圧縮前). + + Raises: + OSError: 書き込み先が閉じられた(ssh が異常終了した)場合など. + """ + total = sum(entry.size for entry in entries) + done = 0 + mode = "w|gz" if compress else "w|" + with tarfile.open( + fileobj=fileobj, + mode=mode, + format=tarfile.PAX_FORMAT, + encoding=_ENCODING, + dereference=True, + ) as tar: + for entry in entries: + tar.add(entry.path, arcname=entry.arcname, recursive=False, filter=_normalize_tarinfo) + done += entry.size + if progress is not None: + progress(done, total) + return done + + +def _reject(reason: str, name: str) -> tarfile.FilterError: + """展開を中止するための例外を作る(問題のメンバー名と確認先を必ず含める).""" + return tarfile.FilterError( + f"{reason}: 「{name}」(展開を中止しました.サーバ側の output/ を確認してください)" + ) + + +def _validate_member_name(name: str) -> None: + """メンバー名の構造を検証する(要求パスとの前方一致判定の前提). + + `filter="data"` は展開先ディレクトリの外に出るものしか拒否しないため, + `output/foo/../../src/...` のように展開先の内側で要求パスの外へ出る名前が通ってしまう. + 文字列の前方一致だけでは防げないので,要素単位で危険な形を拒否する. + バックスラッシュは Windows の `os.path.join` が区切りとして解釈するため + (`output/foo/..\\..\\src\\x.py` で抜けられる),`posixpath.normpath` で丸める方式は採らない. + + Raises: + tarfile.FilterError: `..`・`.`・空の要素(先頭 `/` や `//`)・バックスラッシュ・ + ドライブ文字(`C:` 形式)を含む場合. + """ + # tarfile はディレクトリ名の末尾の / を落とすが,念のため 1 つだけ許す + trimmed = name[:-1] if name.endswith("/") else name + for part in trimmed.split("/"): + if part in ("", ".", "..") or "\\" in part or _WINDOWS_DRIVE_RE.match(part): + raise _reject( + "アーカイブのメンバー名が不正です" + "('..'・'.'・空の要素・バックスラッシュ・ドライブ文字は受け付けません)", + name, + ) + + +def _is_under(name: str, prefixes: Sequence[str]) -> bool: + """name が prefixes のいずれか自身,またはその配下か(構造検証済みの名前に使う).""" + name = name.rstrip("/") + return any(name == prefix or name.startswith(prefix + "/") for prefix in prefixes) + + +def extract_tar_stream( + fileobj: BinaryIO, + dest: Path, + allowed_prefixes: Sequence[str], + *, + progress: ProgressCallback | None = None, +) -> tuple[int, int]: + """tar ストリーム(非圧縮・gzip 自動判別)を dest へ安全に展開する. + + アーカイブの末尾(終端ブロック後のパディング)は読み残すことがあるため, + ssh プロセスを待つ前に呼び出し側で fileobj を読み切ること(読み残すと ssh が + パイプへの書き込みで止まり,待ち合わせがデッドロックする). + + Args: + fileobj: 読み出し元のバイナリストリーム(ssh の標準出力). + dest: 展開先ディレクトリ(無ければ作る). + allowed_prefixes: 受け入れるメンバーのパス(要求したパスそのもの). + progress: ファイル 1 件ごとに呼ぶ進捗コールバック(任意.総バイト数は 0). + + Returns: + (展開したファイル数, 合計バイト数). + + 検証の順序: メンバー名の構造 → リンクの拒否 → 要求パスとの前方一致 → `data_filter` + (展開先の外・特殊ファイル・危険なパーミッション).中断時,それまでに展開した + ファイルは残る. + + Raises: + tarfile.TarError: ストリームが空・壊れている,または不正な名前・リンク・要求外・ + 危険なメンバーを含む場合(名前・リンク・要求外は `tarfile.FilterError`). + OSError: 書き込みに失敗した場合. + """ + counts = {"files": 0, "bytes": 0} + + def _filter(member: tarfile.TarInfo, path: str) -> tarfile.TarInfo | None: + _validate_member_name(member.name) + # data_filter はリンク先が展開先の内側なら通すため,同名の通常ファイルを後から + # 送られるとハードリンク経由で src/ 等を上書きできる(Windows でも特権なしで作れる). + # pull の対象(output/ の PNG・npy・csv)にリンクは無い前提で一律に拒否する + if member.issym() or member.islnk(): + raise _reject( + f"アーカイブにリンクが含まれています(リンク先 {member.linkname})", member.name + ) + if not _is_under(member.name, allowed_prefixes): + raise _reject("要求していないパスがアーカイブに含まれています", member.name) + filtered = tarfile.data_filter(member, path) + if filtered is not None and filtered.isfile(): + counts["files"] += 1 + counts["bytes"] += filtered.size + if progress is not None: + progress(counts["bytes"], 0) + return filtered + + dest.mkdir(parents=True, exist_ok=True) + with tarfile.open(fileobj=fileobj, mode="r|*", encoding=_ENCODING) as tar: + tar.extractall(dest, filter=_filter) + return counts["files"], counts["bytes"] diff --git a/src/sm_tias_lightsim/render/runner.py b/src/sm_tias_lightsim/render/runner.py index 59bffea..49914fc 100644 --- a/src/sm_tias_lightsim/render/runner.py +++ b/src/sm_tias_lightsim/render/runner.py @@ -16,6 +16,7 @@ import numpy from sm_tias_lightsim.runtime.llvm_init import ensure_drjit_libllvm_path, log_variant_status +from sm_tias_lightsim.runtime.overrides import apply_drjit_thread_count from sm_tias_lightsim.scene.builder import build_scene from sm_tias_lightsim.scene.schema import SceneConfig @@ -59,6 +60,9 @@ # ImportError になるため,この位置に遅延 import する. import mitsuba as mi + # 共用サーバで LLVM バックエンドが全コアを占有しないよう,SM_DRJIT_THREADS があれば制限する + apply_drjit_thread_count() + if _current_variant is None: try: mi.set_variant(desired_variant) diff --git a/src/sm_tias_lightsim/runtime/llvm_init.py b/src/sm_tias_lightsim/runtime/llvm_init.py index a37f9ce..fa7431e 100644 --- a/src/sm_tias_lightsim/runtime/llvm_init.py +++ b/src/sm_tias_lightsim/runtime/llvm_init.py @@ -12,19 +12,69 @@ import glob import logging import os +import re from pathlib import Path _logger = logging.getLogger(__name__) +# Debian / Ubuntu / Fedora 系は LLVM-C API を単一の libLLVM-.so(または libLLVM.so.)に +# 同梱して配布し,libLLVM-C.so を置かない.Dr.Jit 自身はこれらを dlopen できるため, +# 探索対象に含めないと「正常動作しているのに見つからない」誤警告になる(2026-09-13 Newton で確認). +_POSIX_VERSIONED_LIB_PATTERNS: tuple[str, ...] = ( + "/usr/lib/llvm-*/lib/libLLVM.so", + "/usr/lib/llvm-*/lib/libLLVM-*.so", + "/usr/lib/llvm-*/lib/libLLVM-*.so.*", + "/usr/lib/llvm-*/lib/libLLVM.so.*", + "/usr/lib/*-linux-gnu/libLLVM-*.so", + "/usr/lib/*-linux-gnu/libLLVM-*.so.*", + "/usr/lib/*-linux-gnu/libLLVM.so.*", + "/usr/lib64/libLLVM-*.so", + "/usr/lib64/libLLVM.so", + "/usr/lib64/libLLVM.so.*", +) + +_LLVM_VERSION_RE = re.compile(r"(?:llvm-|libLLVM-|libLLVM\.so\.)(\d+)") + + +def _llvm_version_sort_key(path: str) -> tuple[int, int, str]: + """LLVM 共有ライブラリのパスを「新しいバージョン優先・.so 優先」で並べるキーを返す. + + パス中の `llvm-` / `libLLVM-` / `libLLVM.so.` のうち最大の数値を + バージョンとみなす.バージョンが読めないパスは最後に回す. + + Args: + path: ライブラリのパス文字列. + + Returns: + sorted() 用のキー(昇順ソートで新しいバージョンが先頭に来る). + """ + versions = [int(v) for v in _LLVM_VERSION_RE.findall(path)] + version = max(versions) if versions else -1 + # 同一バージョン内では開発用シンボリックリンク(*.so)を実体(*.so.N)より優先する + is_suffixed = 0 if path.endswith(".so") else 1 + return (-version, is_suffixed, path) + + +def _glob_posix_versioned_libs() -> list[str]: + """Debian / Ubuntu / Fedora 系のバージョン付き libLLVM を探索して返す. + + Returns: + 見つかったパスのリスト(新しいバージョン優先・重複なし). + """ + found: set[str] = set() + for pattern in _POSIX_VERSIONED_LIB_PATTERNS: + found.update(glob.glob(pattern)) + return sorted(found, key=_llvm_version_sort_key) + def _iter_default_candidates() -> list[str]: - """LLVM-C.dll / libLLVM-C.so の標準インストール先パス候補を返す. + """LLVM-C.dll / libLLVM-C.so / libLLVM-*.so の標準インストール先パス候補を返す. OS に応じて Windows / POSIX を分岐する. テスト容易化のため独立関数として定義する. Returns: - パス候補文字列のリスト(優先度順). + パス候補文字列のリスト(優先度順・重複なし). """ candidates: list[str] = [] @@ -49,14 +99,20 @@ else: # POSIX 系(Linux / macOS) # glob で llvm-* の複数バージョンを一括検索 - candidates += glob.glob("/usr/lib/llvm-*/lib/libLLVM-C.so") + candidates += sorted( + glob.glob("/usr/lib/llvm-*/lib/libLLVM-C.so"), key=_llvm_version_sort_key + ) + # Debian / Ubuntu(例: /usr/lib/x86_64-linux-gnu/libLLVM-15.so)/ Fedora 系 + candidates += _glob_posix_versioned_libs() candidates += [ "/usr/local/lib/libLLVM-C.so", "/opt/homebrew/opt/llvm/lib/libLLVM-C.dylib", "/usr/local/opt/llvm/lib/libLLVM-C.dylib", ] - return candidates + # `libLLVM-*.so` のパターンは libLLVM-C.so にも一致して同じパスが 2 回入るため, + # 最初の出現(=優先度の高い位置)だけを残す + return list(dict.fromkeys(candidates)) def ensure_drjit_libllvm_path(extra_candidates: list[str] | None = None) -> str | None: @@ -75,6 +131,9 @@ - C:\\msys64\\clang64\\bin\\LLVM-C.dll 4. POSIX 系: - /usr/lib/llvm-*/lib/libLLVM-C.so(glob) + - Debian / Ubuntu / Fedora 系のバージョン付き libLLVM(glob.新しいバージョン優先): + /usr/lib/llvm-*/lib/libLLVM{,-*}.so*,/usr/lib/*-linux-gnu/libLLVM-*.so*, + /usr/lib/*-linux-gnu/libLLVM.so.*,/usr/lib64/libLLVM*.so* - /usr/local/lib/libLLVM-C.so - /opt/homebrew/opt/llvm/lib/libLLVM-C.dylib - /usr/local/opt/llvm/lib/libLLVM-C.dylib @@ -112,7 +171,7 @@ return candidate _logger.warning( - "LLVM-C.dll / libLLVM-C.so が標準パスに見つかりませんでした." + "LLVM-C.dll / libLLVM-C.so / libLLVM-*.so が標準パスに見つかりませんでした." "LLVM をインストールするか,DRJIT_LIBLLVM_PATH 環境変数を手動で設定してください." "詳細は docs/02_ENV/ENV_02_環境構築手順.md を参照してください." ) diff --git a/src/sm_tias_lightsim/runtime/overrides.py b/src/sm_tias_lightsim/runtime/overrides.py new file mode 100644 index 0000000..076db39 --- /dev/null +++ b/src/sm_tias_lightsim/runtime/overrides.py @@ -0,0 +1,113 @@ +"""環境変数による実行時設定の上書き(variant・Dr.Jit スレッド数). + +同じシーン YAML を手元(Windows・llvm_ad_rgb)と研究室サーバ(cuda_ad_rgb)の両方で +使い回すため,YAML を書き換えずに実行環境側から設定を差し替える手段を提供する. +スイープスクリプトは sm-render をサブプロセスで呼ぶので,環境変数にしておけば +`sm-remote run` で 1 回指定するだけで配下の全レンダに伝わる. + +- `SM_RENDER_VARIANT`: sm-render が使う Mitsuba variant(YAML の render.variant より優先) +- `SM_DRJIT_THREADS`: Dr.Jit(LLVM バックエンド / scalar レンダ)の CPU スレッド数上限. + 共用サーバ(64 スレッド)で llvm_ad_rgb が全コアを占有するのを防ぐ + (2026-09-13 Newton で loadavg 33 → 87 を実測) + +drjit は `apply_drjit_thread_count` の中で必要な時だけ import する +(未設定時は import しない=テスト・--help の軽量性を保つ). +""" + +from __future__ import annotations + +import logging +import os +from collections.abc import Mapping + +_logger = logging.getLogger(__name__) + +VARIANT_ENV_VAR = "SM_RENDER_VARIANT" +THREADS_ENV_VAR = "SM_DRJIT_THREADS" + +# 同一プロセスで何度呼ばれても INFO ログは 1 回にする(runner と baker の両方から呼ばれるため) +_applied_thread_count: int | None = None + + +def resolve_render_variant( + yaml_variant: str, + cli_variant: str | None = None, + environ: Mapping[str, str] | None = None, +) -> str: + """実際に使う Mitsuba variant を決める. + + 優先順位: CLI 引数 > 環境変数 `SM_RENDER_VARIANT` > YAML の render.variant. + 空文字・空白のみの値は「指定なし」とみなす. + + Args: + yaml_variant: シーン YAML の render.variant. + cli_variant: CLI の --variant(未指定なら None). + environ: 参照する環境変数(None なら os.environ). + + Returns: + 使用する variant 文字列. + """ + if cli_variant is not None and cli_variant.strip(): + return cli_variant.strip() + env = os.environ if environ is None else environ + env_variant = env.get(VARIANT_ENV_VAR, "").strip() + if env_variant: + return env_variant + return yaml_variant + + +def resolve_thread_count(environ: Mapping[str, str] | None = None) -> int | None: + """環境変数 `SM_DRJIT_THREADS` から Dr.Jit のスレッド数上限を読む. + + Args: + environ: 参照する環境変数(None なら os.environ). + + Returns: + スレッド数(1 以上).未設定・空なら None(Dr.Jit 既定=全論理コア). + + Raises: + ValueError: 値が整数でない,または 1 未満の場合. + """ + env = os.environ if environ is None else environ + raw = env.get(THREADS_ENV_VAR, "").strip() + if not raw: + return None + try: + count = int(raw) + except ValueError as exc: + msg = f"環境変数 {THREADS_ENV_VAR}={raw!r} は整数ではありません(1 以上の整数を指定)." + raise ValueError(msg) from exc + if count < 1: + msg = f"環境変数 {THREADS_ENV_VAR}={count} は 1 以上である必要があります." + raise ValueError(msg) + return count + + +def apply_drjit_thread_count(environ: Mapping[str, str] | None = None) -> int | None: + """`SM_DRJIT_THREADS` が設定されていれば Dr.Jit のスレッドプールをその数に制限する. + + 未設定なら何もしない(drjit も import しない).`dr.set_thread_count` は + JIT 初期化の前後どちらで呼んでもよく,冪等なので複数箇所から呼んでよい. + + Args: + environ: 参照する環境変数(None なら os.environ). + + Returns: + 設定したスレッド数.未設定なら None. + + Raises: + ValueError: 環境変数の値が不正な場合. + """ + global _applied_thread_count # noqa: PLW0603 + + count = resolve_thread_count(environ) + if count is None: + return None + + import drjit as dr + + dr.set_thread_count(count) + if _applied_thread_count != count: + _logger.info("Dr.Jit の CPU スレッド数を %d に制限しました(%s)", count, THREADS_ENV_VAR) + _applied_thread_count = count + return count diff --git a/src/sm_tias_lightsim/scene/paths.py b/src/sm_tias_lightsim/scene/paths.py new file mode 100644 index 0000000..b27f235 --- /dev/null +++ b/src/sm_tias_lightsim/scene/paths.py @@ -0,0 +1,44 @@ +"""シーン YAML に書くメッシュパスの組み立て. + +シーン YAML の `geometry.meshes[].path` は,相対パスなら **YAML 自身のディレクトリ基準**で +解決される(sm-render / sm-visualize / 検証スクリプトはいずれも YAML の親を基点に渡す). +スイープスクリプトが configs/_tmp_*/ に書き出す YAML は,以前は絶対パスを書いていたため +Windows で生成した YAML(C:\\Users\\...)を研究室サーバへ送ると読めなかった +(2026-09-13 Newton で確認).YAML からの相対パスを POSIX 区切りで書けば, +リポジトリごとどこへ移しても(Windows でも Linux でも)そのまま解決できる. +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path + +_logger = logging.getLogger(__name__) + + +def relative_mesh_path(target: Path, yaml_dir: Path) -> str: + """メッシュファイルを,YAML の置き場所からの相対パス(POSIX 区切り)で返す. + + Windows で別ドライブにあるなど相対パスで表せない場合だけ,警告ログを出して + 絶対パスを返す(従来の挙動.サーバへは持ち出せない). + + Args: + target: メッシュファイルのパス(相対なら CWD 基準). + yaml_dir: そのパスを書き込むシーン YAML のディレクトリ. + + Returns: + YAML に書き込むパス文字列(例: `meshes/筐体_L120.stl`,`../meshes/前側.stl`). + """ + target_abs = target.resolve() + try: + rel = os.path.relpath(target_abs, yaml_dir.resolve()) + except ValueError: + _logger.warning( + "メッシュ %s は YAML ディレクトリ %s からの相対パスで表せないため" + "絶対パスを書き込みます(他のマシンでは読めません).", + target_abs, + yaml_dir, + ) + return str(target_abs) + return Path(rel).as_posix() diff --git a/src/sm_tias_lightsim/visualize/baker.py b/src/sm_tias_lightsim/visualize/baker.py index 0303166..a5762c5 100644 --- a/src/sm_tias_lightsim/visualize/baker.py +++ b/src/sm_tias_lightsim/visualize/baker.py @@ -31,6 +31,7 @@ from sm_tias_lightsim.geometry.subdivide import subdivide_mesh_to_max_edge from sm_tias_lightsim.geometry.transforms import scale_vertices from sm_tias_lightsim.runtime.llvm_init import ensure_drjit_libllvm_path, log_variant_status +from sm_tias_lightsim.runtime.overrides import apply_drjit_thread_count from sm_tias_lightsim.scene.schema import ( AreaLight, DirectionalLight, @@ -253,6 +254,10 @@ from sm_tias_lightsim.scene.builder import build_scene + # 共用サーバで Dr.Jit のスレッドプールが全コアを占有しないよう, + # SM_DRJIT_THREADS があれば制限する + apply_drjit_thread_count() + if mode == "direct": # Mitsuba variant を初期化(scalar_rgb を使う:shadow ray 評価に最適) # scalar_rgb は LLVM 不要なので ensure_drjit_libllvm_path() は呼ばない diff --git a/tests/cli/test_remote.py b/tests/cli/test_remote.py new file mode 100644 index 0000000..b6fa703 --- /dev/null +++ b/tests/cli/test_remote.py @@ -0,0 +1,975 @@ +"""cli/remote.py(sm-remote CLI)の仕様ベーステスト. + +仕様出典: +- docs/02_ENV/ENV_04_開発コマンド.md「研究室サーバでの実行」「環境変数」 +- docs/02_ENV/ENV_02_環境構築手順.md「研究室サーバ編」 +- 実装サマリー 仕様 1〜6(push / run / status・logs・attach・kill / pull / 設定の解決 / + サーバ上での実行検出) + +テスト方針: +- ssh・tmux・実サーバには依存しない.subprocess.Popen を偽物に差し替え, + 「ssh にどんな引数・標準入力を渡したか」と「ssh の終了コードをどう扱うか」を検証する +- ホスト名はテストごとに固定し(dev-pc),CWD はリポジトリ外にする + (このテストを研究室サーバ上で回してもサーバ上検出が誤作動しないように) +""" + +from __future__ import annotations + +import gzip +import io +import os +import re +import shlex +import subprocess +import tarfile +import types +from pathlib import Path + +import pytest + +import sm_tias_lightsim.cli.remote as remote_cli +from sm_tias_lightsim.cli.remote import ( + DEFAULT_HOST, + DEFAULT_REMOTE_DIR, + DEFAULT_UV, + build_job_env, + default_ssh_executable, + detect_running_on_server, + main, + resolve_settings, +) + +# --------------------------------------------------------------------------- +# 偽の ssh(subprocess.Popen) +# --------------------------------------------------------------------------- + + +class _CapturingPipe(io.BytesIO): + """ssh の標準入力の代わり.close 時点の内容を captured に残す.""" + + captured: bytes | None = None + + def close(self) -> None: + if not self.closed: + self.captured = self.getvalue() + super().close() + + +class _InterruptingStdout(io.BytesIO): + """読むと KeyboardInterrupt(手元の Ctrl+C)を起こす標準出力.""" + + def read1(self, size: int = -1) -> bytes: + raise KeyboardInterrupt + + +class FakeProc: + def __init__( + self, + argv: list[str], + kwargs: dict, + *, + rc: int = 0, + stdout: bytes = b"", + finished: bool = False, + interrupt: bool = False, + ) -> None: + self.argv = argv + self.kwargs = kwargs + self._rc = rc + self.returncode = rc if finished else None + self.stdin = _CapturingPipe() if kwargs.get("stdin") == subprocess.PIPE else None + if kwargs.get("stdout") == subprocess.PIPE: + self.stdout = _InterruptingStdout(stdout) if interrupt else io.BytesIO(stdout) + else: + self.stdout = None + self.input: bytes | None = None + self.killed = False + + def communicate(self, input: bytes | None = None): # noqa: A002 + self.input = input + self.returncode = self._rc + return (self.stdout.read() if self.stdout is not None else None), None + + def wait(self, timeout: float | None = None) -> int: + self.returncode = self._rc + return self._rc + + def poll(self) -> int | None: + return self.returncode + + def kill(self) -> None: + self.killed = True + + +@pytest.fixture +def ssh(monkeypatch): + """subprocess.Popen を偽物にする.responses に積んだ応答を呼ばれた順に返す.""" + state = types.SimpleNamespace(calls=[], responses=[]) + + def fake_popen(argv, **kwargs): + spec = state.responses.pop(0) if state.responses else {} + proc = FakeProc(list(argv), kwargs, **spec) + state.calls.append(proc) + return proc + + monkeypatch.setattr(remote_cli.subprocess, "Popen", fake_popen) + return state + + +@pytest.fixture(autouse=True) +def _isolated_environment(monkeypatch, tmp_path): + for key in ( + "SM_REMOTE_HOST", + "SM_REMOTE_DIR", + "SM_REMOTE_UV", + "SM_REMOTE_VARIANT", + "SM_REMOTE_ALLOW_SELF", + ): + monkeypatch.delenv(key, raising=False) + monkeypatch.setenv("SM_REMOTE_SSH", "fake-ssh") + monkeypatch.setattr(remote_cli.socket, "gethostname", lambda: "dev-pc") + cwd = tmp_path / "cwd" + cwd.mkdir() + monkeypatch.chdir(cwd) + + +# --------------------------------------------------------------------------- +# シェルの単語分割の再現 +# --------------------------------------------------------------------------- + + +def _shell_words(text: str) -> list[str]: + lex = shlex.shlex(text, posix=True, punctuation_chars=";&|") + lex.whitespace_split = True + return list(lex) + + +def _statements(text: str) -> list[list[str]]: + result: list[list[str]] = [[]] + for word in _shell_words(text): + if word == ";": + result.append([]) + else: + result[-1].append(word) + return [s for s in result if s] + + +def _has_sequence(words: list[str], seq: list[str]) -> bool: + n = len(seq) + return any(words[i : i + n] == seq for i in range(len(words) - n + 1)) + + +def _job_script(run_command: str) -> str: + """build_run_command の出力から,tmux → bash -c に渡るジョブ本体を取り出す.""" + words = _shell_words(run_command) + stmt = words[words.index("new-session") :] + if ";" in stmt: + stmt = stmt[: stmt.index(";")] + inner = shlex.split(stmt[-1]) + assert inner[:2] == ["bash", "-c"] + return inner[2] + + +def _exports(script: str) -> dict[str, str]: + result = {} + for stmt in _statements(script): + if stmt[0] == "export" and len(stmt) == 2 and not stmt[1].startswith("PATH="): + key, _, value = stmt[1].partition("=") + result[key] = value + return result + + +def _run_statement(script: str) -> list[str]: + return next(s for s in _statements(script) if "/dev/null" in s and "tee" in s) + + +def _tar_bytes(members: dict[str, bytes], *, gz: bool = False) -> bytes: + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz" if gz else "w", format=tarfile.PAX_FORMAT) as tar: + for name, data in members.items(): + info = tarfile.TarInfo(name) + info.size = len(data) + info.mode = 0o644 + tar.addfile(info, io.BytesIO(data)) + return buf.getvalue() + + +# --------------------------------------------------------------------------- +# 疑似プロジェクト(push 用) +# --------------------------------------------------------------------------- + +_PROJECT = { + "pyproject.toml": "[project]\nname='x'\n", + "src/sm_tias_lightsim/__init__.py": "", + "src/sm_tias_lightsim/a.py": "x = 1\n", + "src/sm_tias_lightsim/__pycache__/a.cpython-312.pyc": "pyc", + "tests/test_a.py": "", + "scripts/s.py": "", + ".venv/Scripts/python.exe": "exe", + ".git/HEAD": "ref", + "output/r/luminance.npy": "npy", + "configs/scene.yaml": "geometry:\n meshes:\n - id: a\n path: meshes/a.stl\n", + "configs/meshes/前側.stl": "solid", + "configs/_tmp_x/s.yaml": "geometry:\n meshes:\n - id: a\n path: C:\\Users\\r\\a.stl\n", +} + +_EXPECTED_PUSH = { + "pyproject.toml", + "src/sm_tias_lightsim/__init__.py", + "src/sm_tias_lightsim/a.py", + "tests/test_a.py", + "scripts/s.py", + "configs/scene.yaml", + "configs/meshes/前側.stl", +} + + +@pytest.fixture +def project(tmp_path) -> Path: + root = tmp_path / "proj" + for rel, content in _PROJECT.items(): + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return root + + +# --------------------------------------------------------------------------- +# 設定の解決(仕様 5) +# --------------------------------------------------------------------------- + + +class TestResolveSettings: + def test_default_values(self): + assert DEFAULT_HOST == "Newton" + assert DEFAULT_REMOTE_DIR == "~/lab/SmTIAS-LightSim" + assert DEFAULT_UV == "~/.local/bin/uv" + settings = resolve_settings(None, None, None, {}) + assert settings.host == "Newton" + assert settings.remote_dir == "~/lab/SmTIAS-LightSim" + assert settings.uv_path == "~/.local/bin/uv" + assert settings.ssh == default_ssh_executable({}) + + def test_env_overrides_defaults(self): + env = { + "SM_REMOTE_HOST": "Einstein", + "SM_REMOTE_DIR": "/srv/proj", + "SM_REMOTE_UV": "/opt/uv/uv", + "SM_REMOTE_SSH": "/usr/bin/ssh", + } + settings = resolve_settings(None, None, None, env) + assert (settings.host, settings.remote_dir, settings.uv_path, settings.ssh) == ( + "Einstein", + "/srv/proj", + "/opt/uv/uv", + "/usr/bin/ssh", + ) + + def test_args_override_env(self): + env = {"SM_REMOTE_HOST": "Einstein", "SM_REMOTE_DIR": "/srv/a", "SM_REMOTE_UV": "/a/uv"} + settings = resolve_settings("Archimedes", "~/b", "/b/uv", env) + assert (settings.host, settings.remote_dir, settings.uv_path) == ( + "Archimedes", + "~/b", + "/b/uv", + ) + + def test_blank_values_fall_back(self): + env = {"SM_REMOTE_HOST": " ", "SM_REMOTE_DIR": "", "SM_REMOTE_UV": " "} + settings = resolve_settings("", " ", None, env) + assert (settings.host, settings.remote_dir, settings.uv_path) == ( + "Newton", + "~/lab/SmTIAS-LightSim", + "~/.local/bin/uv", + ) + + def test_main_uses_env_then_args_for_host_and_dir(self, ssh, monkeypatch): + monkeypatch.setenv("SM_REMOTE_HOST", "Einstein") + monkeypatch.setenv("SM_REMOTE_DIR", "/srv/env dir") + assert main(["status"]) == 0 + argv = ssh.calls[0].argv + assert argv[:2] == ["fake-ssh", "Einstein"] + assert _statements(argv[-1])[0][:2] == ["cd", "/srv/env dir"] + + assert main(["status", "--host", "Archimedes", "--remote-dir", "~/x"]) == 0 + argv = ssh.calls[1].argv + assert argv[:2] == ["fake-ssh", "Archimedes"] + assert _statements(argv[-1])[0][:2] == ["cd", "$HOME/x"] + + +class TestDefaultSshExecutable: + def _make_ssh(self, root: Path) -> Path: + exe = root / "System32" / "OpenSSH" / "ssh.exe" + exe.parent.mkdir(parents=True) + exe.write_bytes(b"") + return exe + + def test_windows_prefers_system32_openssh(self, tmp_path): + exe = self._make_ssh(tmp_path) + assert default_ssh_executable({"SystemRoot": str(tmp_path)}, "nt") == str(exe) + + def test_windows_uppercase_systemroot(self, tmp_path): + exe = self._make_ssh(tmp_path) + assert default_ssh_executable({"SYSTEMROOT": str(tmp_path)}, "nt") == str(exe) + + def test_windows_without_openssh_falls_back_to_path(self, tmp_path): + assert default_ssh_executable({"SystemRoot": str(tmp_path)}, "nt") == "ssh" + + def test_posix_uses_ssh_on_path(self, tmp_path): + self._make_ssh(tmp_path) + assert default_ssh_executable({"SystemRoot": str(tmp_path)}, "posix") == "ssh" + + @pytest.mark.skipif( + os.name != "nt" or not Path(r"C:\Windows\System32\OpenSSH\ssh.exe").is_file(), + reason="Windows 標準の OpenSSH がある環境のみ", + ) + def test_real_windows_default(self): + result = default_ssh_executable(os.environ) + assert result.lower() == r"c:\windows\system32\openssh\ssh.exe" + + +# --------------------------------------------------------------------------- +# サーバ上での実行検出(仕様 6) +# --------------------------------------------------------------------------- + + +def _detect(**overrides): + kwargs = { + "host": "Newton", + "remote_dir": "~/lab/SmTIAS-LightSim", + "environ": {}, + "local_hostname": "dev-pc", + "project_root": None, + "home": None, + "os_name": "posix", + } + kwargs.update(overrides) + host = kwargs.pop("host") + remote_dir = kwargs.pop("remote_dir") + return detect_running_on_server(host, remote_dir, **kwargs) + + +class TestDetectRunningOnServer: + @pytest.mark.parametrize( + ("local", "host"), + [ + ("Newton", "Newton"), + ("newton", "Newton"), + ("NEWTON", "Newton"), + ("newton.local", "Newton"), + ("Newton", "Newton.local"), + ("newton.lab.example.ac.jp", "Newton.local"), + (" newton ", "Newton"), + ("192.168.23.13", "192.168.23.13"), + ], + ) + def test_hostname_match(self, local, host): + """ホスト名は小文字化・ドメイン部除去で比べる(IPv4 はそのまま).""" + reason = _detect(host=host, local_hostname=local) + assert isinstance(reason, str) and reason + + @pytest.mark.parametrize( + ("local", "host"), + [ + ("dev-pc", "Newton"), + ("Newton2", "Newton"), + ("newt", "Newton"), + ("192.168.23.13", "192.168.23.15"), + ("192", "192.168.23.13"), # IPv4 はドメイン部除去をしない + ("192.168.23.13", "192"), + ("", "Newton"), + ], + ) + def test_hostname_mismatch(self, local, host): + assert _detect(host=host, local_hostname=local) is None + + def test_hostname_match_applies_on_windows_too(self): + assert _detect(local_hostname="newton", os_name="nt") is not None + + def _server_layout(self, tmp_path: Path) -> tuple[Path, Path]: + home = tmp_path / "home" + root = home / "lab" / "SmTIAS-LightSim" + root.mkdir(parents=True) + return home, root + + @pytest.mark.parametrize("remote_dir", ["~/lab/SmTIAS-LightSim", "lab/SmTIAS-LightSim"]) + def test_posix_repo_root_match(self, tmp_path, remote_dir): + """POSIX でリポジトリのルートが remote_dir(~ 展開・ホーム基準)と一致すれば検出.""" + home, root = self._server_layout(tmp_path) + assert _detect(remote_dir=remote_dir, project_root=root, home=home) is not None + + def test_posix_absolute_remote_dir_match(self, tmp_path): + home, root = self._server_layout(tmp_path) + assert _detect(remote_dir=str(root), project_root=root, home=home) is not None + + def test_posix_repo_root_mismatch(self, tmp_path): + home, _ = self._server_layout(tmp_path) + other = tmp_path / "work" / "SmTIAS-LightSim" + other.mkdir(parents=True) + assert _detect(project_root=other, home=home) is None + + def test_windows_does_not_compare_directories(self, tmp_path): + """Windows ではディレクトリを比べない(手元の PC を誤検出しない).""" + home, root = self._server_layout(tmp_path) + assert _detect(project_root=root, home=home, os_name="nt") is None + + def test_unknown_root_or_home_is_not_detected(self, tmp_path): + home, root = self._server_layout(tmp_path) + assert _detect(project_root=None, home=home) is None + assert _detect(project_root=root, home=None) is None + + def test_tilde_user_remote_dir_does_not_crash(self, tmp_path): + home, root = self._server_layout(tmp_path) + assert _detect(remote_dir="~other/lab", project_root=root, home=home) is None + + @pytest.mark.parametrize("value", ["1", "true", "yes", "TRUE", " Yes "]) + def test_allow_self_disables_detection(self, tmp_path, value): + home, root = self._server_layout(tmp_path) + environ = {"SM_REMOTE_ALLOW_SELF": value} + assert ( + _detect(environ=environ, local_hostname="newton", project_root=root, home=home) is None + ) + + @pytest.mark.parametrize("value", ["", "0", "no", "false", "off"]) + def test_other_allow_self_values_keep_detection(self, value): + environ = {"SM_REMOTE_ALLOW_SELF": value} + assert _detect(environ=environ, local_hostname="newton") is not None + + +class TestMainStopsOnServer: + @pytest.mark.parametrize( + "argv", + [ + ["status"], + ["logs", "job1"], + ["logs", "job1", "-f"], + ["attach", "job1"], + ["kill", "job1"], + ["run", "--name", "job1", "--", "echo", "hi"], + ["push", "--dry-run", "--local-dir", "{project}"], + ["pull", "foo", "--dest", "{dest}"], + ], + ) + def test_exits_1_before_starting_ssh(self, argv, ssh, monkeypatch, capsys, project, tmp_path): + """サーバ上なら ssh を起動する前に案内を出して終了コード 1 で止まること.""" + monkeypatch.setattr(remote_cli.socket, "gethostname", lambda: "newton.local") + argv = [a.format(project=project, dest=tmp_path / "dest") for a in argv] + assert main(argv) == 1 + assert ssh.calls == [] + captured = capsys.readouterr() + assert "SM_REMOTE_ALLOW_SELF" in captured.err + assert "送信対象" not in captured.out + + def test_allow_self_lets_command_run(self, ssh, monkeypatch): + monkeypatch.setattr(remote_cli.socket, "gethostname", lambda: "newton") + monkeypatch.setenv("SM_REMOTE_ALLOW_SELF", "1") + assert main(["status"]) == 0 + assert len(ssh.calls) == 1 + + +# --------------------------------------------------------------------------- +# run(仕様 2) +# --------------------------------------------------------------------------- + + +class TestBuildJobEnv: + def test_minimal_env_is_unbuffered_python(self): + assert build_job_env(gpu=None, variant=None, threads=None, extra=[]) == { + "PYTHONUNBUFFERED": "1" + } + + def test_all_options(self): + env = build_job_env(gpu="0,2", variant="cuda_ad_rgb", threads=16, extra=["FOO=bar"]) + assert env == { + "PYTHONUNBUFFERED": "1", + "CUDA_VISIBLE_DEVICES": "0,2", + "SM_RENDER_VARIANT": "cuda_ad_rgb", + "SM_DRJIT_THREADS": "16", + "FOO": "bar", + } + + def test_extra_is_last_wins(self): + env = build_job_env(gpu=None, variant=None, threads=None, extra=["A=1", "A=2"]) + assert env["A"] == "2" + + @pytest.mark.parametrize( + "kwargs", + [ + {"gpu": "0;rm"}, + {"gpu": ""}, + {"threads": 0}, + {"threads": -4}, + {"extra": ["NOEQUALS"]}, + {"extra": ["1BAD=x"]}, + ], + ) + def test_invalid_values_raise(self, kwargs): + base = {"gpu": None, "variant": None, "threads": None, "extra": []} + base.update(kwargs) + with pytest.raises(ValueError): + build_job_env(**base) + + +def _dry_run_script(capsys, *args: str) -> tuple[str, str]: + """run --dry-run を実行し,(表示されたリモートコマンド, ジョブ本体) を返す.""" + argv = ["run", "--name", "offaxis", "--dry-run", *args] + if "--" not in args: + argv += ["--", "python", "scripts/sweep_offaxis_led.py"] + assert main(argv) == 0 + remote_command = capsys.readouterr().out.strip() + return remote_command, _job_script(remote_command) + + +class TestRunCommand: + def test_dry_run_does_not_start_ssh(self, ssh, capsys): + remote_command, _ = _dry_run_script(capsys) + assert ssh.calls == [] + assert "new-session" in remote_command + + def test_default_env(self, capsys): + """既定で SM_RENDER_VARIANT=cuda_ad_rgb と PYTHONUNBUFFERED=1 を渡すこと.""" + _, script = _dry_run_script(capsys) + env = _exports(script) + assert env["SM_RENDER_VARIANT"] == "cuda_ad_rgb" + assert env["PYTHONUNBUFFERED"] == "1" + assert "CUDA_VISIBLE_DEVICES" not in env + assert "SM_DRJIT_THREADS" not in env + + def test_yaml_variant_does_not_set_variant(self, capsys): + remote_command, script = _dry_run_script(capsys, "--yaml-variant") + assert "SM_RENDER_VARIANT" not in _exports(script) + assert "SM_RENDER_VARIANT" not in remote_command + + def test_gpu_and_threads(self, capsys): + _, script = _dry_run_script(capsys, "--gpu", "0,2", "--threads", "16") + env = _exports(script) + assert env["CUDA_VISIBLE_DEVICES"] == "0,2" + assert env["SM_DRJIT_THREADS"] == "16" + + def test_variant_precedence(self, capsys, monkeypatch): + monkeypatch.setenv("SM_REMOTE_VARIANT", "llvm_ad_rgb") + _, script = _dry_run_script(capsys) + assert _exports(script)["SM_RENDER_VARIANT"] == "llvm_ad_rgb" + _, script = _dry_run_script(capsys, "--variant", "scalar_rgb") + assert _exports(script)["SM_RENDER_VARIANT"] == "scalar_rgb" + _, script = _dry_run_script(capsys, "--variant", "scalar_rgb", "--yaml-variant") + assert "SM_RENDER_VARIANT" not in _exports(script) + + def test_extra_env(self, capsys): + _, script = _dry_run_script(capsys, "--env", "FOO=a b", "--env", "BAR=") + env = _exports(script) + assert env["FOO"] == "a b" + assert env["BAR"] == "" + + def test_command_after_double_dash_survives_quoting(self, capsys): + command = ["python", "-c", "print('a b'); import os", "--x=$(id)", "it's"] + _, script = _dry_run_script(capsys, "--", *command) + run = _run_statement(script) + assert run[: 2 + len(command)] == ["$HOME/.local/bin/uv", "run", *command] + + def test_uv_path_from_env_and_arg(self, capsys, monkeypatch): + monkeypatch.setenv("SM_REMOTE_UV", "/opt/uv/uv") + _, script = _dry_run_script(capsys) + assert _run_statement(script)[:2] == ["/opt/uv/uv", "run"] + _, script = _dry_run_script(capsys, "--uv", "/usr/local/bin/uv") + assert _run_statement(script)[:2] == ["/usr/local/bin/uv", "run"] + + def test_no_uv(self, capsys): + _, script = _dry_run_script(capsys, "--no-uv", "--", "nvidia-smi") + assert _run_statement(script)[0] == "nvidia-smi" + + def test_log_and_remote_dir(self, capsys): + remote_command, script = _dry_run_script(capsys, "--remote-dir", "~/lab/X") + statements = _statements(script) + assert statements[0][:2] == ["cd", "$HOME/lab/X"] + assert ["log=output/_remote_logs/offaxis.log"] in statements + assert _has_sequence(_run_statement(script), ["<", "/dev/null"]) + assert _statements(remote_command)[0][:2] == ["cd", "$HOME/lab/X"] + + def test_default_job_name_is_timestamp(self, capsys): + assert main(["run", "--dry-run", "--", "echo"]) == 0 + script = _job_script(capsys.readouterr().out.strip()) + logs = [s[0] for s in _statements(script) if s[0].startswith("log=")] + assert re.fullmatch(r"log=output/_remote_logs/job-\d{8}-\d{6}\.log", logs[0]) + + @pytest.mark.parametrize( + "argv", + [ + ["run", "--name", "x"], # コマンドなし + ["run", "--name", "x", "--"], + ["run", "--name", "bad name", "--", "echo"], + ["run", "--name", "a.b", "--", "echo"], + ["run", "--name", "x", "--gpu", "0;rm", "--", "echo"], + ["run", "--name", "x", "--gpu", "gpu0", "--", "echo"], + ["run", "--name", "x", "--threads", "0", "--", "echo"], + ["run", "--name", "x", "--env", "BAD", "--", "echo"], + ], + ) + def test_invalid_input_exits_1_without_ssh(self, argv, ssh, capsys): + assert main(argv) == 1 + assert ssh.calls == [] + assert "エラー" in capsys.readouterr().err + + def test_launch_success(self, ssh, capsys): + ssh.responses.append({"rc": 0}) + rc = main(["run", "--name", "offaxis", "--gpu", "0", "--", "python", "a.py"]) + assert rc == 0 + proc = ssh.calls[0] + assert proc.argv[:2] == ["fake-ssh", "Newton"] + assert len(proc.argv) == 3 + assert proc.kwargs["stdin"] == subprocess.DEVNULL + assert _exports(_job_script(proc.argv[-1]))["CUDA_VISIBLE_DEVICES"] == "0" + out = capsys.readouterr().out + assert "offaxis" in out + assert "output/_remote_logs/offaxis.log" in out + + def test_existing_session_returns_91(self, ssh, capsys): + ssh.responses.append({"rc": 91}) + assert main(["run", "--name", "offaxis", "--", "echo"]) == 91 + err = capsys.readouterr().err + assert "offaxis" in err + assert "実行中" in err + + def test_ssh_failure_is_reported(self, ssh, capsys): + ssh.responses.append({"rc": 255}) + assert main(["run", "--name", "offaxis", "--", "echo"]) == 255 + assert "ssh" in capsys.readouterr().err + + def test_missing_remote_dir_is_reported(self, ssh, capsys): + ssh.responses.append({"rc": 90}) + assert main(["run", "--name", "offaxis", "--", "echo"]) == 90 + assert "push" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# status / logs / attach / kill(仕様 3) +# --------------------------------------------------------------------------- + + +class TestStatus: + def test_prints_remote_output(self, ssh, capsys): + ssh.responses.append({"rc": 0, "stdout": "== tmux sessions ==\n(none)\n日本語\n".encode()}) + assert main(["status"]) == 0 + assert "(none)" in capsys.readouterr().out + words = _shell_words(ssh.calls[0].argv[-1]) + assert "nvidia-smi" in words + assert ssh.calls[0].kwargs["stdin"] == subprocess.DEVNULL + + def test_missing_remote_dir(self, ssh, capsys): + ssh.responses.append({"rc": 90}) + assert main(["status"]) == 90 + assert "push" in capsys.readouterr().err + + +class TestLogs: + def test_tail_n_lines(self, ssh, capsys): + ssh.responses.append({"rc": 0, "stdout": b"line1\nline2\n"}) + assert main(["logs", "offaxis", "-n", "7"]) == 0 + assert _statements(ssh.calls[0].argv[-1])[-1] == [ + "tail", + "-n", + "7", + "output/_remote_logs/offaxis.log", + ] + assert "line2" in capsys.readouterr().out + + def test_missing_log_returns_92(self, ssh, capsys): + ssh.responses.append({"rc": 92}) + assert main(["logs", "offaxis"]) == 92 + assert "offaxis" in capsys.readouterr().err + + @pytest.mark.parametrize("argv", [["logs", "offaxis", "-n", "-1"], ["logs", "a;b"]]) + def test_invalid_input_exits_1(self, argv, ssh): + assert main(argv) == 1 + assert ssh.calls == [] + + def test_follow_streams_and_closes_stdin(self, ssh, capsys): + """-f はリモートの出力を逐次表示し,終了時に ssh の標準入力を閉じる(tail を止める).""" + ssh.responses.append({"rc": 0, "stdout": b"start\n 50%\r100%\n"}) + assert main(["logs", "offaxis", "-f"]) == 0 + proc = ssh.calls[0] + assert proc.kwargs["stdin"] == subprocess.PIPE + assert proc.stdin.closed + assert "100%" in capsys.readouterr().out + assert "-F" in _shell_words(proc.argv[-1]) + + def test_follow_ctrl_c_returns_0_and_stops_remote_tail(self, ssh, capsys): + """Ctrl+C は追従の正常な終わらせ方(0).標準入力を閉じてリモートの tail を止める.""" + ssh.responses.append({"rc": 255, "interrupt": True}) + assert main(["logs", "offaxis", "-f"]) == 0 + assert ssh.calls[0].stdin.closed + assert "ジョブは継続" in capsys.readouterr().err + + +class TestAttach: + def test_ended_job_returns_93_with_last_line(self, ssh, capsys): + ssh.responses.append({"rc": 93, "stdout": b"=== [2026-09-13 12:00:00] exit 0\n"}) + assert main(["attach", "offaxis"]) == 93 + assert len(ssh.calls) == 1 # tmux には接続しない + out = capsys.readouterr().out + assert "既に終了しています" in out + assert "exit 0" in out + + def test_unknown_job_returns_92(self, ssh, capsys): + ssh.responses.append({"rc": 92}) + assert main(["attach", "offaxis"]) == 92 + assert len(ssh.calls) == 1 + assert "offaxis" in capsys.readouterr().err + + def test_missing_remote_dir_returns_90(self, ssh, capsys): + ssh.responses.append({"rc": 90}) + assert main(["attach", "offaxis"]) == 90 + assert len(ssh.calls) == 1 + assert "push" in capsys.readouterr().err + + def test_running_job_attaches_with_tty(self, ssh): + ssh.responses += [{"rc": 0}, {"rc": 0}] + assert main(["attach", "offaxis"]) == 0 + assert len(ssh.calls) == 2 + attach = ssh.calls[1].argv + assert attach[:3] == ["fake-ssh", "-t", "Newton"] + assert shlex.split(attach[-1]) == ["tmux", "attach-session", "-t", "=offaxis"] + + def test_invalid_name(self, ssh): + assert main(["attach", "a b"]) == 1 + assert ssh.calls == [] + + +class TestKill: + def test_success(self, ssh, capsys): + ssh.responses.append({"rc": 0}) + assert main(["kill", "offaxis"]) == 0 + assert "offaxis" in capsys.readouterr().out + assert _has_sequence( + _shell_words(ssh.calls[0].argv[-1]), ["kill-session", "-t", "=offaxis"] + ) + + def test_not_running_returns_93(self, ssh, capsys): + ssh.responses.append({"rc": 93}) + assert main(["kill", "offaxis"]) == 93 + assert "offaxis" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# pull(仕様 4) +# --------------------------------------------------------------------------- + + +class TestPull: + @pytest.mark.parametrize("spec", ["foo", "output/foo", "output\\foo"]) + def test_accepts_all_spellings_and_extracts(self, spec, ssh, tmp_path): + dest = tmp_path / "dest" + ssh.responses.append({"rc": 0, "stdout": _tar_bytes({"output/foo/a.txt": b"hello"})}) + assert main(["pull", spec, "--dest", str(dest)]) == 0 + assert (dest / "output/foo/a.txt").read_bytes() == b"hello" + proc = ssh.calls[0] + assert proc.kwargs["stdin"] == subprocess.DEVNULL + assert _statements(proc.argv[-1])[-1] == ["tar", "-cf", "-", "output/foo"] + + def test_compress(self, ssh, tmp_path): + dest = tmp_path / "dest" + payload = _tar_bytes({"output/foo/前側.npy": b"\x00" * 100}, gz=True) + ssh.responses.append({"rc": 0, "stdout": payload}) + assert main(["pull", "foo", "--compress", "--dest", str(dest)]) == 0 + assert (dest / "output/foo/前側.npy").read_bytes() == b"\x00" * 100 + assert _statements(ssh.calls[0].argv[-1])[-1] == ["tar", "-czf", "-", "output/foo"] + + def test_multiple_paths_are_deduplicated(self, ssh, tmp_path): + payload = _tar_bytes({"output/foo/a": b"1", "output/bar/b": b"2"}) + ssh.responses.append({"rc": 0, "stdout": payload}) + assert main(["pull", "foo", "output/foo", "bar", "--dest", str(tmp_path / "d")]) == 0 + assert _statements(ssh.calls[0].argv[-1])[-1] == [ + "tar", + "-cf", + "-", + "output/foo", + "output/bar", + ] + + @pytest.mark.parametrize("spec", ["../src", "output", "/etc", "C:\\x", "x/../../y"]) + def test_rejects_paths_outside_output(self, spec, ssh, tmp_path, capsys): + assert main(["pull", spec, "--dest", str(tmp_path / "d")]) == 1 + assert ssh.calls == [] + assert "エラー" in capsys.readouterr().err + + def test_unrequested_member_is_rejected(self, ssh, tmp_path, capsys): + """要求したパス以外のメンバーが届いたら展開を中止し,失敗を返すこと.""" + dest = tmp_path / "dest" + payload = _tar_bytes({"output/foo/a.txt": b"ok", "src/sm_tias_lightsim/evil.py": b"x"}) + ssh.responses.append({"rc": 0, "stdout": payload}) + assert main(["pull", "foo", "--dest", str(dest)]) == 1 + assert not (dest / "src").exists() + assert "エラー" in capsys.readouterr().err + + def test_remote_path_missing_returns_92(self, ssh, tmp_path, capsys): + ssh.responses.append({"rc": 92, "stdout": b"", "finished": True}) + assert main(["pull", "nope", "--dest", str(tmp_path / "d")]) == 92 + assert "サーバにありません" in capsys.readouterr().err + + def test_ssh_failure_returns_255(self, ssh, tmp_path, capsys): + ssh.responses.append({"rc": 255, "stdout": b"", "finished": True}) + assert main(["pull", "foo", "--dest", str(tmp_path / "d")]) == 255 + assert "ssh" in capsys.readouterr().err + + def test_tar_warning_rc1_still_succeeds(self, ssh, tmp_path, capsys): + """GNU tar の終了コード 1(読み取り中に変化)は警告にとどめ,取得は成功とする.""" + dest = tmp_path / "dest" + ssh.responses.append({"rc": 1, "stdout": _tar_bytes({"output/foo/a": b"x"})}) + assert main(["pull", "foo", "--dest", str(dest)]) == 0 + assert (dest / "output/foo/a").is_file() + assert "警告" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# push(仕様 1) +# --------------------------------------------------------------------------- + + +def _tar_names(data: bytes) -> list[str]: + if data[:2] == b"\x1f\x8b": + data = gzip.decompress(data) + with tarfile.open(fileobj=io.BytesIO(data), mode="r:", encoding="utf-8") as tar: + assert tar.format == tarfile.PAX_FORMAT + return tar.getnames() + + +class TestPush: + def test_dry_run_lists_without_ssh(self, ssh, capsys, project): + assert main(["push", "--dry-run", "--local-dir", str(project)]) == 0 + assert ssh.calls == [] + out = capsys.readouterr().out + assert "送信対象: 7 ファイル" in out + assert "src/sm_tias_lightsim/a.py" in out + assert ".venv" not in out + assert "output/r" not in out + assert "--dry-run" in out + + def test_windows_path_yaml_is_warned(self, ssh, capsys, project): + assert ( + main(["push", "--dry-run", "--local-dir", str(project), "--include", "configs/_tmp_x"]) + == 0 + ) + out = capsys.readouterr().out + assert "警告" in out + assert "configs/_tmp_x/s.yaml" in out + + def test_no_warning_for_portable_yaml(self, ssh, capsys, project): + assert main(["push", "--dry-run", "--local-dir", str(project)]) == 0 + assert "警告" not in capsys.readouterr().out + + @pytest.mark.parametrize( + "extra", + [ + ["--include", "configs/_tmp_x", "--only", "configs/_tmp_x"], + ["--prune", "--only", "configs/_tmp_x"], + ["--only", "output/r"], + ["--include", "output"], + ["--only", "configs/_tmp_missing"], + ], + ) + def test_invalid_options_exit_1(self, extra, ssh, capsys, project): + assert main(["push", "--local-dir", str(project), *extra]) == 1 + assert ssh.calls == [] + assert "エラー" in capsys.readouterr().err + + def test_streams_tar_to_remote_tar_x(self, ssh, capsys, project): + ssh.responses.append({"rc": 0}) + assert main(["push", "--local-dir", str(project)]) == 0 + proc = ssh.calls[0] + assert proc.argv[:2] == ["fake-ssh", "Newton"] + assert proc.kwargs["stdin"] == subprocess.PIPE + assert proc.stdin.captured is not None + assert set(_tar_names(proc.stdin.captured)) == _EXPECTED_PUSH + words = _shell_words(proc.argv[-1]) + assert _has_sequence(words, ["tar", "-xf", "-", "-C", "$HOME/lab/SmTIAS-LightSim"]) + assert "送信完了" in capsys.readouterr().out + + def test_only_streams_just_the_path(self, ssh, project): + ssh.responses.append({"rc": 0}) + assert main(["push", "--local-dir", str(project), "--only", "configs/_tmp_x"]) == 0 + assert _tar_names(ssh.calls[0].stdin.captured) == ["configs/_tmp_x/s.yaml"] + + def test_compress(self, ssh, project): + ssh.responses.append({"rc": 0}) + assert main(["push", "--local-dir", str(project), "--compress"]) == 0 + proc = ssh.calls[0] + assert proc.stdin.captured[:2] == b"\x1f\x8b" + assert set(_tar_names(proc.stdin.captured)) == _EXPECTED_PUSH + assert "-xzf" in _shell_words(proc.argv[-1]) + + def test_ssh_failure_is_returned(self, ssh, capsys, project): + ssh.responses.append({"rc": 255}) + assert main(["push", "--local-dir", str(project)]) == 255 + assert "ssh" in capsys.readouterr().err + + def test_prune_removes_only_stale_code_files(self, ssh, project): + """--prune はサーバの src/ tests/ scripts/ にあって手元に無いファイルだけを消す.""" + remote_files = [ + "src/sm_tias_lightsim/__init__.py", + "src/sm_tias_lightsim/a.py", + "src/sm_tias_lightsim/old.py", + "src/sm_tias_lightsim/__pycache__/old.cpython-312.pyc", + "tests/test_old.py", + "scripts/s.py", + "scripts/gone.py", + ] + ssh.responses += [ + {"rc": 0}, + {"rc": 0, "stdout": b"".join(f.encode() + b"\0" for f in remote_files)}, + {"rc": 0}, + ] + assert main(["push", "--local-dir", str(project), "--prune"]) == 0 + assert len(ssh.calls) == 3 + listing = _shell_words(ssh.calls[1].argv[-1]) + assert _has_sequence(listing, ["in", "src", "tests", "scripts"]) + assert "output" not in listing + assert "configs" not in listing + remove = ssh.calls[2] + assert _has_sequence(_shell_words(remove.argv[-1]), ["xargs", "-0", "rm", "-f", "--"]) + assert remove.input == ( + b"scripts/gone.py\0src/sm_tias_lightsim/old.py\0tests/test_old.py\0" + ) + + def test_prune_with_nothing_stale(self, ssh, capsys, project): + ssh.responses += [{"rc": 0}, {"rc": 0, "stdout": b"src/sm_tias_lightsim/a.py\0"}] + assert main(["push", "--local-dir", str(project), "--prune"]) == 0 + assert len(ssh.calls) == 2 + assert "削除対象はありません" in capsys.readouterr().out + + def test_prune_dry_run_lists_targets_without_deleting(self, ssh, capsys, project): + ssh.responses.append({"rc": 0, "stdout": b"tests/test_old.py\0"}) + assert main(["push", "--local-dir", str(project), "--prune", "--dry-run"]) == 0 + assert len(ssh.calls) == 1 # 一覧の取得だけ(送信・削除はしない) + assert "tests/test_old.py" in capsys.readouterr().out + + @pytest.mark.parametrize( + "listing", + [ + b"src/x.py\0output/r/luminance.npy\0", + b"src/x.py\0configs/_tmp_x/s.yaml\0", + b"configs/scene.yaml\0output/_remote_logs/a.log\0", + b"src/../output/x\0", + b"/etc/passwd\0", + ], + ) + def test_prune_aborts_cleanly_on_unexpected_remote_paths(self, ssh, capsys, project, listing): + """一覧に output/・configs/ 等のパスが紛れたら,削除コマンドを送らずに中止すること. + + compute_prune_targets が RuntimeError を出す状況でも,トレースバックにせず + 「削除を中止しました」旨を stderr に出して終了コード 1 で終わる. + (main が例外を送出した場合はこのテスト自体がエラーになる=不合格) + """ + ssh.responses += [{"rc": 0}, {"rc": 0, "stdout": listing}, {"rc": 0}] + rc = main(["push", "--local-dir", str(project), "--prune"]) + assert rc == 1 + assert len(ssh.calls) == 2 # 送信と一覧取得だけ.削除(xargs rm)は送らない + assert all("xargs" not in _shell_words(c.argv[-1]) for c in ssh.calls) + assert "削除を中止" in capsys.readouterr().err + + def test_prune_dry_run_aborts_cleanly_on_unexpected_remote_paths(self, ssh, capsys, project): + """--dry-run --prune でも同じく,トレースバックにせず中止を伝えて 1 を返すこと.""" + ssh.responses.append({"rc": 0, "stdout": b"src/x.py\0output/r/luminance.npy\0"}) + rc = main(["push", "--local-dir", str(project), "--prune", "--dry-run"]) + assert rc == 1 + assert len(ssh.calls) == 1 + assert "削除を中止" in capsys.readouterr().err diff --git a/tests/cli/test_render_variant_override.py b/tests/cli/test_render_variant_override.py new file mode 100644 index 0000000..c6c2905 --- /dev/null +++ b/tests/cli/test_render_variant_override.py @@ -0,0 +1,141 @@ +"""cli/render.py の variant 上書き(--variant / SM_RENDER_VARIANT)の仕様ベーステスト. + +仕様出典: +- docs/02_ENV/ENV_04_開発コマンド.md(sm-render の --variant・環境変数 SM_RENDER_VARIANT) +- 実装サマリー 仕様 7: 優先順位は --variant > SM_RENDER_VARIANT > YAML の render.variant. + 上書き時に「variant を上書き: A → B」を表示する +- 実装サマリー 仕様 8: SM_DRJIT_THREADS の不正値の扱い + +テスト方針: +- 軽量テストでは render_scene を差し替えて「どの variant で呼ばれたか」だけを確かめる + (variant は runner 内でプロセスに 1 度しか設定できないため,実レンダでは検証できない) +- 実レンダを伴う確認は slow に 1 本置く +""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +from sm_tias_lightsim.cli.render import main +from sm_tias_lightsim.scene.schema import load_scene_yaml + +MINIMAL_YAML = Path(__file__).parent.parent / "fixtures" / "minimal.yaml" +YAML_VARIANT = "llvm_ad_rgb" # minimal.yaml の render.variant + + +@pytest.fixture +def render_calls(monkeypatch): + """render_scene を差し替え,渡された SceneConfig を記録する.""" + import sm_tias_lightsim.render.runner as runner + import sm_tias_lightsim.runtime.llvm_init as llvm_init + + calls: list[dict] = [] + + def fake_render_scene(config, project_root=None): + calls.append({"config": config, "project_root": project_root}) + return np.full((4, 4, 3), 0.5, dtype=np.float32) + + monkeypatch.setattr(runner, "render_scene", fake_render_scene) + monkeypatch.setattr(llvm_init, "ensure_drjit_libllvm_path", lambda *a, **k: None) + monkeypatch.delenv("SM_RENDER_VARIANT", raising=False) + return calls + + +def _run(tmp_path: Path, *extra: str) -> int: + return main(["--scene", str(MINIMAL_YAML), "--output", str(tmp_path / "out"), *extra]) + + +def test_fixture_variant_precondition(): + assert load_scene_yaml(MINIMAL_YAML).render.variant == YAML_VARIANT + + +def test_yaml_variant_is_used_without_override(tmp_path, render_calls, capsys): + assert _run(tmp_path) == 0 + assert render_calls[0]["config"].render.variant == YAML_VARIANT + assert "variant を上書き" not in capsys.readouterr().out + assert (tmp_path / "out" / "render.png").is_file() + assert (tmp_path / "out" / "luminance.npy").is_file() + + +def test_env_overrides_yaml(tmp_path, render_calls, capsys, monkeypatch): + monkeypatch.setenv("SM_RENDER_VARIANT", "scalar_rgb") + assert _run(tmp_path) == 0 + assert render_calls[0]["config"].render.variant == "scalar_rgb" + assert f"variant を上書き: {YAML_VARIANT} → scalar_rgb" in capsys.readouterr().out + + +def test_cli_overrides_yaml(tmp_path, render_calls, capsys): + assert _run(tmp_path, "--variant", "cuda_ad_rgb") == 0 + assert render_calls[0]["config"].render.variant == "cuda_ad_rgb" + assert f"variant を上書き: {YAML_VARIANT} → cuda_ad_rgb" in capsys.readouterr().out + + +def test_cli_overrides_env(tmp_path, render_calls, capsys, monkeypatch): + monkeypatch.setenv("SM_RENDER_VARIANT", "scalar_rgb") + assert _run(tmp_path, "--variant", "cuda_ad_rgb") == 0 + assert render_calls[0]["config"].render.variant == "cuda_ad_rgb" + assert f"variant を上書き: {YAML_VARIANT} → cuda_ad_rgb" in capsys.readouterr().out + + +@pytest.mark.parametrize("blank", ["", " "]) +def test_blank_env_is_ignored(tmp_path, render_calls, capsys, monkeypatch, blank): + monkeypatch.setenv("SM_RENDER_VARIANT", blank) + assert _run(tmp_path) == 0 + assert render_calls[0]["config"].render.variant == YAML_VARIANT + assert "variant を上書き" not in capsys.readouterr().out + + +def test_same_variant_is_not_reported_as_override(tmp_path, render_calls, capsys): + assert _run(tmp_path, "--variant", YAML_VARIANT) == 0 + assert render_calls[0]["config"].render.variant == YAML_VARIANT + assert "variant を上書き" not in capsys.readouterr().out + + +def test_override_keeps_other_settings(tmp_path, render_calls): + """上書きするのは variant だけ(spp・max_depth・シーン内容は YAML のまま).""" + assert _run(tmp_path, "--variant", "cuda_ad_rgb") == 0 + original = load_scene_yaml(MINIMAL_YAML) + config = render_calls[0]["config"] + assert config.render.model_dump(exclude={"variant"}) == original.render.model_dump( + exclude={"variant"} + ) + assert config.model_dump(exclude={"render"}) == original.model_dump(exclude={"render"}) + assert render_calls[0]["project_root"] == MINIMAL_YAML.resolve().parent + + +def test_yaml_file_is_not_modified(tmp_path, render_calls, monkeypatch): + before = MINIMAL_YAML.read_bytes() + monkeypatch.setenv("SM_RENDER_VARIANT", "scalar_rgb") + assert _run(tmp_path, "--variant", "cuda_ad_rgb") == 0 + assert MINIMAL_YAML.read_bytes() == before + + +def test_help_mentions_variant(capsys): + with pytest.raises(SystemExit) as exc: + main(["--help"]) + assert exc.value.code == 0 + out = capsys.readouterr().out + assert "--variant" in out + assert "SM_RENDER_VARIANT" in out + + +def test_invalid_thread_env_fails_render_with_message(tmp_path, capsys, monkeypatch): + """SM_DRJIT_THREADS が不正なら(黙って全コアで回さず)レンダ失敗として 1 を返すこと.""" + monkeypatch.delenv("SM_RENDER_VARIANT", raising=False) + monkeypatch.setenv("SM_DRJIT_THREADS", "abc") + assert _run(tmp_path, "--variant", "scalar_rgb") == 1 + assert "SM_DRJIT_THREADS" in capsys.readouterr().err + + +@pytest.mark.slow +def test_end_to_end_with_variant_option(tmp_path, capsys, monkeypatch): + """--variant 付きでも実レンダが完走し,PNG と luminance.npy が出ること.""" + monkeypatch.delenv("SM_RENDER_VARIANT", raising=False) + monkeypatch.delenv("SM_DRJIT_THREADS", raising=False) + assert _run(tmp_path, "--variant", "scalar_rgb") == 0 + assert (tmp_path / "out" / "render.png").is_file() + assert (tmp_path / "out" / "luminance.npy").is_file() + assert f"variant を上書き: {YAML_VARIANT} → scalar_rgb" in capsys.readouterr().out diff --git a/tests/remote/__init__.py b/tests/remote/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/remote/__init__.py diff --git a/tests/remote/test_commands.py b/tests/remote/test_commands.py new file mode 100644 index 0000000..29f5572 --- /dev/null +++ b/tests/remote/test_commands.py @@ -0,0 +1,638 @@ +"""remote/commands.py の仕様ベーステスト. + +仕様出典: +- docs/02_ENV/ENV_04_開発コマンド.md「研究室サーバでの実行」 +- docs/02_ENV/ENV_02_環境構築手順.md「研究室サーバ編」 +- 実装サマリー(sm-remote 仕様 1〜5) + +テスト方針: +- ssh・tmux・実サーバには依存しない(コマンド文字列の組み立てだけを検証する) +- 「リモートの POSIX シェルがどう単語分割するか」を shlex(posix モード)で再現し, + ユーザー由来の値(パス・コマンド引数・環境変数値)が 1 語のまま届くことを確かめる +""" + +from __future__ import annotations + +import shlex + +import pytest + +from sm_tias_lightsim.remote import commands as cmd + +REMOTE_DIR = "~/lab/SmTIAS-LightSim" +REMOTE_DIR_EXPANDED = "$HOME/lab/SmTIAS-LightSim" +UV = "~/.local/bin/uv" + + +# --------------------------------------------------------------------------- +# ヘルパ(シェルの単語分割の再現) +# --------------------------------------------------------------------------- + + +def _shell_words(text: str) -> list[str]: + """POSIX シェル相当の単語分割(; & | は区切り記号として別トークンにする).""" + lex = shlex.shlex(text, posix=True, punctuation_chars=";&|") + lex.whitespace_split = True + return list(lex) + + +def _statements(text: str) -> list[list[str]]: + """`;` で区切った文ごとの単語リストを返す(引用符内の ; は区切らない).""" + result: list[list[str]] = [[]] + for word in _shell_words(text): + if word == ";": + result.append([]) + else: + result[-1].append(word) + return [s for s in result if s] + + +def _has_sequence(words: list[str], seq: list[str]) -> bool: + """words に seq が連続して現れるか.""" + n = len(seq) + return any(words[i : i + n] == seq for i in range(len(words) - n + 1)) + + +def _index_of_sequence(words: list[str], seq: list[str]) -> int: + n = len(seq) + for i in range(len(words) - n + 1): + if words[i : i + n] == seq: + return i + raise AssertionError(f"{seq} が見つかりません: {words}") + + +def _job_script(**overrides) -> str: + kwargs = { + "remote_dir": REMOTE_DIR, + "name": "offaxis", + "command": ["python", "scripts/sweep_offaxis_led.py"], + "uv_path": UV, + "use_uv": True, + "env": {"PYTHONUNBUFFERED": "1", "SM_RENDER_VARIANT": "cuda_ad_rgb"}, + } + kwargs.update(overrides) + return cmd.build_job_script( + kwargs["remote_dir"], + kwargs["name"], + kwargs["command"], + uv_path=kwargs["uv_path"], + use_uv=kwargs["use_uv"], + env=kwargs["env"], + ) + + +def _run_statement(script: str) -> list[str]: + """ジョブ本体(標準入力を /dev/null にしてコマンドを実行する文)を返す.""" + candidates = [s for s in _statements(script) if "/dev/null" in s and "tee" in s] + assert len(candidates) == 1, f"ジョブ本体の文が 1 つに特定できません: {candidates}" + return candidates[0] + + +# --------------------------------------------------------------------------- +# 終了コード(仕様 2・3) +# --------------------------------------------------------------------------- + + +def test_exit_codes_match_spec(): + """リモートスクリプトの終了コードが仕様の値であること(90/91/92/93).""" + assert cmd.EXIT_REMOTE_DIR_MISSING == 90 + assert cmd.EXIT_SESSION_EXISTS == 91 + assert cmd.EXIT_NOT_FOUND == 92 + assert cmd.EXIT_NO_SESSION == 93 + + +def test_log_dir_is_under_output(): + """ジョブログの置き場所が output/_remote_logs であること(仕様 2).""" + assert cmd.REMOTE_LOG_DIR == "output/_remote_logs" + + +# --------------------------------------------------------------------------- +# quote_remote_path(仕様 5: ~ はリモートで展開させる) +# --------------------------------------------------------------------------- + + +class TestQuoteRemotePath: + def test_tilde_path_expands_home_on_remote(self): + """`~/...` は `"$HOME"/...` になり,リモートのシェルで $HOME が展開される形であること.""" + quoted = cmd.quote_remote_path(REMOTE_DIR) + assert quoted.startswith('"$HOME"/') + assert shlex.split(quoted) == [REMOTE_DIR_EXPANDED] + + @pytest.mark.parametrize("path", ["~", "~/"]) + def test_bare_tilde_is_home(self, path): + assert cmd.quote_remote_path(path) == '"$HOME"' + + def test_tilde_remainder_is_quoted_against_expansion(self): + """`~/` 以降はシェルに解釈させない($(...) 等を含んでもそのまま 1 語).""" + quoted = cmd.quote_remote_path("~/$(id)") + assert quoted == '"$HOME"/' + shlex.quote("$(id)") + + @pytest.mark.parametrize( + ("path", "expected"), + [ + ("~/my dir/it's", "$HOME/my dir/it's"), + ("~/x; rm -rf /", "$HOME/x; rm -rf /"), + ("/srv/data dir/proj", "/srv/data dir/proj"), + ("lab/proj", "lab/proj"), + ], + ) + def test_special_characters_stay_single_word(self, path, expected): + """空白・引用符・; を含むパスもシェルで 1 語として扱われること.""" + assert shlex.split(cmd.quote_remote_path(path)) == [expected] + + @pytest.mark.parametrize("path", ["", " "]) + def test_empty_path_raises(self, path): + with pytest.raises(ValueError): + cmd.quote_remote_path(path) + + def test_tilde_user_form_is_rejected(self): + """`~ユーザー名/...` 形式は対応しない(ValueError).""" + with pytest.raises(ValueError): + cmd.quote_remote_path("~other/lab") + + +# --------------------------------------------------------------------------- +# 入力の検証(仕様 2: ジョブ名と GPU 指定を検証する) +# --------------------------------------------------------------------------- + + +class TestValidateHost: + @pytest.mark.parametrize("host", ["Newton", "Newton.local", "192.168.23.13", "user@host"]) + def test_valid_hosts_pass_through(self, host): + assert cmd.validate_host(host) == host + + @pytest.mark.parametrize( + "host", ["", "-oProxyCommand=calc", "-t", "New ton", "Newton\n", "\tNewton"] + ) + def test_invalid_hosts_raise(self, host): + """空・`-` 始まり(ssh のオプション注入)・空白入りは拒否すること.""" + with pytest.raises(ValueError): + cmd.validate_host(host) + + +class TestValidateJobName: + @pytest.mark.parametrize( + "name", ["offaxis", "r100", "pytest-all", "job-20260913-120000", "A_b-9", "x" * 64] + ) + def test_valid_names(self, name): + assert cmd.validate_job_name(name) == name + + @pytest.mark.parametrize( + "name", + [ + "", + "-job", # オプションと紛らわしい + "_job", + "a.b", # tmux のセッション名に使えない + "a:b", + "a b", + "a;b", + "a/b", + "../x", + "$(id)", + "ジョブ", + "x" * 65, # 64 文字超 + ], + ) + def test_invalid_names_raise(self, name): + with pytest.raises(ValueError): + cmd.validate_job_name(name) + + +class TestValidateGpuSpec: + @pytest.mark.parametrize("spec", ["0", "1", "0,2", "0,1,2", "10"]) + def test_valid_specs(self, spec): + assert cmd.validate_gpu_spec(spec) == spec + + @pytest.mark.parametrize("spec", ["", "a", "0,", ",0", "0, 1", "-1", "0;rm -rf ~", "0 1"]) + def test_invalid_specs_raise(self, spec): + with pytest.raises(ValueError): + cmd.validate_gpu_spec(spec) + + +class TestParseEnvAssignment: + @pytest.mark.parametrize( + ("text", "expected"), + [ + ("KEY=VALUE", ("KEY", "VALUE")), + ("KEY=", ("KEY", "")), + ("KEY=a=b", ("KEY", "a=b")), + ("_K1=with space", ("_K1", "with space")), + ], + ) + def test_valid_assignments(self, text, expected): + assert cmd.parse_env_assignment(text) == expected + + @pytest.mark.parametrize("text", ["KEY", "=v", "1KEY=v", "K-1=v", "K 1=v", ""]) + def test_invalid_assignments_raise(self, text): + with pytest.raises(ValueError): + cmd.parse_env_assignment(text) + + +# --------------------------------------------------------------------------- +# build_ssh_argv +# --------------------------------------------------------------------------- + + +class TestBuildSshArgv: + def test_remote_command_is_single_argument(self): + argv = cmd.build_ssh_argv("ssh", "Newton", "echo a b; ls") + assert argv == ["ssh", "Newton", "echo a b; ls"] + + def test_tty_adds_t_option_before_host(self): + argv = cmd.build_ssh_argv("ssh", "Newton", "tmux attach", tty=True) + assert argv == ["ssh", "-t", "Newton", "tmux attach"] + + def test_invalid_host_raises(self): + with pytest.raises(ValueError): + cmd.build_ssh_argv("ssh", "-oProxyCommand=calc", "echo") + + +# --------------------------------------------------------------------------- +# push(仕様 1) +# --------------------------------------------------------------------------- + + +class TestBuildPushCommand: + def test_extracts_stdin_tar_into_remote_dir(self): + """標準入力の tar をリモートのプロジェクトルートへ展開すること.""" + statements = _statements(cmd.build_push_command(REMOTE_DIR, compress=False)) + last = statements[-1] + assert _has_sequence(last, ["mkdir", "-p", REMOTE_DIR_EXPANDED]) + assert _has_sequence(last, ["tar", "-xf", "-", "-C", REMOTE_DIR_EXPANDED]) + + def test_compress_uses_gzip_extraction(self): + words = _shell_words(cmd.build_push_command(REMOTE_DIR, compress=True)) + assert _has_sequence(words, ["tar", "-xzf", "-", "-C", REMOTE_DIR_EXPANDED]) + + def test_tar_is_the_last_command(self): + """ssh の終了コード=tar の終了コードになるよう tar が最後に実行されること.""" + last = _statements(cmd.build_push_command(REMOTE_DIR, compress=False))[-1] + assert last[last.index("tar") :] == ["tar", "-xf", "-", "-C", REMOTE_DIR_EXPANDED] + + def test_warns_when_tmux_sessions_are_running(self): + """ジョブ実行中の push に警告を出すため tmux ls を確認すること(ENV_04 注意).""" + words = _shell_words(cmd.build_push_command(REMOTE_DIR, compress=False)) + assert _has_sequence(words, ["tmux", "ls"]) + + def test_remote_dir_with_space_is_single_word(self): + words = _shell_words(cmd.build_push_command("~/my lab/proj", compress=False)) + assert _has_sequence(words, ["-C", "$HOME/my lab/proj"]) + + +class TestBuildListAndRemoveCommands: + def test_list_targets_only_given_dirs(self): + """--prune の一覧は指定ディレクトリ(src/tests/scripts)だけを対象にすること.""" + text = cmd.build_list_files_command( + REMOTE_DIR, ["src", "tests", "scripts"], ["__pycache__", ".venv"] + ) + words = _shell_words(text) + assert _has_sequence(words, ["in", "src", "tests", "scripts"]) + assert "output" not in words + assert "configs" not in words + assert "-print0" in words + + def test_list_skips_cache_dirs(self): + words = _shell_words( + cmd.build_list_files_command(REMOTE_DIR, ["src"], ["__pycache__", ".pytest_cache"]) + ) + assert _has_sequence(words, ["-name", "__pycache__"]) + assert _has_sequence(words, ["-name", ".pytest_cache"]) + assert "-prune" in words + + def test_list_exits_90_when_remote_dir_missing(self): + words = _shell_words(cmd.build_list_files_command(REMOTE_DIR, ["src"], ["__pycache__"])) + assert words[:2] == ["cd", REMOTE_DIR_EXPANDED] + assert _has_sequence(words, ["exit", "90"]) + + def test_remove_reads_nul_separated_paths(self): + words = _shell_words(cmd.build_remove_files_command(REMOTE_DIR)) + assert words[:2] == ["cd", REMOTE_DIR_EXPANDED] + assert _has_sequence(words, ["exit", "90"]) + assert _has_sequence(words, ["xargs", "-0", "rm", "-f", "--"]) + + +# --------------------------------------------------------------------------- +# run(仕様 2) +# --------------------------------------------------------------------------- + + +class TestBuildJobScript: + def test_runs_in_remote_project_root(self): + first = _statements(_job_script())[0] + assert first[:2] == ["cd", REMOTE_DIR_EXPANDED] + assert _has_sequence(_shell_words(_job_script()), ["exit", "90"]) + + def test_log_file_is_named_after_job(self): + """ログは output/_remote_logs/.log であること.""" + statements = _statements(_job_script(name="r100")) + assert ["log=output/_remote_logs/r100.log"] in statements + + def test_writes_start_and_exit_lines_to_log(self): + """開始行と `exit <終了コード>` 行をログに追記すること.""" + statements = _statements(_job_script()) + start = [s for s in statements if s[0] == "printf" and "start" in s[1]] + exit_line = [s for s in statements if s[0] == "printf" and "exit" in s[1]] + assert len(start) == 1 + assert len(exit_line) == 1 + assert start[0][-2:] == [">>", "$log"] + assert exit_line[0][-2:] == [">>", "$log"] + assert "$rc" in exit_line[0] + # 開始行 → ジョブ本体 → 終了コード行 の順 + run_idx = statements.index(_run_statement(_job_script())) + assert statements.index(start[0]) < run_idx < statements.index(exit_line[0]) + + def test_start_line_shows_the_command(self): + statements = _statements(_job_script(command=["sm-render", "--scene", "a.yaml"])) + start = next(s for s in statements if s[0] == "printf" and "start" in s[1]) + assert "sm-render --scene a.yaml" in start[-3] + + def test_exit_code_of_command_is_propagated(self): + """パイプ(tee)越しでもコマンド自身の終了コードで終わること.""" + statements = _statements(_job_script()) + assert ["rc=${PIPESTATUS[0]}"] in statements + assert statements[-1] == ["exit", "$rc"] + + def test_output_is_also_tee_to_log(self): + run = _run_statement(_job_script()) + assert _has_sequence(run, ["tee", "-a", "$log"]) + + def test_stdin_is_dev_null(self): + """入力待ちで止まらないよう標準入力を /dev/null にすること.""" + run = _run_statement(_job_script()) + assert _has_sequence(run, ["<", "/dev/null"]) + + def test_runs_command_with_uv_run(self): + run = _run_statement(_job_script(command=["python", "scripts/sweep_offaxis_led.py"])) + assert run[:4] == ["$HOME/.local/bin/uv", "run", "python", "scripts/sweep_offaxis_led.py"] + + def test_no_uv_runs_command_directly(self): + run = _run_statement(_job_script(use_uv=False, command=["nvidia-smi"])) + assert run[0] == "nvidia-smi" + assert "run" not in run[:2] + + def test_arguments_are_quoted_and_survive_intact(self): + """引数は shlex で正しくクォートされ,リモートのシェルでそのまま 1 語ずつ届くこと.""" + command = [ + "python", + "-c", + "import sys; print('a b', \"$HOME\")", + "--label=two words", + "it's", + "$(rm -rf ~)", + "`id`", + "a|b&c", + "", + "日本語 引数", + ] + run = _run_statement(_job_script(command=command)) + assert run[: 2 + len(command)] == ["$HOME/.local/bin/uv", "run", *command] + + def test_uv_directory_is_added_to_path(self): + """スクリプトが `uv run sm-render` を名前で呼べるよう uv のディレクトリを PATH に足す.""" + statements = _statements(_job_script()) + path_exports = [s for s in statements if s[0] == "export" and s[1].startswith("PATH=")] + assert len(path_exports) == 1 + value = path_exports[0][1] + assert "dirname" in value + assert ".local/bin/uv" in value + assert value.endswith(":$PATH") + + def test_custom_uv_path_is_used(self): + run = _run_statement(_job_script(uv_path="/opt/uv tools/uv")) + assert run[:2] == ["/opt/uv tools/uv", "run"] + + def test_env_values_are_exported_and_quoted(self): + env = { + "PYTHONUNBUFFERED": "1", + "SM_RENDER_VARIANT": "cuda_ad_rgb", + "CUDA_VISIBLE_DEVICES": "0,2", + "WEIRD": "a b $(id); x", + } + statements = _statements(_job_script(env=env)) + for key, value in env.items(): + assert ["export", f"{key}={value}"] in statements + + def test_env_is_exported_before_the_command(self): + statements = _statements(_job_script(env={"SM_DRJIT_THREADS": "16"})) + export_idx = statements.index(["export", "SM_DRJIT_THREADS=16"]) + assert export_idx < statements.index(_run_statement(_job_script(env={"X": "1"}))) + + def test_empty_command_raises(self): + with pytest.raises(ValueError): + _job_script(command=[]) + + @pytest.mark.parametrize("name", ["a b", "a.b", "-x", ""]) + def test_invalid_job_name_raises(self, name): + with pytest.raises(ValueError): + _job_script(name=name) + + @pytest.mark.parametrize("key", ["1X", "A-B", "A B", "X;rm", ""]) + def test_invalid_env_key_raises(self, key): + with pytest.raises(ValueError): + _job_script(env={key: "1"}) + + +def _tmux_new_session(run_command: str) -> list[str]: + words = _shell_words(run_command) + start = words.index("new-session") + stmt = words[start:] + return stmt[: stmt.index(";")] if ";" in stmt else stmt + + +class TestBuildRunCommand: + def test_job_script_survives_nested_quoting(self): + """tmux → bash -c と 2 段に渡してもジョブ本体が 1 文字も崩れないこと.""" + script = _job_script(command=["python", "-c", "print('x; y')", "$(id)"]) + stmt = _tmux_new_session(cmd.build_run_command(REMOTE_DIR, "offaxis", script)) + assert shlex.split(stmt[-1]) == ["bash", "-c", script] + + def test_starts_detached_session_named_after_job(self): + script = _job_script() + stmt = _tmux_new_session(cmd.build_run_command(REMOTE_DIR, "offaxis", script)) + assert stmt[:4] == ["new-session", "-d", "-s", "offaxis"] + assert _has_sequence(stmt, ["-c", "$PWD"]) + + def test_existing_session_exits_91(self): + """同名セッションが実行中なら起動せず 91 で終わること(tmux の完全一致指定 =NAME).""" + words = _shell_words(cmd.build_run_command(REMOTE_DIR, "off", _job_script(name="off"))) + check = _index_of_sequence(words, ["has-session", "-t", "=off"]) + exit91 = _index_of_sequence(words, ["exit", "91"]) + new = words.index("new-session") + assert check < exit91 < new + + def test_creates_log_dir_and_enters_remote_dir(self): + statements = _statements(cmd.build_run_command(REMOTE_DIR, "offaxis", _job_script())) + assert statements[0][:2] == ["cd", REMOTE_DIR_EXPANDED] + assert any(_has_sequence(s, ["mkdir", "-p", "output/_remote_logs"]) for s in statements) + + @pytest.mark.parametrize("name", ["a b", "a:b", "-t"]) + def test_invalid_name_raises(self, name): + with pytest.raises(ValueError): + cmd.build_run_command(REMOTE_DIR, name, "echo") + + +# --------------------------------------------------------------------------- +# status / logs / attach / kill(仕様 3) +# --------------------------------------------------------------------------- + + +class TestBuildStatusCommand: + def test_shows_sessions_logs_gpu_and_load(self): + words = _shell_words(cmd.build_status_command(REMOTE_DIR)) + assert words[:2] == ["cd", REMOTE_DIR_EXPANDED] + assert _has_sequence(words, ["exit", "90"]) + assert _has_sequence(words, ["tmux", "ls"]) + assert any("output/_remote_logs/*.log" in w for w in words) + assert "nvidia-smi" in words + assert "uptime" in words + + +class TestBuildLogsCommand: + def test_tail_last_n_lines(self): + statements = _statements(cmd.build_logs_command(REMOTE_DIR, "job1", lines=7, follow=False)) + assert statements[-1] == ["tail", "-n", "7", "output/_remote_logs/job1.log"] + + def test_missing_log_exits_92(self): + words = _shell_words(cmd.build_logs_command(REMOTE_DIR, "job1", lines=40, follow=False)) + assert _has_sequence(words, ["exit", "92"]) + assert _has_sequence(words, ["exit", "90"]) + + def test_follow_stops_tail_when_stdin_closes(self): + """-f は tail -F で追従し,手元が切れて標準入力が閉じたら tail を止めること.""" + words = _shell_words(cmd.build_logs_command(REMOTE_DIR, "job1", lines=5, follow=True)) + assert _has_sequence(words, ["tail", "-n", "5", "-F", "output/_remote_logs/job1.log"]) + cat = _index_of_sequence(words, ["cat", ">/dev/null"]) + kill = words.index("kill") + assert cat < kill + + def test_invalid_name_raises(self): + with pytest.raises(ValueError): + cmd.build_logs_command(REMOTE_DIR, "../etc/passwd", lines=1, follow=False) + + +class TestAttachCommands: + def test_attach_uses_exact_session_match(self): + assert shlex.split(cmd.build_attach_command("offaxis")) == [ + "tmux", + "attach-session", + "-t", + "=offaxis", + ] + + def test_attach_invalid_name_raises(self): + with pytest.raises(ValueError): + cmd.build_attach_command("a;b") + + def test_check_exit_codes_in_order(self): + """セッションあり→0,無くてログあり→93,ログも無し→92,ディレクトリ無し→90.""" + words = _shell_words(cmd.build_attach_check_command(REMOTE_DIR, "offaxis")) + has = _index_of_sequence(words, ["has-session", "-t", "=offaxis"]) + exit0 = _index_of_sequence(words, ["exit", "0"]) + cd = _index_of_sequence(words, ["cd", REMOTE_DIR_EXPANDED]) + exit90 = _index_of_sequence(words, ["exit", "90"]) + log_check = _index_of_sequence(words, ["-f", "output/_remote_logs/offaxis.log"]) + exit93 = _index_of_sequence(words, ["exit", "93"]) + exit92 = _index_of_sequence(words, ["exit", "92"]) + assert has < exit0 < cd < exit90 < log_check < exit93 < exit92 + + def test_check_prints_last_log_line_when_ended(self): + words = _shell_words(cmd.build_attach_check_command(REMOTE_DIR, "offaxis")) + assert _has_sequence(words, ["tail", "-n", "1", "output/_remote_logs/offaxis.log"]) + + +class TestBuildKillCommand: + def test_missing_session_exits_93(self): + words = _shell_words(cmd.build_kill_command(REMOTE_DIR, "offaxis")) + has = _index_of_sequence(words, ["has-session", "-t", "=offaxis"]) + assert has < _index_of_sequence(words, ["exit", "93"]) + + def test_appends_note_to_log_then_kills_session(self): + words = _shell_words(cmd.build_kill_command(REMOTE_DIR, "offaxis")) + append = _index_of_sequence(words, [">>", "output/_remote_logs/offaxis.log"]) + kill = _index_of_sequence(words, ["kill-session", "-t", "=offaxis"]) + assert append < kill + assert any("killed" in w for w in words) + + def test_invalid_name_raises(self): + with pytest.raises(ValueError): + cmd.build_kill_command(REMOTE_DIR, "a b") + + +# --------------------------------------------------------------------------- +# pull(仕様 4) +# --------------------------------------------------------------------------- + + +class TestNormalizeOutputSubpath: + @pytest.mark.parametrize( + "text", + [ + "offaxis_led_sweep", + "output/offaxis_led_sweep", + "output\\offaxis_led_sweep", + "./offaxis_led_sweep", + "offaxis_led_sweep/", + " offaxis_led_sweep ", + "output//offaxis_led_sweep", + ], + ) + def test_accepts_all_spellings(self, text): + """`foo` / `output/foo` / `output\\foo` のどれでも output/foo になること.""" + assert cmd.normalize_output_subpath(text) == "output/offaxis_led_sweep" + + def test_nested_path(self): + assert cmd.normalize_output_subpath("sweep\\r100\\figures") == "output/sweep/r100/figures" + assert cmd.normalize_output_subpath("_remote_logs") == "output/_remote_logs" + + def test_similar_prefix_is_not_stripped(self): + """`outputs/...` は output/ と見なさず,output/ 配下のサブパスとして扱う.""" + assert cmd.normalize_output_subpath("outputs/x") == "output/outputs/x" + + @pytest.mark.parametrize( + "text", + [ + "", + " ", + "/etc/passwd", + "/output/x", + "C:\\Users\\x", + "c:/x", + "C:x", + "../x", + "x/../../y", + "output/../src", + "..\\src", + "output", + "output/", + "output\\", + ".", + "./", + ], + ) + def test_rejects_outside_output_and_whole_output(self, text): + """output/ の外・絶対パス・`..`・output/ 全体は拒否すること.""" + with pytest.raises(ValueError): + cmd.normalize_output_subpath(text) + + +class TestBuildPullCommand: + def test_tars_requested_paths_to_stdout(self): + statements = _statements( + cmd.build_pull_command(REMOTE_DIR, ["output/a", "output/b c"], compress=False) + ) + assert statements[0][:2] == ["cd", REMOTE_DIR_EXPANDED] + assert statements[-1] == ["tar", "-cf", "-", "output/a", "output/b c"] + + def test_compress_uses_gzip(self): + last = _statements(cmd.build_pull_command(REMOTE_DIR, ["output/a"], compress=True))[-1] + assert last == ["tar", "-czf", "-", "output/a"] + + def test_missing_path_exits_92_before_tar(self): + words = _shell_words(cmd.build_pull_command(REMOTE_DIR, ["output/a"], compress=False)) + assert _index_of_sequence(words, ["exit", "92"]) < words.index("tar") + assert _has_sequence(words, ["exit", "90"]) + + def test_empty_paths_raise(self): + with pytest.raises(ValueError): + cmd.build_pull_command(REMOTE_DIR, [], compress=False) diff --git a/tests/remote/test_files.py b/tests/remote/test_files.py new file mode 100644 index 0000000..2f58e4c --- /dev/null +++ b/tests/remote/test_files.py @@ -0,0 +1,382 @@ +"""remote/files.py の仕様ベーステスト(push で送るファイルの選定). + +仕様出典: +- docs/02_ENV/ENV_04_開発コマンド.md「研究室サーバでの実行」 +- docs/02_ENV/ENV_02_環境構築手順.md「コードの転送」(送らないもの / 送るもの) +- 実装サマリー 仕様 1(除外規則・output/ の拒否・Windows 絶対パスの警告・--prune の対象) + +テスト方針: +- tmp_path に小さな疑似プロジェクトを実ファイルで作る(ファイル I/O はモックしない) +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from sm_tias_lightsim.remote import files + +# 疑似プロジェクトのファイル構成(arcname → 内容) +_PROJECT_FILES: dict[str, str] = { + "pyproject.toml": "[project]\nname = 'x'\n", + "src/sm_tias_lightsim/__init__.py": "", + "src/sm_tias_lightsim/remote/commands.py": "x = 1\n", + "src/sm_tias_lightsim/__pycache__/__init__.cpython-312.pyc": "pyc", + "src/sm_tias_lightsim/output/keep.py": "# 直下以外の output は送る\n", + "tests/test_a.py": "def test_a(): pass\n", + "tests/.pytest_cache/v/cache": "c", + "scripts/run.py": "print(1)\n", + ".venv/Scripts/python.exe": "exe", + ".git/HEAD": "ref: refs/heads/main\n", + ".ruff_cache/0.1/x": "r", + ".pytest_cache/README.md": "p", + "output/offaxis/luminance.npy": "npy", + "output/_remote_logs/a.log": "log", + "configs/scene.yaml": "name: a\n", + "configs/meshes/前側.stl": "solid 前側\n", + "configs/_tmp_offaxis/scene_cube_L120.yaml": "name: b\n", + "configs/_tmp_offaxis/meshes/立方体_L120.stl": "solid\n", + "configs/_tmp_offaxis/__pycache__/z.pyc": "pyc", + "configs/_tmp_fn_full_base.yaml": "name: c\n", + "lightsim/luminance_maps/a.npy": "npy", + "reference_measurement/r.png": "png", + "REFERENCE/paper.pdf": "pdf", + "docs/a.md": "# a\n", +} + +# 既定の push で送られるべきもの(ENV_02「送らないもの」を除いた全体) +_EXPECTED_BASE = { + "pyproject.toml", + "src/sm_tias_lightsim/__init__.py", + "src/sm_tias_lightsim/remote/commands.py", + "src/sm_tias_lightsim/output/keep.py", + "tests/test_a.py", + "scripts/run.py", + "configs/scene.yaml", + "configs/meshes/前側.stl", + "lightsim/luminance_maps/a.npy", + "reference_measurement/r.png", + "REFERENCE/paper.pdf", + "docs/a.md", +} + + +@pytest.fixture +def project(tmp_path: Path) -> Path: + root = tmp_path / "proj" + for rel, content in _PROJECT_FILES.items(): + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return root + + +def _arcnames(entries) -> list[str]: + return [e.arcname for e in entries] + + +# --------------------------------------------------------------------------- +# is_excluded +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "rel", + [ + ".venv", + ".venv/Scripts/python.exe", + ".git/HEAD", + "__pycache__/x.pyc", + "src/sm_tias_lightsim/__pycache__/a.pyc", + "tests/.pytest_cache/v", + "scripts/.ruff_cache/x", + "scripts/sub/.venv/lib/x.py", # どの階層でも除外 + "src/a/.git/config", + "output", + "output/offaxis/luminance.npy", + "configs/_tmp_offaxis", + "configs/_tmp_offaxis/scene.yaml", + "configs/_tmp_offaxis/meshes/x.stl", + "configs/_tmp_fn_full_base.yaml", # ファイルでも除外 + "configs/_tmp_", + ], +) +def test_is_excluded_true(rel): + assert files.is_excluded(rel) is True + + +@pytest.mark.parametrize( + "rel", + [ + "", + "pyproject.toml", + "src/sm_tias_lightsim/cli/remote.py", + "src/sm_tias_lightsim/output/keep.py", # 直下以外の output は送る + "tests/output/x.py", + "outputs/x", + "output_old/x", + "configs/scene.yaml", + "configs/meshes/前側.stl", + "configs/sub/_tmp_x.yaml", # configs 直下の _tmp_* だけが対象 + "configs/tmp_x.yaml", + "lightsim/luminance_maps/a.npy", + "reference_measurement/r.png", + "REFERENCE/paper.pdf", + ".gitignore", + ".github/workflows/ci.yml", + "venv/x", + ], +) +def test_is_excluded_false(rel): + assert files.is_excluded(rel) is False + + +# --------------------------------------------------------------------------- +# collect_push_entries +# --------------------------------------------------------------------------- + + +class TestCollectPushEntries: + def test_base_push_matches_spec(self, project): + """既定の push は .venv/.git/キャッシュ/output/configs/_tmp_* を除く全体を送ること.""" + entries = files.collect_push_entries(project, include_base=True) + assert set(_arcnames(entries)) == _EXPECTED_BASE + + def test_entries_are_sorted_posix_and_sized(self, project): + entries = files.collect_push_entries(project, include_base=True) + names = _arcnames(entries) + assert names == sorted(names) + for entry in entries: + assert "\\" not in entry.arcname + assert entry.path.is_file() + assert entry.path == (project / entry.arcname).resolve() + assert entry.size == entry.path.stat().st_size + + def test_japanese_file_names_are_kept(self, project): + entries = files.collect_push_entries(project, include_base=True) + assert "configs/meshes/前側.stl" in _arcnames(entries) + + def test_include_adds_excluded_tmp_dir(self, project): + """--include で configs/_tmp_* を一緒に送れること(キャッシュ類は除く).""" + entries = files.collect_push_entries( + project, include_base=True, extra_paths=["configs/_tmp_offaxis"] + ) + names = set(_arcnames(entries)) + assert names == _EXPECTED_BASE | { + "configs/_tmp_offaxis/scene_cube_L120.yaml", + "configs/_tmp_offaxis/meshes/立方体_L120.stl", + } + + def test_include_single_file(self, project): + entries = files.collect_push_entries( + project, include_base=True, extra_paths=["configs/_tmp_fn_full_base.yaml"] + ) + assert "configs/_tmp_fn_full_base.yaml" in _arcnames(entries) + + def test_only_sends_just_the_given_paths(self, project): + """--only は指定したパスだけを送る(コード本体は送らない).""" + entries = files.collect_push_entries( + project, include_base=False, extra_paths=["configs/_tmp_offaxis"] + ) + assert _arcnames(entries) == [ + "configs/_tmp_offaxis/meshes/立方体_L120.stl", + "configs/_tmp_offaxis/scene_cube_L120.yaml", + ] + + def test_only_with_nothing_returns_empty(self, project): + assert files.collect_push_entries(project, include_base=False) == [] + + def test_overlapping_paths_are_deduplicated(self, project): + entries = files.collect_push_entries( + project, include_base=True, extra_paths=["configs", "configs/scene.yaml"] + ) + names = _arcnames(entries) + assert len(names) == len(set(names)) + assert names.count("configs/scene.yaml") == 1 + + def test_absolute_path_inside_project_is_accepted(self, project): + entries = files.collect_push_entries( + project, + include_base=False, + extra_paths=[str(project / "configs" / "_tmp_fn_full_base.yaml")], + ) + assert _arcnames(entries) == ["configs/_tmp_fn_full_base.yaml"] + + @pytest.mark.skipif(os.name != "nt", reason="Windows の区切り文字の扱い") + def test_windows_backslash_path_is_accepted(self, project): + entries = files.collect_push_entries( + project, include_base=False, extra_paths=["configs\\_tmp_offaxis"] + ) + assert "configs/_tmp_offaxis/scene_cube_L120.yaml" in _arcnames(entries) + + @pytest.mark.parametrize( + "extra", + [ + "output/offaxis", # output/ 配下は送信拒否 + "output", + "output/offaxis/luminance.npy", + "configs/../output/offaxis", + "../outside", # プロジェクト外 + ".", # プロジェクト全体 + "", + "configs/_tmp_does_not_exist", # 存在しない + ], + ) + def test_invalid_extra_paths_raise(self, project, extra): + (project.parent / "outside").mkdir(exist_ok=True) + with pytest.raises(ValueError): + files.collect_push_entries(project, include_base=True, extra_paths=[extra]) + + @pytest.mark.skipif(os.name != "nt", reason="Windows の区切り文字の扱い") + def test_windows_backslash_output_is_rejected(self, project): + with pytest.raises(ValueError): + files.collect_push_entries(project, include_base=False, extra_paths=["output\\offaxis"]) + + def test_absolute_path_outside_project_is_rejected(self, project): + outside = project.parent / "elsewhere.yaml" + outside.write_text("x", encoding="utf-8") + with pytest.raises(ValueError): + files.collect_push_entries(project, include_base=False, extra_paths=[str(outside)]) + + +# --------------------------------------------------------------------------- +# find_project_root +# --------------------------------------------------------------------------- + + +class TestFindProjectRoot: + def test_finds_root_from_nested_dir(self, project): + start = project / "src" / "sm_tias_lightsim" / "remote" + assert files.find_project_root(start) == project.resolve() + + def test_finds_root_from_root_itself(self, project): + assert files.find_project_root(project) == project.resolve() + + def test_raises_outside_project(self, tmp_path): + lonely = tmp_path / "lonely" + lonely.mkdir() + with pytest.raises(ValueError): + files.find_project_root(lonely) + + def test_pyproject_alone_is_not_enough(self, tmp_path): + other = tmp_path / "other" + other.mkdir() + (other / "pyproject.toml").write_text("", encoding="utf-8") + with pytest.raises(ValueError): + files.find_project_root(other) + + +# --------------------------------------------------------------------------- +# find_yaml_with_windows_paths(送る YAML に Windows 絶対パスがあれば警告) +# --------------------------------------------------------------------------- + + +class TestFindYamlWithWindowsPaths: + def _entry(self, root: Path, rel: str, content: str | bytes) -> files.PushEntry: + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + if isinstance(content, bytes): + path.write_bytes(content) + else: + path.write_text(content, encoding="utf-8") + return files.PushEntry(path=path, arcname=rel, size=path.stat().st_size) + + @pytest.mark.parametrize( + "content", + [ + "geometry:\n meshes:\n - id: a\n path: C:\\Users\\r\\meshes\\前側.stl\n", + "geometry:\n meshes:\n - path: 'D:/data/a.stl'\n", + 'geometry:\n meshes:\n - id: a\n path: "c:\\\\x\\\\a.stl"\n', + ], + ) + def test_detects_windows_absolute_paths(self, tmp_path, content): + entry = self._entry(tmp_path, "configs/_tmp_x/scene.yaml", content) + assert files.find_yaml_with_windows_paths([entry]) == ["configs/_tmp_x/scene.yaml"] + + @pytest.mark.parametrize( + "content", + [ + "geometry:\n meshes:\n - id: a\n path: meshes/前側.stl\n", + "geometry:\n meshes:\n - id: a\n path: ../meshes/前側.stl\n", + "geometry:\n meshes:\n - id: a\n path: ../../../../home/x/configs/meshes/a.stl\n", + "geometry:\n meshes:\n - id: a\n path: /home/hasegawa/a.stl\n", + "description: 'C:\\Users は path ではない'\n", + ], + ) + def test_ignores_portable_paths(self, tmp_path, content): + entry = self._entry(tmp_path, "configs/scene.yaml", content) + assert files.find_yaml_with_windows_paths([entry]) == [] + + def test_yml_extension_is_checked(self, tmp_path): + entry = self._entry(tmp_path, "configs/a.yml", "path: C:\\a.stl\n") + assert files.find_yaml_with_windows_paths([entry]) == ["configs/a.yml"] + + def test_non_yaml_files_are_ignored(self, tmp_path): + entry = self._entry(tmp_path, "docs/a.md", "path: C:\\a.stl\n") + assert files.find_yaml_with_windows_paths([entry]) == [] + + def test_undecodable_yaml_is_skipped_without_error(self, tmp_path): + entry = self._entry(tmp_path, "configs/bin.yaml", b"\xff\xfe\x00path: C:\\a\x80") + assert files.find_yaml_with_windows_paths([entry]) == [] + + def test_returns_only_offending_files(self, tmp_path): + good = self._entry(tmp_path, "configs/good.yaml", "path: meshes/a.stl\n") + bad = self._entry(tmp_path, "configs/_tmp_a/bad.yaml", " - path: C:\\a.stl\n") + assert files.find_yaml_with_windows_paths([good, bad]) == ["configs/_tmp_a/bad.yaml"] + + +# --------------------------------------------------------------------------- +# compute_prune_targets(--prune: src/ tests/ scripts/ だけ.output/・configs/ は消さない) +# --------------------------------------------------------------------------- + + +class TestComputePruneTargets: + def test_prune_dirs_are_code_only(self): + assert tuple(files.PRUNE_DIRS) == ("src", "tests", "scripts") + + def test_returns_remote_files_missing_locally(self): + remote = [ + "src/sm_tias_lightsim/a.py", + "src/sm_tias_lightsim/old.py", + "tests/test_old.py", + "scripts/gone.py", + "scripts/run.py", + ] + local = {"src/sm_tias_lightsim/a.py", "scripts/run.py", "configs/scene.yaml"} + assert files.compute_prune_targets(remote, local) == [ + "scripts/gone.py", + "src/sm_tias_lightsim/old.py", + "tests/test_old.py", + ] + + def test_cache_files_are_not_targeted(self): + remote = [ + "src/sm_tias_lightsim/__pycache__/a.cpython-312.pyc", + "tests/.pytest_cache/v/x", + "scripts/.ruff_cache/y", + ] + assert files.compute_prune_targets(remote, set()) == [] + + def test_nothing_to_prune(self): + remote = ["src/a.py"] + assert files.compute_prune_targets(remote, {"src/a.py"}) == [] + + @pytest.mark.parametrize( + "bad", + [ + "output/offaxis/luminance.npy", + "configs/_tmp_offaxis/scene.yaml", + "configs/scene.yaml", + "/etc/passwd", + "src/../output/x", + "../src/x", + "src", # ディレクトリそのもの + "lightsim/a.npy", + ], + ) + def test_never_targets_outside_prune_dirs(self, bad): + """output/・configs/ 等のパスを受け取っても削除対象にせず中断すること.""" + with pytest.raises(RuntimeError): + files.compute_prune_targets(["src/a.py", bad], set()) diff --git a/tests/remote/test_transfer.py b/tests/remote/test_transfer.py new file mode 100644 index 0000000..be444ef --- /dev/null +++ b/tests/remote/test_transfer.py @@ -0,0 +1,456 @@ +"""remote/transfer.py の仕様ベーステスト(tar ストリームの書き出しと安全な展開). + +仕様出典: +- 実装サマリー 仕様 1(push: PAX 形式・UTF-8 ファイル名) +- 実装サマリー 仕様 4(pull: tarfile の filter="data"・要求パス以外のメンバーを拒否 + =パストラバーサル対策) + +テスト方針: +- ssh は使わず,BytesIO をストリームの代わりにする.展開先は tmp_path の実ディレクトリ +""" + +from __future__ import annotations + +import gzip +import io +import tarfile +from pathlib import Path + +import pytest + +from sm_tias_lightsim.remote.files import PushEntry +from sm_tias_lightsim.remote.transfer import extract_tar_stream, write_tar_stream + +# --------------------------------------------------------------------------- +# ヘルパ +# --------------------------------------------------------------------------- + + +def _entry(root: Path, rel: str, data: bytes) -> PushEntry: + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + return PushEntry(path=path, arcname=rel, size=len(data)) + + +def _tar_bytes(members: list[tuple[str, bytes | None, dict]], *, gz: bool = False) -> bytes: + """(name, data, attrs) の並びから tar を作る.data=None ならディレクトリ等(attrs で指定).""" + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz" if gz else "w", format=tarfile.PAX_FORMAT) as tar: + for name, data, attrs in members: + info = tarfile.TarInfo(name) + for key, value in attrs.items(): + setattr(info, key, value) + if data is not None: + info.size = len(data) + tar.addfile(info, io.BytesIO(data)) + else: + tar.addfile(info) + return buf.getvalue() + + +def _file(name: str, data: bytes = b"x") -> tuple[str, bytes, dict]: + return (name, data, {"mode": 0o644}) + + +def _all_files(root: Path) -> set[str]: + return {p.relative_to(root).as_posix() for p in root.rglob("*") if p.is_file()} + + +# --------------------------------------------------------------------------- +# write_tar_stream(push) +# --------------------------------------------------------------------------- + + +class TestWriteTarStream: + def test_writes_pax_with_utf8_names_and_contents(self, tmp_path): + """PAX 形式で書き,日本語ファイル名と内容がそのまま読めること.""" + entries = [ + _entry(tmp_path, "configs/meshes/前側.stl", b"solid front\n"), + _entry(tmp_path, "src/sm_tias_lightsim/a.py", b"x = 1\n"), + ] + buf = io.BytesIO() + write_tar_stream(entries, buf, compress=False) + buf.seek(0) + with tarfile.open(fileobj=buf, mode="r:", encoding="utf-8") as tar: + assert tar.format == tarfile.PAX_FORMAT + names = tar.getnames() + assert names == ["configs/meshes/前側.stl", "src/sm_tias_lightsim/a.py"] + assert tar.extractfile("configs/meshes/前側.stl").read() == b"solid front\n" + + def test_pax_header_carries_utf8_path(self, tmp_path): + """日本語名は PAX 拡張ヘッダ(path=UTF-8)で運ばれること. + + 受信側(サーバの GNU tar)の文字コード設定に依存しないため. + """ + entries = [_entry(tmp_path, "configs/meshes/照明ムラ補正ボード.stl", b"s")] + buf = io.BytesIO() + write_tar_stream(entries, buf, compress=False) + assert "configs/meshes/照明ムラ補正ボード.stl".encode() in buf.getvalue() + + def test_returns_total_bytes_and_reports_progress(self, tmp_path): + entries = [_entry(tmp_path, "a.txt", b"12345"), _entry(tmp_path, "b.txt", b"123")] + calls: list[tuple[int, int]] = [] + total = write_tar_stream( + entries, io.BytesIO(), compress=False, progress=lambda d, t: calls.append((d, t)) + ) + assert total == 8 + assert calls == [(5, 8), (8, 8)] + + def test_owner_and_mode_are_normalized(self, tmp_path): + """所有者は 0,通常ファイルは 644 にそろえる(送信元 OS の stat に引きずられない).""" + entries = [_entry(tmp_path, "a.py", b"x")] + buf = io.BytesIO() + write_tar_stream(entries, buf, compress=False) + buf.seek(0) + with tarfile.open(fileobj=buf, mode="r:") as tar: + info = tar.getmember("a.py") + assert (info.uid, info.gid, info.uname, info.gname) == (0, 0, "", "") + assert info.mode in (0o644, 0o755) + + def test_does_not_close_stream(self, tmp_path): + """ssh の標準入力は呼び出し側が閉じる(EOF を伝える)ため,関数内では閉じないこと.""" + buf = io.BytesIO() + write_tar_stream([_entry(tmp_path, "a.txt", b"a")], buf, compress=False) + assert not buf.closed + + def test_compress_writes_gzip(self, tmp_path): + entries = [_entry(tmp_path, "a.txt", b"a" * 1000)] + buf = io.BytesIO() + write_tar_stream(entries, buf, compress=True) + data = buf.getvalue() + assert data[:2] == b"\x1f\x8b" + with tarfile.open(fileobj=io.BytesIO(gzip.decompress(data)), mode="r:") as tar: + assert tar.getnames() == ["a.txt"] + + def test_roundtrip_with_extract(self, tmp_path): + """書き出したストリームをそのまま展開すると同じファイルが得られること.""" + src = tmp_path / "src" + entries = [ + _entry(src, "output/sweep/前側.npy", b"\x00\x01\x02"), + _entry(src, "output/sweep/sub/metrics.csv", b"a,b\n1,2\n"), + ] + buf = io.BytesIO() + write_tar_stream(entries, buf, compress=True) + buf.seek(0) + dest = tmp_path / "dest" + count, size = extract_tar_stream(buf, dest, ["output/sweep"]) + assert (count, size) == (2, 3 + 8) + assert (dest / "output/sweep/前側.npy").read_bytes() == b"\x00\x01\x02" + assert (dest / "output/sweep/sub/metrics.csv").read_bytes() == b"a,b\n1,2\n" + + +# --------------------------------------------------------------------------- +# extract_tar_stream(pull) +# --------------------------------------------------------------------------- + + +class TestExtractTarStream: + def test_extracts_requested_paths(self, tmp_path): + data = _tar_bytes( + [ + ("output/foo", None, {"type": tarfile.DIRTYPE, "mode": 0o755}), + _file("output/foo/a.txt", b"hello"), + _file("output/foo/sub/b.txt", b"wor"), + ] + ) + dest = tmp_path / "dest" + assert extract_tar_stream(io.BytesIO(data), dest, ["output/foo"]) == (2, 8) + assert (dest / "output/foo/a.txt").read_bytes() == b"hello" + assert (dest / "output/foo/sub/b.txt").read_bytes() == b"wor" + + def test_creates_dest_dir(self, tmp_path): + dest = tmp_path / "a" / "b" + extract_tar_stream(io.BytesIO(_tar_bytes([_file("output/x/a")])), dest, ["output/x"]) + assert (dest / "output/x/a").is_file() + + def test_gzip_is_detected_automatically(self, tmp_path): + data = _tar_bytes([_file("output/foo/a.txt", b"z")], gz=True) + dest = tmp_path / "dest" + extract_tar_stream(io.BytesIO(data), dest, ["output/foo"]) + assert (dest / "output/foo/a.txt").read_bytes() == b"z" + + def test_multiple_prefixes(self, tmp_path): + data = _tar_bytes([_file("output/a/1"), _file("output/b/2")]) + dest = tmp_path / "dest" + assert extract_tar_stream(io.BytesIO(data), dest, ["output/a", "output/b"])[0] == 2 + + def test_overwrites_existing_file(self, tmp_path): + """pull は手元の同名ファイルを上書きする(ENV_04 注意).""" + dest = tmp_path / "dest" + (dest / "output/foo").mkdir(parents=True) + (dest / "output/foo/a.txt").write_bytes(b"old") + extract_tar_stream( + io.BytesIO(_tar_bytes([_file("output/foo/a.txt", b"new")])), dest, ["output/foo"] + ) + assert (dest / "output/foo/a.txt").read_bytes() == b"new" + + def test_progress_is_reported(self, tmp_path): + calls: list[tuple[int, int]] = [] + data = _tar_bytes([_file("output/foo/a", b"12"), _file("output/foo/b", b"345")]) + extract_tar_stream( + io.BytesIO(data), + tmp_path / "d", + ["output/foo"], + progress=lambda d, t: calls.append((d, t)), + ) + assert calls == [(2, 0), (5, 0)] + + def test_rejects_member_outside_requested_path(self, tmp_path): + """要求していないパスのメンバーが来たら中断し,そのファイルは書かないこと.""" + data = _tar_bytes([_file("output/foo/a.txt"), _file("src/sm_tias_lightsim/__init__.py")]) + dest = tmp_path / "dest" + with pytest.raises(tarfile.TarError): + extract_tar_stream(io.BytesIO(data), dest, ["output/foo"]) + assert not (dest / "src").exists() + + def test_prefix_match_respects_path_boundary(self, tmp_path): + """`output/foo` を要求したとき `output/foobar/...` は受け入れないこと.""" + data = _tar_bytes([_file("output/foobar/x")]) + dest = tmp_path / "dest" + with pytest.raises(tarfile.TarError): + extract_tar_stream(io.BytesIO(data), dest, ["output/foo"]) + assert not (dest / "output/foobar/x").exists() + + @pytest.mark.parametrize( + "name", + [ + "/etc/evil.txt", # 絶対パス + "../evil.txt", # 展開先の外 + "output/foo/../../../evil.txt", # 要求パスで始まるが展開先の外へ出る + ], + ) + def test_rejects_traversal_outside_dest(self, tmp_path, name): + dest = tmp_path / "dest" + data = _tar_bytes([_file(name, b"evil")]) + with pytest.raises(tarfile.TarError): + extract_tar_stream(io.BytesIO(data), dest, ["output/foo"]) + assert not (tmp_path / "evil.txt").exists() + assert not (dest / "evil.txt").exists() + + @pytest.mark.parametrize( + ("name", "landing"), + [ + ("output/foo/../../evil.txt", "evil.txt"), + ("output/foo/../bar/evil.txt", "output/bar/evil.txt"), + ( + "output/foo/../../src/sm_tias_lightsim/__init__.py", + "src/sm_tias_lightsim/__init__.py", + ), + ], + ) + def test_rejects_dotdot_escape_from_requested_path(self, tmp_path, name, landing): + """要求パスで始まっても `..` で要求パスの外(展開先の内側)へ出るメンバーは拒否すること. + + 仕様 4: 要求したパス以外のメンバーは拒否する(パストラバーサル対策). + 展開先はプロジェクトルートなので,これを許すと src/ 等を上書きできてしまう. + """ + dest = tmp_path / "dest" + data = _tar_bytes([_file(name, b"evil")]) + with pytest.raises(tarfile.TarError): + extract_tar_stream(io.BytesIO(data), dest, ["output/foo"]) + assert not (dest / landing).exists() + + def test_rejects_symlink_pointing_outside_dest(self, tmp_path): + data = _tar_bytes( + [("output/foo/link", None, {"type": tarfile.SYMTYPE, "linkname": "../../../outside"})] + ) + with pytest.raises(tarfile.TarError): + extract_tar_stream(io.BytesIO(data), tmp_path / "dest", ["output/foo"]) + + def test_rejects_absolute_symlink(self, tmp_path): + data = _tar_bytes( + [("output/foo/link", None, {"type": tarfile.SYMTYPE, "linkname": "/etc/passwd"})] + ) + with pytest.raises(tarfile.TarError): + extract_tar_stream(io.BytesIO(data), tmp_path / "dest", ["output/foo"]) + + def test_rejects_device_file(self, tmp_path): + data = _tar_bytes([("output/foo/dev", None, {"type": tarfile.CHRTYPE})]) + with pytest.raises(tarfile.TarError): + extract_tar_stream(io.BytesIO(data), tmp_path / "dest", ["output/foo"]) + + def test_empty_stream_raises(self, tmp_path): + with pytest.raises(tarfile.TarError): + extract_tar_stream(io.BytesIO(b""), tmp_path / "dest", ["output/foo"]) + + def test_broken_stream_raises(self, tmp_path): + with pytest.raises(tarfile.TarError): + extract_tar_stream(io.BytesIO(b"not a tar" * 100), tmp_path / "dest", ["output/foo"]) + + +# --------------------------------------------------------------------------- +# 仕様 4 の強化: 要求パスの外への書き込みにつながるメンバーの拒否 +# --------------------------------------------------------------------------- + +# 展開先(プロジェクトルート相当)に最初からある,上書きされてはならないファイル +_SENTINEL_REL = "src/sm_tias_lightsim/__init__.py" +_SENTINEL_DATA = b"original\n" + + +@pytest.fixture +def dest_with_sentinel(tmp_path) -> Path: + dest = tmp_path / "dest" + sentinel = dest / _SENTINEL_REL + sentinel.parent.mkdir(parents=True) + sentinel.write_bytes(_SENTINEL_DATA) + return dest + + +def _assert_nothing_outside(dest: Path, prefix: str = "output/foo") -> None: + """要求パスの外に新しいファイル・リンクが無く,既存ファイルも変わっていないこと.""" + sentinel = dest / _SENTINEL_REL + assert sentinel.read_bytes() == _SENTINEL_DATA + assert sentinel.stat().st_nlink == 1, "既存ファイルへのハードリンクが作られている" + outside = [] + for path in dest.rglob("*"): + rel = path.relative_to(dest).as_posix() + if rel == prefix or rel.startswith(prefix + "/") or rel == _SENTINEL_REL: + continue + if path.is_dir() and not path.is_symlink(): + continue # output/・src/ 等の親ディレクトリ + outside.append(rel) + assert outside == [], f"要求パスの外に作られた: {outside}" + + +class TestExtractRejectsEscapes: + """仕様 4: 要求したパス以外のメンバーは拒否する(パストラバーサル対策). + + リンクや名前の書き方で要求パス(output/foo)の外へ書き込めてしまうメンバーを + TarError 系で拒否し,展開先の要求パスの外にファイルが作られない・変わらないこと. + """ + + @staticmethod + def _extract_rejected(dest: Path, members) -> None: + with pytest.raises(tarfile.TarError): + extract_tar_stream(io.BytesIO(_tar_bytes(members)), dest, ["output/foo"]) + + def test_hardlink_then_overwrite_is_rejected(self, dest_with_sentinel): + """要求パス内のハードリンクで要求パス外の既存ファイルを指し,続く同名の通常ファイルで + 上書きするストリームを拒否すること(ハードリンクは Windows でも特権なしで作れる).""" + dest = dest_with_sentinel + self._extract_rejected( + dest, + [ + ("output/foo/h", None, {"type": tarfile.LNKTYPE, "linkname": _SENTINEL_REL}), + _file("output/foo/h", b"evil\n"), + ], + ) + _assert_nothing_outside(dest) + + def test_hardlink_alone_to_outside_is_rejected(self, dest_with_sentinel): + dest = dest_with_sentinel + self._extract_rejected( + dest, [("output/foo/h", None, {"type": tarfile.LNKTYPE, "linkname": _SENTINEL_REL})] + ) + _assert_nothing_outside(dest) + assert not (dest / "output/foo/h").exists() + + @pytest.mark.parametrize( + "linkname", + ["../bar", "../../src", "../../src/sm_tias_lightsim/__init__.py", "../../../outside"], + ) + def test_symlink_to_outside_requested_path_is_rejected(self, dest_with_sentinel, linkname): + """要求パスの外(展開先の内側・外側とも)を指す symlink は拒否すること.""" + dest = dest_with_sentinel + link = ("output/foo/link", None, {"type": tarfile.SYMTYPE, "linkname": linkname}) + self._extract_rejected(dest, [link]) + link_path = dest / "output/foo/link" + assert not link_path.is_symlink() and not link_path.exists() + _assert_nothing_outside(dest) + + @pytest.mark.parametrize("linkname", ["a.txt", "sub/b.txt", "./a.txt"]) + def test_symlink_inside_requested_path_is_rejected(self, dest_with_sentinel, linkname): + """展開先の内側(要求パス内)を指す symlink も拒否すること. + + フィルタの段階で弾くので,symlink を作る特権が無い環境でも同じ結果になる. + """ + dest = dest_with_sentinel + members = [ + _file("output/foo/a.txt", b"a"), + _file("output/foo/sub/b.txt", b"b"), + ("output/foo/link", None, {"type": tarfile.SYMTYPE, "linkname": linkname}), + ] + self._extract_rejected(dest, members) + link_path = dest / "output/foo/link" + assert not link_path.is_symlink() and not link_path.exists() + _assert_nothing_outside(dest) + + def test_symlink_then_write_through_is_rejected(self, dest_with_sentinel): + """symlink を置いてからそれ越しに書き込むストリームを拒否すること.""" + dest = dest_with_sentinel + self._extract_rejected( + dest, + [ + ( + "output/foo/lnk", + None, + {"type": tarfile.SYMTYPE, "linkname": "../../src/sm_tias_lightsim"}, + ), + _file("output/foo/lnk/__init__.py", b"evil\n"), + ], + ) + _assert_nothing_outside(dest) + + @pytest.mark.parametrize( + "name", + [ + "output/foo/..\\..\\src\\evil.py", + "output/foo/..\\..\\src\\sm_tias_lightsim\\__init__.py", + "output/foo/sub\\a.txt", + "output\\foo\\a.txt", + ], + ) + def test_backslash_names_are_rejected(self, dest_with_sentinel, name): + """バックスラッシュ区切りの名前は拒否すること(Windows では区切りとして解釈される).""" + dest = dest_with_sentinel + self._extract_rejected(dest, [_file(name, b"evil\n")]) + assert not (dest / "src" / "evil.py").exists() + _assert_nothing_outside(dest) + + @pytest.mark.parametrize( + "name", + [ + "output/foo/./a.txt", + "./output/foo/a.txt", + "output/foo/.", + "output/foo//a.txt", + "output//foo/a.txt", + "output/foo/C:/evil.txt", + "output/foo/C:evil.txt", + "C:/output/foo/a.txt", + "C:output/foo/a.txt", + ], + ) + def test_names_with_dot_empty_or_drive_elements_are_rejected(self, dest_with_sentinel, name): + """要素に `.`・空文字(`//`)・ドライブ文字(`C:`)を含む名前は拒否すること.""" + dest = dest_with_sentinel + self._extract_rejected(dest, [_file(name, b"evil\n")]) + _assert_nothing_outside(dest) + + @pytest.mark.parametrize( + "name", + [ + "output/foo/a.txt", + "output/foo/.hidden", + "output/foo/a..b.txt", + "output/foo/..hidden", + "output/foo/sub/前側.npy", + "output/foo/with space.txt", + "output/foo/-dash.txt", + ], + ) + def test_legitimate_names_are_still_accepted(self, dest_with_sentinel, name): + """正当な名前(. で始まる名前・.. を含むファイル名・日本語・空白)は受け入れること. + + 拒否の強化で通常の pull が壊れていないことの確認(GNU tar のディレクトリ行を含む). + """ + dest = dest_with_sentinel + members = [ + ("output/foo", None, {"type": tarfile.DIRTYPE, "mode": 0o755}), + ("output/foo/sub", None, {"type": tarfile.DIRTYPE, "mode": 0o755}), + _file(name, b"ok"), + ] + assert extract_tar_stream(io.BytesIO(_tar_bytes(members)), dest, ["output/foo"]) == (1, 2) + assert (dest / name).read_bytes() == b"ok" + _assert_nothing_outside(dest) diff --git a/tests/runtime/test_llvm_init_versioned.py b/tests/runtime/test_llvm_init_versioned.py new file mode 100644 index 0000000..ff82f8b --- /dev/null +++ b/tests/runtime/test_llvm_init_versioned.py @@ -0,0 +1,258 @@ +"""runtime/llvm_init.py のバージョン付き libLLVM 探索(Ubuntu / Debian / Fedora)の仕様テスト. + +仕様出典: +- 実装サマリー 仕様 9: 既存の Windows / POSIX 標準パスに加え,Ubuntu / Debian の + `libLLVM-*.so`(例 `/usr/lib/x86_64-linux-gnu/libLLVM-15.so`)と `/usr/lib/llvm-*/lib/` + (Fedora 形式も)を探す.新しい版を優先する.Windows の既存検出を壊さない. +- docs/02_ENV/ENV_02_環境構築手順.md「研究室サーバ編 / 既知事項」(LLVM) + +テスト方針: +- 実ファイルシステムには依存しない.モジュールが参照する `os` と `glob` を + モジュール内だけ差し替えて疑似的な POSIX / Windows 環境を作る + (グローバルな os.name は変えない=pathlib 等に影響させない) +- glob の `*` はパス区切りをまたがない.疑似 glob もその意味論を守る +""" + +from __future__ import annotations + +import fnmatch +import os +import types +from pathlib import Path + +import pytest + +import sm_tias_lightsim.runtime.llvm_init as llvm_init + +_WINDOWS_STANDARD = [ + r"C:\Program Files\LLVM\bin\LLVM-C.dll", + r"C:\Program Files (x86)\LLVM\bin\LLVM-C.dll", + r"C:\ProgramData\chocolatey\lib\llvm\tools\LLVM\bin\LLVM-C.dll", + r"C:\msys64\mingw64\bin\LLVM-C.dll", + r"C:\msys64\clang64\bin\LLVM-C.dll", +] + + +def _fake_glob(files: set[str]): + def glob(pattern: str) -> list[str]: + pat = pattern.split("/") + hits = [] + for f in files: + parts = f.split("/") + if len(parts) == len(pat) and all( + fnmatch.fnmatchcase(p, q) for p, q in zip(parts, pat, strict=True) + ): + hits.append(f) + return hits + + return glob + + +@pytest.fixture +def fake_fs(monkeypatch): + """疑似ファイルシステムを組み立てる関数を返す.戻り値の environ は疑似 os.environ.""" + + def install(files: set[str], *, os_name: str = "posix", environ: dict | None = None): + env = {} if environ is None else environ + monkeypatch.setattr(llvm_init, "os", types.SimpleNamespace(name=os_name, environ=env)) + if os_name == "nt": + + def _no_glob(pattern): # Windows 分岐では glob を使わない + raise AssertionError(f"Windows で glob が呼ばれた: {pattern}") + + monkeypatch.setattr(llvm_init, "glob", types.SimpleNamespace(glob=_no_glob)) + else: + monkeypatch.setattr(llvm_init, "glob", types.SimpleNamespace(glob=_fake_glob(files))) + monkeypatch.setattr(Path, "is_file", lambda self: self.as_posix() in files) + return env + + return install + + +# --------------------------------------------------------------------------- +# Ubuntu / Debian +# --------------------------------------------------------------------------- + + +def test_ubuntu_versioned_lib_is_found(fake_fs): + """Newton(Ubuntu 22.04)の `/usr/lib/x86_64-linux-gnu/libLLVM-15.so` を検出すること.""" + env = fake_fs({"/usr/lib/x86_64-linux-gnu/libLLVM-15.so"}) + result = llvm_init.ensure_drjit_libllvm_path() + assert result == "/usr/lib/x86_64-linux-gnu/libLLVM-15.so" + assert env["DRJIT_LIBLLVM_PATH"] == result + + +@pytest.mark.parametrize( + "path", + [ + "/usr/lib/x86_64-linux-gnu/libLLVM-15.so.1", + "/usr/lib/x86_64-linux-gnu/libLLVM.so.18.1", + "/usr/lib/aarch64-linux-gnu/libLLVM-14.so", + ], +) +def test_ubuntu_variants_are_found(fake_fs, path): + fake_fs({path}) + assert llvm_init.ensure_drjit_libllvm_path() == path + + +def test_dev_symlink_preferred_over_soname_of_same_version(fake_fs): + fake_fs( + { + "/usr/lib/x86_64-linux-gnu/libLLVM-15.so.1", + "/usr/lib/x86_64-linux-gnu/libLLVM-15.so", + } + ) + assert llvm_init.ensure_drjit_libllvm_path() == "/usr/lib/x86_64-linux-gnu/libLLVM-15.so" + + +def test_newest_version_is_preferred(fake_fs): + """複数の版があるときは新しい版を優先すること(数値比較.9 < 11 < 15).""" + fake_fs( + { + "/usr/lib/x86_64-linux-gnu/libLLVM-9.so", + "/usr/lib/x86_64-linux-gnu/libLLVM-15.so", + "/usr/lib/x86_64-linux-gnu/libLLVM-11.so", + } + ) + assert llvm_init.ensure_drjit_libllvm_path() == "/usr/lib/x86_64-linux-gnu/libLLVM-15.so" + + +def test_newest_version_is_preferred_across_directories(fake_fs): + fake_fs( + { + "/usr/lib/llvm-14/lib/libLLVM-14.so", + "/usr/lib/x86_64-linux-gnu/libLLVM-17.so.1", + } + ) + assert llvm_init.ensure_drjit_libllvm_path() == "/usr/lib/x86_64-linux-gnu/libLLVM-17.so.1" + + +def test_libllvm_c_versions_sorted_numerically(fake_fs): + """既存の `/usr/lib/llvm-*/lib/libLLVM-C.so` も新しい版が先(辞書順の 9 > 15 にしない).""" + fake_fs({"/usr/lib/llvm-9/lib/libLLVM-C.so", "/usr/lib/llvm-15/lib/libLLVM-C.so"}) + assert llvm_init.ensure_drjit_libllvm_path() == "/usr/lib/llvm-15/lib/libLLVM-C.so" + + +# --------------------------------------------------------------------------- +# /usr/lib/llvm-*/lib/(Fedora 形式を含む)と /usr/lib64 +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "path", + [ + "/usr/lib/llvm-17/lib/libLLVM.so", + "/usr/lib/llvm-16/lib/libLLVM-16.so", + "/usr/lib/llvm-18/lib/libLLVM.so.18.1", + "/usr/lib/llvm-18/lib/libLLVM-18.so.1", + "/usr/lib64/libLLVM-17.so", + "/usr/lib64/libLLVM.so", + "/usr/lib64/libLLVM.so.18.1", + ], +) +def test_llvm_dir_and_fedora_forms_are_found(fake_fs, path): + fake_fs({path}) + assert llvm_init.ensure_drjit_libllvm_path() == path + + +# --------------------------------------------------------------------------- +# 既存挙動の維持・候補リストの性質 +# --------------------------------------------------------------------------- + + +def test_existing_libllvm_c_is_still_found(fake_fs): + fake_fs({"/usr/lib/llvm-15/lib/libLLVM-C.so"}) + assert llvm_init.ensure_drjit_libllvm_path() == "/usr/lib/llvm-15/lib/libLLVM-C.so" + + +def test_existing_fixed_posix_paths_remain_candidates(fake_fs): + fake_fs(set()) + candidates = llvm_init._iter_default_candidates() + for path in [ + "/usr/local/lib/libLLVM-C.so", + "/opt/homebrew/opt/llvm/lib/libLLVM-C.dylib", + "/usr/local/opt/llvm/lib/libLLVM-C.dylib", + ]: + assert path in candidates + + +def test_all_forms_are_candidates_and_newest_first(fake_fs): + """全形式を候補に含み,同一バージョンが並んでも .so(開発用リンク)から試すこと. + + ※ `libLLVM-*.so` パターンは `libLLVM-C.so` にも一致するため,libLLVM-C.so が候補に + 2 回現れうる(is_file の確認が 1 回増えるだけで結果は変わらない).仕様上の要件では + ないので重複の有無は検証しない. + """ + fake_fs( + { + "/usr/lib/llvm-15/lib/libLLVM-C.so", + "/usr/lib/llvm-15/lib/libLLVM.so", + "/usr/lib/llvm-15/lib/libLLVM-15.so", + "/usr/lib/llvm-15/lib/libLLVM-15.so.1", + "/usr/lib/llvm-15/lib/libLLVM.so.15", + "/usr/lib/x86_64-linux-gnu/libLLVM-15.so", + "/usr/lib/x86_64-linux-gnu/libLLVM-15.so.1", + "/usr/lib/x86_64-linux-gnu/libLLVM.so.15", + "/usr/lib64/libLLVM-15.so", + } + ) + candidates = llvm_init._iter_default_candidates() + for path in [ + "/usr/lib/llvm-15/lib/libLLVM.so", + "/usr/lib/llvm-15/lib/libLLVM-15.so", + "/usr/lib/llvm-15/lib/libLLVM.so.15", + "/usr/lib/x86_64-linux-gnu/libLLVM-15.so", + "/usr/lib/x86_64-linux-gnu/libLLVM-15.so.1", + "/usr/lib/x86_64-linux-gnu/libLLVM.so.15", + "/usr/lib64/libLLVM-15.so", + ]: + assert path in candidates + so_1 = candidates.index("/usr/lib/x86_64-linux-gnu/libLLVM-15.so.1") + assert candidates.index("/usr/lib/x86_64-linux-gnu/libLLVM-15.so") < so_1 + + +def test_unrelated_libraries_are_not_candidates(fake_fs, caplog): + """libLLVM 以外(静的ライブラリ・別名のライブラリ)は拾わないこと.""" + env = fake_fs( + { + "/usr/lib/x86_64-linux-gnu/libLLVM-15.a", + "/usr/lib/x86_64-linux-gnu/libLLVMSupport.so", + "/usr/lib/x86_64-linux-gnu/libclang-15.so", + "/usr/lib/x86_64-linux-gnu/sub/libLLVM-15.so", # glob の * は / をまたがない + } + ) + assert llvm_init.ensure_drjit_libllvm_path() is None + assert "DRJIT_LIBLLVM_PATH" not in env + + +def test_existing_env_is_respected_on_posix(fake_fs): + env = fake_fs( + {"/usr/lib/x86_64-linux-gnu/libLLVM-15.so"}, + environ={"DRJIT_LIBLLVM_PATH": "/custom/libLLVM.so"}, + ) + assert llvm_init.ensure_drjit_libllvm_path() == "/custom/libLLVM.so" + assert env["DRJIT_LIBLLVM_PATH"] == "/custom/libLLVM.so" + + +# --------------------------------------------------------------------------- +# Windows の既存検出を壊していない +# --------------------------------------------------------------------------- + + +def test_windows_candidates_unchanged(fake_fs): + """Windows 分岐の候補は従来の 5 パスのまま(POSIX の glob を呼ばない).""" + fake_fs(set(), os_name="nt") + assert llvm_init._iter_default_candidates() == _WINDOWS_STANDARD + + +def test_windows_llvm_dir_still_first(fake_fs): + fake_fs(set(), os_name="nt", environ={"LLVM_DIR": r"D:\llvm"}) + candidates = llvm_init._iter_default_candidates() + assert candidates[0] == str(Path(r"D:\llvm") / "bin" / "LLVM-C.dll") + assert candidates[1:] == _WINDOWS_STANDARD + + +@pytest.mark.skipif(os.name != "nt", reason="実 Windows 環境での回帰確認") +def test_real_windows_candidates_have_no_posix_paths(monkeypatch): + monkeypatch.delenv("LLVM_DIR", raising=False) + assert llvm_init._iter_default_candidates() == _WINDOWS_STANDARD diff --git a/tests/runtime/test_overrides.py b/tests/runtime/test_overrides.py new file mode 100644 index 0000000..5da2d86 --- /dev/null +++ b/tests/runtime/test_overrides.py @@ -0,0 +1,248 @@ +"""runtime/overrides.py と,その runner / baker への組み込みの仕様ベーステスト. + +仕様出典: +- docs/02_ENV/ENV_04_開発コマンド.md「環境変数」(SM_RENDER_VARIANT / SM_DRJIT_THREADS) +- 実装サマリー 仕様 7: 優先順位は --variant > SM_RENDER_VARIANT > YAML の render.variant +- 実装サマリー 仕様 8: SM_DRJIT_THREADS は runner / baker で dr.set_thread_count を適用する. + 未設定なら drjit を import しない.不正値(整数でない・1 未満)は ValueError + +テスト方針: +- 環境変数は monkeypatch で設定・復元する +- 「drjit を import しない」は sys.modules["drjit"] = None(import すると ImportError)で検証する +- 実 drjit の set_thread_count も 1 本確かめ,元のスレッド数に戻す +""" + +from __future__ import annotations + +import logging +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +import sm_tias_lightsim.runtime.overrides as overrides +from sm_tias_lightsim.runtime.overrides import ( + THREADS_ENV_VAR, + VARIANT_ENV_VAR, + apply_drjit_thread_count, + resolve_render_variant, + resolve_thread_count, +) +from sm_tias_lightsim.scene.schema import SceneConfig + + +def test_env_var_names_match_spec(): + assert VARIANT_ENV_VAR == "SM_RENDER_VARIANT" + assert THREADS_ENV_VAR == "SM_DRJIT_THREADS" + + +# --------------------------------------------------------------------------- +# resolve_render_variant(仕様 7) +# --------------------------------------------------------------------------- + + +class TestResolveRenderVariant: + def test_yaml_is_used_when_nothing_else(self): + assert resolve_render_variant("llvm_ad_rgb", None, {}) == "llvm_ad_rgb" + + def test_env_overrides_yaml(self): + env = {VARIANT_ENV_VAR: "cuda_ad_rgb"} + assert resolve_render_variant("llvm_ad_rgb", None, env) == "cuda_ad_rgb" + + def test_cli_overrides_env_and_yaml(self): + env = {VARIANT_ENV_VAR: "cuda_ad_rgb"} + assert resolve_render_variant("llvm_ad_rgb", "scalar_rgb", env) == "scalar_rgb" + + @pytest.mark.parametrize("blank", ["", " "]) + def test_blank_cli_is_ignored(self, blank): + env = {VARIANT_ENV_VAR: "cuda_ad_rgb"} + assert resolve_render_variant("llvm_ad_rgb", blank, env) == "cuda_ad_rgb" + + @pytest.mark.parametrize("blank", ["", " "]) + def test_blank_env_is_ignored(self, blank): + assert ( + resolve_render_variant("llvm_ad_rgb", None, {VARIANT_ENV_VAR: blank}) == "llvm_ad_rgb" + ) + + def test_values_are_stripped(self): + assert resolve_render_variant("llvm_ad_rgb", " cuda_ad_rgb ", {}) == "cuda_ad_rgb" + env = {VARIANT_ENV_VAR: " scalar_rgb\n"} + assert resolve_render_variant("llvm_ad_rgb", None, env) == "scalar_rgb" + + def test_defaults_to_process_environment(self, monkeypatch): + monkeypatch.setenv(VARIANT_ENV_VAR, "cuda_ad_rgb") + assert resolve_render_variant("llvm_ad_rgb") == "cuda_ad_rgb" + monkeypatch.delenv(VARIANT_ENV_VAR) + assert resolve_render_variant("llvm_ad_rgb") == "llvm_ad_rgb" + + +# --------------------------------------------------------------------------- +# resolve_thread_count(仕様 8) +# --------------------------------------------------------------------------- + + +class TestResolveThreadCount: + @pytest.mark.parametrize("env", [{}, {THREADS_ENV_VAR: ""}, {THREADS_ENV_VAR: " "}]) + def test_unset_returns_none(self, env): + assert resolve_thread_count(env) is None + + @pytest.mark.parametrize(("raw", "expected"), [("1", 1), ("16", 16), (" 8 ", 8), ("064", 64)]) + def test_valid_values(self, raw, expected): + assert resolve_thread_count({THREADS_ENV_VAR: raw}) == expected + + @pytest.mark.parametrize("raw", ["0", "-1", "abc", "1.5", "16 threads", "0x10"]) + def test_invalid_values_raise_value_error(self, raw): + """整数でない・1 未満は ValueError(黙って全コアで走らせない).""" + with pytest.raises(ValueError, match=THREADS_ENV_VAR): + resolve_thread_count({THREADS_ENV_VAR: raw}) + + def test_defaults_to_process_environment(self, monkeypatch): + monkeypatch.setenv(THREADS_ENV_VAR, "12") + assert resolve_thread_count() == 12 + + +# --------------------------------------------------------------------------- +# apply_drjit_thread_count(仕様 8) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def reset_applied(monkeypatch): + monkeypatch.setattr(overrides, "_applied_thread_count", None) + + +class TestApplyDrjitThreadCount: + def test_unset_does_not_import_drjit(self, monkeypatch, reset_applied): + """未設定なら何もせず,drjit を import しない.""" + monkeypatch.setitem(sys.modules, "drjit", None) # import されると ImportError になる + assert apply_drjit_thread_count({}) is None + + def test_sets_thread_count(self, monkeypatch, reset_applied): + fake_dr = MagicMock() + monkeypatch.setitem(sys.modules, "drjit", fake_dr) + assert apply_drjit_thread_count({THREADS_ENV_VAR: "16"}) == 16 + fake_dr.set_thread_count.assert_called_once_with(16) + + def test_invalid_value_raises_before_touching_drjit(self, monkeypatch, reset_applied): + fake_dr = MagicMock() + monkeypatch.setitem(sys.modules, "drjit", fake_dr) + with pytest.raises(ValueError): + apply_drjit_thread_count({THREADS_ENV_VAR: "0"}) + fake_dr.set_thread_count.assert_not_called() + + def test_reads_process_environment(self, monkeypatch, reset_applied): + fake_dr = MagicMock() + monkeypatch.setitem(sys.modules, "drjit", fake_dr) + monkeypatch.setenv(THREADS_ENV_VAR, "3") + assert apply_drjit_thread_count() == 3 + fake_dr.set_thread_count.assert_called_once_with(3) + + def test_info_log_once_for_repeated_calls(self, monkeypatch, reset_applied, caplog): + """runner と baker の両方から呼ばれても同じ値なら INFO は 1 回(冪等).""" + fake_dr = MagicMock() + monkeypatch.setitem(sys.modules, "drjit", fake_dr) + with caplog.at_level(logging.INFO, logger=overrides.__name__): + apply_drjit_thread_count({THREADS_ENV_VAR: "4"}) + apply_drjit_thread_count({THREADS_ENV_VAR: "4"}) + infos = [r for r in caplog.records if r.levelno == logging.INFO] + assert len(infos) == 1 + assert fake_dr.set_thread_count.call_count == 2 + + def test_real_drjit_thread_count_is_applied(self, reset_applied): + """実物の Dr.Jit でスレッド数が変わること(元に戻す).""" + import drjit as dr + + original = dr.thread_count() + target = 2 if original != 2 else 3 + try: + assert apply_drjit_thread_count({THREADS_ENV_VAR: str(target)}) == target + assert dr.thread_count() == target + finally: + dr.set_thread_count(original) + + +# --------------------------------------------------------------------------- +# runner / baker への組み込み(仕様 8) +# --------------------------------------------------------------------------- + + +class _StopError(Exception): + """set_thread_count が呼ばれた時点で処理を打ち切るための例外.""" + + +def _scene_config(variant: str = "scalar_rgb") -> SceneConfig: + return SceneConfig.model_validate( + { + "name": "threads", + "geometry": { + "meshes": [{"id": "m", "path": "dummy.stl", "unit": "mm", "material": "white"}] + }, + "materials": {"white": {"type": "diffuse", "reflectance": 0.9}}, + "lights": [ + { + "id": "led", + "type": "point", + "position": [0.0, 0.0, 100.0], + "intensity": 1.0, + "profile": "lambertian", + } + ], + "sensor": { + "type": "perspective", + "position": [0.0, 0.0, 200.0], + "target": [0.0, 0.0, 0.0], + "up": [0.0, 1.0, 0.0], + "fov_deg": 60.0, + "film": {"width": 4, "height": 4}, + }, + "render": {"variant": variant, "integrator": "path", "max_depth": 2, "spp": 1}, + } + ) + + +class TestRunnerAppliesThreadCount: + @pytest.fixture(autouse=True) + def _isolate_variant(self, monkeypatch, reset_applied): + import sm_tias_lightsim.render.runner as runner + + monkeypatch.setattr(runner, "_current_variant", None) + + def test_render_scene_applies_env_thread_count(self, monkeypatch): + from sm_tias_lightsim.render.runner import render_scene + + fake_dr = MagicMock() + fake_dr.set_thread_count.side_effect = _StopError + monkeypatch.setitem(sys.modules, "drjit", fake_dr) + monkeypatch.setenv(THREADS_ENV_VAR, "5") + with pytest.raises(_StopError): + render_scene(_scene_config(), project_root=Path(".")) + fake_dr.set_thread_count.assert_called_once_with(5) + + def test_render_scene_rejects_invalid_env(self, monkeypatch): + from sm_tias_lightsim.render.runner import render_scene + + monkeypatch.setenv(THREADS_ENV_VAR, "abc") + with pytest.raises(ValueError, match=THREADS_ENV_VAR): + render_scene(_scene_config(), project_root=Path(".")) + + +class TestBakerAppliesThreadCount: + @pytest.mark.parametrize("mode", ["direct", "path"]) + def test_bake_irradiance_applies_env_thread_count(self, monkeypatch, reset_applied, mode): + from sm_tias_lightsim.visualize.baker import bake_irradiance + + fake_dr = MagicMock() + fake_dr.set_thread_count.side_effect = _StopError + monkeypatch.setitem(sys.modules, "drjit", fake_dr) + monkeypatch.setenv(THREADS_ENV_VAR, "6") + with pytest.raises(_StopError): + bake_irradiance(_scene_config(), Path("."), mode=mode) + fake_dr.set_thread_count.assert_called_once_with(6) + + def test_bake_irradiance_rejects_invalid_env(self, monkeypatch, reset_applied): + from sm_tias_lightsim.visualize.baker import bake_irradiance + + monkeypatch.setenv(THREADS_ENV_VAR, "-2") + with pytest.raises(ValueError, match=THREADS_ENV_VAR): + bake_irradiance(_scene_config(), Path("."), mode="direct") diff --git a/tests/scene/test_paths.py b/tests/scene/test_paths.py new file mode 100644 index 0000000..11afdfe --- /dev/null +++ b/tests/scene/test_paths.py @@ -0,0 +1,155 @@ +"""scene/paths.py(relative_mesh_path)の仕様ベーステスト. + +仕様出典: +- 実装サマリー 仕様 10: スイープ生成スクリプトは _tmp YAML のメッシュパスを + YAML ファイルからの相対パス(POSIX 区切り)で書く +- docs/02_ENV/ENV_04_開発コマンド.md「研究室サーバでの実行」注意(Windows 絶対パスは + サーバで読めない.push 時に警告が出る) + +テスト方針: +- 相対パスは「YAML のディレクトリから解決すると元のメッシュを指す」ことで検証する +- 相対化できない場合(Windows で別ドライブ)は,警告を出して絶対パスを返す + (docstring に明記された従来挙動へのフォールバック).この扱いをテストで明示する +""" + +from __future__ import annotations + +import logging +import os +import string +from pathlib import Path, PurePosixPath, PureWindowsPath + +import pytest + +from sm_tias_lightsim.remote.files import PushEntry, find_yaml_with_windows_paths +from sm_tias_lightsim.scene import paths +from sm_tias_lightsim.scene.paths import relative_mesh_path + +_ROOT = Path(__file__).resolve().parents[2] + + +def _assert_portable(rel: str) -> None: + """どの OS でも相対パスとして解釈される文字列であること.""" + assert "\\" not in rel, f"POSIX 区切りでない: {rel}" + assert not PurePosixPath(rel).is_absolute(), rel + assert not PureWindowsPath(rel).is_absolute(), rel + assert not PureWindowsPath(rel).drive, rel + + +@pytest.mark.parametrize( + ("target", "yaml_dir", "expected"), + [ + ("configs/scene.stl", "configs", "scene.stl"), + ("configs/meshes/前側.stl", "configs", "meshes/前側.stl"), + ("configs/meshes/前側.stl", "configs/_tmp_offaxis", "../meshes/前側.stl"), + ( + "configs/_tmp_offaxis/meshes/立方体_L120.stl", + "configs/_tmp_offaxis", + "meshes/立方体_L120.stl", + ), + ("configs/meshes/a.stl", "configs/_tmp_x/deep/er", "../../../meshes/a.stl"), + ("other/b.stl", "configs/_tmp_x", "../../other/b.stl"), + ], +) +def test_relative_to_yaml_dir(tmp_path, target, yaml_dir, expected): + """YAML のディレクトリからの相対パスを POSIX 区切りで返すこと.""" + rel = relative_mesh_path(tmp_path / target, tmp_path / yaml_dir) + assert rel == expected + _assert_portable(rel) + + +def test_resolves_back_to_target(tmp_path): + """YAML のディレクトリ基準で解決すると実在のメッシュを指すこと. + + YAML のディレクトリは実在させておく(実運用でも YAML を書き出す場所なので必ずある). + POSIX ではパス中の `..` をカーネルがたどるため,手前のディレクトリが無いと + `_tmp_blend/../meshes/...` へのアクセスが ENOENT になる(Windows は `..` を文字列上で + 先に畳むので通ってしまい,差が出る). + """ + mesh = tmp_path / "configs" / "meshes" / "照明ムラ補正ボード.stl" + mesh.parent.mkdir(parents=True) + mesh.write_text("solid", encoding="utf-8") + yaml_dir = tmp_path / "configs" / "_tmp_blend" + yaml_dir.mkdir(parents=True) + rel = relative_mesh_path(mesh, yaml_dir) + assert (yaml_dir / rel).resolve() == mesh.resolve() + assert (yaml_dir / rel).is_file() + + +def test_yaml_dir_need_not_exist(tmp_path): + """書き出し前(YAML ディレクトリ未作成)でも計算できること.""" + rel = relative_mesh_path(tmp_path / "meshes" / "a.stl", tmp_path / "not" / "yet") + assert rel == "../../meshes/a.stl" + + +def test_relative_target_is_resolved_from_cwd(tmp_path, monkeypatch): + """target が相対パスなら CWD 基準で解釈すること.""" + monkeypatch.chdir(tmp_path) + rel = relative_mesh_path(Path("meshes") / "a.stl", tmp_path / "cfg") + assert rel == "../meshes/a.stl" + + +def test_repository_layout_gives_short_relative_path(): + """実リポジトリの configs/_tmp_* → configs/meshes は `../meshes/...` になること + (サーバの任意の場所に置いたリポジトリでもそのまま解決できる).""" + rel = relative_mesh_path( + _ROOT / "configs" / "meshes" / "前側.stl", _ROOT / "configs" / "_tmp_offaxis" + ) + assert rel == "../meshes/前側.stl" + + +def test_portable_path_is_not_flagged_by_push_warning(tmp_path): + """相対パスで書いた YAML は push の Windows 絶対パス警告に引っかからないこと.""" + rel = relative_mesh_path(tmp_path / "configs" / "meshes" / "a.stl", tmp_path / "configs" / "_t") + yaml_path = tmp_path / "configs" / "_t" / "scene.yaml" + yaml_path.parent.mkdir(parents=True) + yaml_path.write_text(f"geometry:\n meshes:\n - id: a\n path: {rel}\n", encoding="utf-8") + entry = PushEntry(path=yaml_path, arcname="configs/_t/scene.yaml", size=1) + assert find_yaml_with_windows_paths([entry]) == [] + + +# --------------------------------------------------------------------------- +# 相対化できない場合(Windows の別ドライブ)の扱い +# --------------------------------------------------------------------------- + + +def test_unrelatable_path_falls_back_to_absolute_with_warning(tmp_path, monkeypatch, caplog): + """os.path.relpath が ValueError(別ドライブ)のときは例外にせず,警告を出して + 解決済みの絶対パスを返すこと(従来挙動へのフォールバック).""" + + def _raise(*_args, **_kwargs): + raise ValueError("path is on mount 'D:', start on mount 'C:'") + + monkeypatch.setattr(paths.os.path, "relpath", _raise) + target = tmp_path / "meshes" / "a.stl" + with caplog.at_level(logging.WARNING, logger=paths.__name__): + result = relative_mesh_path(target, tmp_path / "cfg") + assert result == str(target.resolve()) + assert Path(result).is_absolute() + assert any(r.levelno == logging.WARNING for r in caplog.records) + + +def _other_drive_letter() -> str: + current = Path.cwd().drive.rstrip(":").upper() + for letter in reversed(string.ascii_uppercase): + if letter != current and letter != Path(os.environ.get("TEMP", "C:")).drive[:1].upper(): + return letter + raise RuntimeError("別ドライブ文字が見つかりません") + + +@pytest.mark.skipif(os.name != "nt", reason="ドライブ文字は Windows のみ") +def test_windows_other_drive_returns_absolute_and_push_warns(tmp_path, caplog): + """Windows で YAML とメッシュのドライブが違う場合: 絶対パスを返し(警告ログ付き), + その YAML を push すると Windows 絶対パスとして警告対象になること(安全網が効く).""" + letter = _other_drive_letter() + target = Path(f"{letter}:\\meshes\\a.stl") + with caplog.at_level(logging.WARNING, logger=paths.__name__): + result = relative_mesh_path(target, tmp_path) + assert PureWindowsPath(result).drive.upper() == f"{letter}:" + assert PureWindowsPath(result).is_absolute() + assert any(r.levelno == logging.WARNING for r in caplog.records) + + yaml_path = tmp_path / "scene.yaml" + yaml_path.write_text(f"geometry:\n meshes:\n - id: a\n path: {result}\n", encoding="utf-8") + entry = PushEntry(path=yaml_path, arcname="configs/_tmp_x/scene.yaml", size=1) + assert find_yaml_with_windows_paths([entry]) == ["configs/_tmp_x/scene.yaml"] diff --git a/tests/scripts/test_sweep_blend_bsdf.py b/tests/scripts/test_sweep_blend_bsdf.py index 614648b..b5fb26f 100644 --- a/tests/scripts/test_sweep_blend_bsdf.py +++ b/tests/scripts/test_sweep_blend_bsdf.py @@ -2,7 +2,9 @@ 仕様出典: 実装サマリー §E - E1: weight/alpha パラメータが一時 YAML に正しく書き込まれること -- E2: geometry.meshes の STL パスが絶対パスに変換されること +- E2: geometry.meshes の STL パスが一時 YAML からの相対パス(POSIX 区切り)に変換されること + (旧仕様は絶対パス化.Windows で生成した YAML を研究室サーバで読めないため 2026-09-13 に + scene/paths.py の relative_mesh_path による YAML 相対パスへ変更) - E3: 一時 YAML が BOM なし UTF-8 で書かれること - E4: name フィールドが "baseline_minitias_blend_{job_name}" 形式に更新されること @@ -15,7 +17,7 @@ from __future__ import annotations import sys -from pathlib import Path +from pathlib import Path, PurePosixPath, PureWindowsPath from types import ModuleType from unittest import mock @@ -158,70 +160,68 @@ # --------------------------------------------------------------------------- -# E2: geometry.meshes の path が絶対パスに変換されること +# E2: geometry.meshes の path が一時 YAML からの相対パス(POSIX 区切り)に変換されること # --------------------------------------------------------------------------- -class TestGenerateTmpYamlAbsolutePaths: - """E2 系: geometry.meshes の STL パスが絶対パスに変換されること.""" +class TestGenerateTmpYamlRelativePaths: + """E2 系: メッシュパスは一時 YAML のディレクトリからの相対パス(POSIX 区切り)で書かれ, + YAML の場所から解決するとテンプレートと同じ実在のメッシュを指すこと. + + 旧仕様(絶対パス化)は Windows で生成した YAML(C:\\Users\\...)を研究室サーバで + 読めないため廃止した(2026-09-13.scene/paths.py relative_mesh_path). + """ @pytest.fixture(autouse=True) def _patch_tmp_dir(self, tmp_path, sweep_module): """TMP_DIR を tmp_path に差し替える.""" + if tmp_path.resolve().drive.upper() != _ROOT.drive.upper(): + pytest.skip("tmp_path とリポジトリが別ドライブのため相対パスで表せない環境") with mock.patch.object(sweep_module, "TMP_DIR", tmp_path): yield - def test_all_mesh_paths_are_absolute(self, sweep_module): - """全メッシュの path が絶対パスになっていること. - - 仕様 E2: 一時 YAML の geometry.meshes の各 path が絶対パスに変換されている - """ + @staticmethod + def _generate(sweep_module) -> tuple[Path, list[dict]]: tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3) with tmp_yaml.open(encoding="utf-8") as f: data = yaml.safe_load(f) meshes = data["geometry"]["meshes"] assert len(meshes) > 0, "meshes が空のため検証不可" - for mesh in meshes: - p = Path(mesh["path"]) - assert p.is_absolute(), ( - f"mesh '{mesh.get('id')}' の path が絶対パスでない: {mesh['path']}" - ) + return tmp_yaml, meshes - def test_absolute_path_resolves_relative_to_template_dir(self, sweep_module): - """絶対化されたパスがテンプレート YAML の親ディレクトリ(configs/)基準で解決されること. + def test_no_mesh_path_is_absolute(self, sweep_module): + """全メッシュの path が(POSIX でも Windows でも)絶対パスでないこと. - 仕様 E2: template_base = TEMPLATE_YAML.parent(configs/)を基準に resolve + 仕様 E2: 絶対パスだと別マシン(研究室サーバ)で読めない """ - tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3) - with tmp_yaml.open(encoding="utf-8") as f: - data = yaml.safe_load(f) - meshes = data["geometry"]["meshes"] - template_base = _TEMPLATE_YAML.parent # configs/ + _, meshes = self._generate(sweep_module) for mesh in meshes: - p = Path(mesh["path"]) - # 絶対化されたパスは template_base 配下またはそのサブディレクトリにあること - # (relative path が "meshes/xxx.stl" → configs/meshes/xxx.stl の absolute) - try: - p.relative_to(template_base) - except ValueError: - pytest.fail( - f"mesh '{mesh.get('id')}' の絶対パス '{p}' が " - f"template_base '{template_base}' の下にありません(パス解決ロジックの不整合)" - ) + p = mesh["path"] + assert not PurePosixPath(p).is_absolute(), f"mesh '{mesh.get('id')}': {p}" + assert not PureWindowsPath(p).is_absolute(), f"mesh '{mesh.get('id')}': {p}" + assert not PureWindowsPath(p).drive, f"mesh '{mesh.get('id')}': {p}" - def test_no_mesh_path_is_relative(self, sweep_module): - """一時 YAML のメッシュパスに相対パスが含まれないこと. + def test_mesh_paths_use_posix_separator(self, sweep_module): + """区切り文字は / のみであること(Windows で生成しても Linux で読める).""" + _, meshes = self._generate(sweep_module) + for mesh in meshes: + assert "\\" not in mesh["path"], f"mesh '{mesh.get('id')}': {mesh['path']}" - 仕様 E2: 相対パスが残っていないことを確認 + def test_relative_path_resolves_to_template_mesh(self, sweep_module): + """YAML の場所から解決すると,テンプレート(configs/)基準の元のメッシュを指すこと. + + 仕様 E2: template_base = TEMPLATE_YAML.parent(configs/)基準の実体を, + 書き出し先 YAML のディレクトリからの相対パスで表す """ - tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3) - with tmp_yaml.open(encoding="utf-8") as f: - data = yaml.safe_load(f) - meshes = data["geometry"]["meshes"] + tmp_yaml, meshes = self._generate(sweep_module) + template = yaml.safe_load(_TEMPLATE_YAML.read_text(encoding="utf-8")) + originals = {m["id"]: m["path"] for m in template["geometry"]["meshes"]} + assert {m["id"] for m in meshes} == set(originals) for mesh in meshes: - assert Path(mesh["path"]).is_absolute(), ( - f"mesh '{mesh.get('id')}' の path が相対パス: {mesh['path']}" - ) + resolved = (tmp_yaml.parent / mesh["path"]).resolve() + expected = (_TEMPLATE_YAML.parent / originals[mesh["id"]]).resolve() + assert resolved == expected, f"mesh '{mesh.get('id')}': {mesh['path']}" + assert resolved.is_file(), f"mesh '{mesh.get('id')}' の実体がありません: {resolved}" # --------------------------------------------------------------------------- diff --git a/tests/scripts/test_sweep_relative_mesh_paths.py b/tests/scripts/test_sweep_relative_mesh_paths.py new file mode 100644 index 0000000..113a905 --- /dev/null +++ b/tests/scripts/test_sweep_relative_mesh_paths.py @@ -0,0 +1,178 @@ +"""スイープ生成スクリプトが一時 YAML に書くメッシュパスの仕様ベーステスト. + +仕様出典: +- 実装サマリー 仕様 10: スイープ生成スクリプトは _tmp YAML のメッシュパスを + YAML ファイルからの相対パス(POSIX 区切り)で書く(scene/paths.py relative_mesh_path). + 以前は絶対パスだった +- docs/02_ENV/ENV_04_開発コマンド.md「研究室サーバでの実行」注意 + (Windows 絶対パスの YAML はサーバで読めない.push 時に警告が出る) + +対象(9 本): sanity_led_groups / sweep_max_depth / sweep_led_distribution / sweep_reflectance / +sweep_blend_bsdf / sweep_bsdf_grid_fix / sweep_sphere_radius / sweep_cube_size / sweep_offaxis_led + +テスト方針: +- sm-render は呼ばない.YAML 生成関数だけを呼び,出力先は tmp_path に差し替える +- 「YAML の場所から解決すると元のメッシュ(テンプレートの実在メッシュ・生成メッシュ)を指す」 + 「どの OS でも相対パスとして読める(区切りは /,ドライブ無し)」 + 「push の Windows 絶対パス警告に掛からない」の 3 点を確かめる +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path, PurePosixPath, PureWindowsPath +from types import ModuleType +from unittest import mock + +import pytest +import yaml + +from sm_tias_lightsim.remote.files import PushEntry, find_yaml_with_windows_paths + +_ROOT = Path(__file__).resolve().parents[2] +_SCRIPTS = _ROOT / "scripts" + + +def _load(name: str) -> ModuleType: + """scripts/.py を import する(既に読み込み済みならそれを使う).""" + path = (_SCRIPTS / f"{name}.py").resolve() + cached = sys.modules.get(name) + if cached is not None and Path(getattr(cached, "__file__", "")).resolve() == path: + return cached + spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture(autouse=True) +def _same_drive(tmp_path): + if tmp_path.resolve().drive.upper() != _ROOT.drive.upper(): + pytest.skip("tmp_path とリポジトリが別ドライブのため相対パスで表せない環境") + + +def _template_targets(template: Path) -> dict[str, Path]: + """テンプレート YAML のメッシュ id → 実体(テンプレートのディレクトリ基準で解決).""" + data = yaml.safe_load(template.read_text(encoding="utf-8")) + return {m["id"]: (template.parent / m["path"]).resolve() for m in data["geometry"]["meshes"]} + + +def _assert_mesh_paths(yaml_path: Path, expected: dict[str, Path]) -> None: + data = yaml.safe_load(yaml_path.read_text(encoding="utf-8")) + meshes = data["geometry"]["meshes"] + assert {m["id"] for m in meshes} == set(expected) + for mesh in meshes: + path = mesh["path"] + assert isinstance(path, str) + assert "\\" not in path, f"{mesh['id']}: POSIX 区切りでない: {path}" + assert not PurePosixPath(path).is_absolute(), f"{mesh['id']}: 絶対パス: {path}" + assert not PureWindowsPath(path).is_absolute(), f"{mesh['id']}: 絶対パス: {path}" + assert not PureWindowsPath(path).drive, f"{mesh['id']}: ドライブ付き: {path}" + assert (yaml_path.parent / path).resolve() == expected[mesh["id"]], mesh["id"] + entry = PushEntry(path=yaml_path, arcname=f"configs/_tmp_x/{yaml_path.name}", size=0) + assert find_yaml_with_windows_paths([entry]) == [] + + +def _require_existing(targets: dict[str, Path]) -> None: + missing = [str(p) for p in targets.values() if not p.is_file()] + if missing: + pytest.skip(f"テンプレートのメッシュが実在しない環境: {missing}") + + +# --------------------------------------------------------------------------- +# テンプレートのメッシュをそのまま参照する 5 本(_generate_tmp_yaml) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("script", "args"), + [ + ("sanity_led_groups", ("bottom",)), + ("sweep_max_depth", (16,)), + ("sweep_led_distribution", (90,)), + ("sweep_reflectance", (0.85,)), + ("sweep_blend_bsdf", (0.15, 0.3)), + ], +) +def test_generate_tmp_yaml_writes_yaml_relative_paths(tmp_path, script, args): + module = _load(script) + tmp_dir = tmp_path / "configs" / f"_tmp_{script}" + with mock.patch.object(module, "TMP_DIR", tmp_dir): + yaml_path = module._generate_tmp_yaml(*args) + assert yaml_path.parent == tmp_dir + expected = _template_targets(module.TEMPLATE_YAML) + _require_existing(expected) + _assert_mesh_paths(yaml_path, expected) + + +def test_sweep_bsdf_grid_fix_writes_yaml_relative_paths(tmp_path): + module = _load("sweep_bsdf_grid_fix") + if not module.TEMPLATE_YAML.is_file(): + pytest.skip(f"テンプレートがありません: {module.TEMPLATE_YAML}") + expected = _template_targets(module.TEMPLATE_YAML) + _require_existing(expected) + dest = tmp_path / "configs" / "_tmp_bsdf_grid" / "r0.85_w0.05_a0.1" / "scene.yaml" + module.build_scene_yaml(0.85, 0.05, 0.1, dest) + _assert_mesh_paths(dest, expected) + + +# --------------------------------------------------------------------------- +# 生成メッシュ(筐体・バッフル)を参照する 3 本(build_scene_yaml) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("with_baffle", [True, False]) +def test_sweep_sphere_radius_writes_yaml_relative_paths(tmp_path, with_baffle): + module = _load("sweep_sphere_radius") + tmp_dir = tmp_path / "configs" / "_tmp_sphere_radius" + mesh = tmp_dir / "meshes" / "球_r100.stl" + baffle = tmp_dir / "meshes" / "バッフル_r100.stl" + dest = tmp_dir / "board_baffle" / "scene_r100.yaml" + module.build_scene_yaml( + 100.0, mesh, dest, "board_baffle", "similar_radial", baffle if with_baffle else None + ) + expected = _template_targets(module.TEMPLATE_YAML) + expected["sphere_housing"] = mesh.resolve() + if with_baffle: + expected["baffle"] = baffle.resolve() + _assert_mesh_paths(dest, expected) + paths = { + m["id"]: m["path"] + for m in yaml.safe_load(dest.read_text(encoding="utf-8"))["geometry"]["meshes"] + } + assert paths["sphere_housing"] == "../meshes/球_r100.stl" + if with_baffle: + assert paths["baffle"] == "../meshes/バッフル_r100.stl" + + +def test_sweep_cube_size_writes_yaml_relative_paths(tmp_path): + module = _load("sweep_cube_size") + tmp_dir = tmp_path / "configs" / "_tmp_cube_size" + mesh = tmp_dir / "meshes" / "立方体_L120.stl" + baffle = tmp_dir / "meshes" / "バッフル_L120.stl" + dest = tmp_dir / "scene_L120.yaml" + module.build_scene_yaml(120.0, mesh, dest, baffle) + expected = _template_targets(module.TEMPLATE_YAML) + expected.pop("sphere_housing") + expected["cube_housing"] = mesh.resolve() + expected["baffle"] = baffle.resolve() + _assert_mesh_paths(dest, expected) + + +@pytest.mark.parametrize(("shape", "size"), [("cube", 120.0), ("sphere", 100.0)]) +def test_sweep_offaxis_led_writes_yaml_relative_paths(tmp_path, shape, size): + module = _load("sweep_offaxis_led") + tmp_dir = tmp_path / "configs" / "_tmp_offaxis" + name = module.job_name(shape, size) + mesh = tmp_dir / "meshes" / f"{name}.stl" + baffle = tmp_dir / "meshes" / f"{name}_baffle.stl" + dest = tmp_dir / f"scene_{name}.yaml" + module.build_scene_yaml(shape, size, mesh, baffle, module.led_geometry(shape, size), dest) + expected = _template_targets(module.TEMPLATE_YAML) + expected.pop("sphere_housing") + expected["cube_housing" if shape == "cube" else "sphere_housing"] = mesh.resolve() + expected["baffle"] = baffle.resolve() + _assert_mesh_paths(dest, expected)