setiastrosuitepro 1.6.5.post3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (368) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/data/SASP_data.fits +0 -0
  3. setiastro/data/catalogs/List_of_Galaxies_with_Distances_Gly.csv +488 -0
  4. setiastro/data/catalogs/astrobin_filters.csv +890 -0
  5. setiastro/data/catalogs/astrobin_filters_page1_local.csv +51 -0
  6. setiastro/data/catalogs/cali2.csv +63 -0
  7. setiastro/data/catalogs/cali2color.csv +65 -0
  8. setiastro/data/catalogs/celestial_catalog - original.csv +16471 -0
  9. setiastro/data/catalogs/celestial_catalog.csv +24031 -0
  10. setiastro/data/catalogs/detected_stars.csv +24784 -0
  11. setiastro/data/catalogs/fits_header_data.csv +46 -0
  12. setiastro/data/catalogs/test.csv +8 -0
  13. setiastro/data/catalogs/updated_celestial_catalog.csv +16471 -0
  14. setiastro/images/Astro_Spikes.png +0 -0
  15. setiastro/images/Background_startup.jpg +0 -0
  16. setiastro/images/HRDiagram.png +0 -0
  17. setiastro/images/LExtract.png +0 -0
  18. setiastro/images/LInsert.png +0 -0
  19. setiastro/images/Oxygenation-atm-2.svg.png +0 -0
  20. setiastro/images/RGB080604.png +0 -0
  21. setiastro/images/abeicon.png +0 -0
  22. setiastro/images/aberration.png +0 -0
  23. setiastro/images/andromedatry.png +0 -0
  24. setiastro/images/andromedatry_satellited.png +0 -0
  25. setiastro/images/annotated.png +0 -0
  26. setiastro/images/aperture.png +0 -0
  27. setiastro/images/astrosuite.ico +0 -0
  28. setiastro/images/astrosuite.png +0 -0
  29. setiastro/images/astrosuitepro.icns +0 -0
  30. setiastro/images/astrosuitepro.ico +0 -0
  31. setiastro/images/astrosuitepro.png +0 -0
  32. setiastro/images/background.png +0 -0
  33. setiastro/images/background2.png +0 -0
  34. setiastro/images/benchmark.png +0 -0
  35. setiastro/images/big_moon_stabilizer_timeline.png +0 -0
  36. setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
  37. setiastro/images/blaster.png +0 -0
  38. setiastro/images/blink.png +0 -0
  39. setiastro/images/clahe.png +0 -0
  40. setiastro/images/collage.png +0 -0
  41. setiastro/images/colorwheel.png +0 -0
  42. setiastro/images/contsub.png +0 -0
  43. setiastro/images/convo.png +0 -0
  44. setiastro/images/copyslot.png +0 -0
  45. setiastro/images/cosmic.png +0 -0
  46. setiastro/images/cosmicsat.png +0 -0
  47. setiastro/images/crop1.png +0 -0
  48. setiastro/images/cropicon.png +0 -0
  49. setiastro/images/curves.png +0 -0
  50. setiastro/images/cvs.png +0 -0
  51. setiastro/images/debayer.png +0 -0
  52. setiastro/images/denoise_cnn_custom.png +0 -0
  53. setiastro/images/denoise_cnn_graph.png +0 -0
  54. setiastro/images/disk.png +0 -0
  55. setiastro/images/dse.png +0 -0
  56. setiastro/images/exoicon.png +0 -0
  57. setiastro/images/eye.png +0 -0
  58. setiastro/images/fliphorizontal.png +0 -0
  59. setiastro/images/flipvertical.png +0 -0
  60. setiastro/images/font.png +0 -0
  61. setiastro/images/freqsep.png +0 -0
  62. setiastro/images/functionbundle.png +0 -0
  63. setiastro/images/graxpert.png +0 -0
  64. setiastro/images/green.png +0 -0
  65. setiastro/images/gridicon.png +0 -0
  66. setiastro/images/halo.png +0 -0
  67. setiastro/images/hdr.png +0 -0
  68. setiastro/images/histogram.png +0 -0
  69. setiastro/images/hubble.png +0 -0
  70. setiastro/images/imagecombine.png +0 -0
  71. setiastro/images/invert.png +0 -0
  72. setiastro/images/isophote.png +0 -0
  73. setiastro/images/isophote_demo_figure.png +0 -0
  74. setiastro/images/isophote_demo_image.png +0 -0
  75. setiastro/images/isophote_demo_model.png +0 -0
  76. setiastro/images/isophote_demo_residual.png +0 -0
  77. setiastro/images/jwstpupil.png +0 -0
  78. setiastro/images/linearfit.png +0 -0
  79. setiastro/images/livestacking.png +0 -0
  80. setiastro/images/mask.png +0 -0
  81. setiastro/images/maskapply.png +0 -0
  82. setiastro/images/maskcreate.png +0 -0
  83. setiastro/images/maskremove.png +0 -0
  84. setiastro/images/morpho.png +0 -0
  85. setiastro/images/mosaic.png +0 -0
  86. setiastro/images/multiscale_decomp.png +0 -0
  87. setiastro/images/nbtorgb.png +0 -0
  88. setiastro/images/neutral.png +0 -0
  89. setiastro/images/nuke.png +0 -0
  90. setiastro/images/openfile.png +0 -0
  91. setiastro/images/pedestal.png +0 -0
  92. setiastro/images/pen.png +0 -0
  93. setiastro/images/pixelmath.png +0 -0
  94. setiastro/images/platesolve.png +0 -0
  95. setiastro/images/ppp.png +0 -0
  96. setiastro/images/pro.png +0 -0
  97. setiastro/images/project.png +0 -0
  98. setiastro/images/psf.png +0 -0
  99. setiastro/images/redo.png +0 -0
  100. setiastro/images/redoicon.png +0 -0
  101. setiastro/images/rescale.png +0 -0
  102. setiastro/images/rgbalign.png +0 -0
  103. setiastro/images/rgbcombo.png +0 -0
  104. setiastro/images/rgbextract.png +0 -0
  105. setiastro/images/rotate180.png +0 -0
  106. setiastro/images/rotatearbitrary.png +0 -0
  107. setiastro/images/rotateclockwise.png +0 -0
  108. setiastro/images/rotatecounterclockwise.png +0 -0
  109. setiastro/images/satellite.png +0 -0
  110. setiastro/images/script.png +0 -0
  111. setiastro/images/selectivecolor.png +0 -0
  112. setiastro/images/simbad.png +0 -0
  113. setiastro/images/slot0.png +0 -0
  114. setiastro/images/slot1.png +0 -0
  115. setiastro/images/slot2.png +0 -0
  116. setiastro/images/slot3.png +0 -0
  117. setiastro/images/slot4.png +0 -0
  118. setiastro/images/slot5.png +0 -0
  119. setiastro/images/slot6.png +0 -0
  120. setiastro/images/slot7.png +0 -0
  121. setiastro/images/slot8.png +0 -0
  122. setiastro/images/slot9.png +0 -0
  123. setiastro/images/spcc.png +0 -0
  124. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  125. setiastro/images/spinner.gif +0 -0
  126. setiastro/images/stacking.png +0 -0
  127. setiastro/images/staradd.png +0 -0
  128. setiastro/images/staralign.png +0 -0
  129. setiastro/images/starnet.png +0 -0
  130. setiastro/images/starregistration.png +0 -0
  131. setiastro/images/starspike.png +0 -0
  132. setiastro/images/starstretch.png +0 -0
  133. setiastro/images/statstretch.png +0 -0
  134. setiastro/images/supernova.png +0 -0
  135. setiastro/images/uhs.png +0 -0
  136. setiastro/images/undoicon.png +0 -0
  137. setiastro/images/upscale.png +0 -0
  138. setiastro/images/viewbundle.png +0 -0
  139. setiastro/images/whitebalance.png +0 -0
  140. setiastro/images/wimi_icon_256x256.png +0 -0
  141. setiastro/images/wimilogo.png +0 -0
  142. setiastro/images/wims.png +0 -0
  143. setiastro/images/wrench_icon.png +0 -0
  144. setiastro/images/xisfliberator.png +0 -0
  145. setiastro/qml/ResourceMonitor.qml +126 -0
  146. setiastro/saspro/__init__.py +20 -0
  147. setiastro/saspro/__main__.py +958 -0
  148. setiastro/saspro/_generated/__init__.py +7 -0
  149. setiastro/saspro/_generated/build_info.py +3 -0
  150. setiastro/saspro/abe.py +1346 -0
  151. setiastro/saspro/abe_preset.py +196 -0
  152. setiastro/saspro/aberration_ai.py +698 -0
  153. setiastro/saspro/aberration_ai_preset.py +224 -0
  154. setiastro/saspro/accel_installer.py +218 -0
  155. setiastro/saspro/accel_workers.py +30 -0
  156. setiastro/saspro/add_stars.py +624 -0
  157. setiastro/saspro/astrobin_exporter.py +1010 -0
  158. setiastro/saspro/astrospike.py +153 -0
  159. setiastro/saspro/astrospike_python.py +1841 -0
  160. setiastro/saspro/autostretch.py +198 -0
  161. setiastro/saspro/backgroundneutral.py +611 -0
  162. setiastro/saspro/batch_convert.py +328 -0
  163. setiastro/saspro/batch_renamer.py +522 -0
  164. setiastro/saspro/blemish_blaster.py +491 -0
  165. setiastro/saspro/blink_comparator_pro.py +3149 -0
  166. setiastro/saspro/bundles.py +61 -0
  167. setiastro/saspro/bundles_dock.py +114 -0
  168. setiastro/saspro/cheat_sheet.py +213 -0
  169. setiastro/saspro/clahe.py +368 -0
  170. setiastro/saspro/comet_stacking.py +1442 -0
  171. setiastro/saspro/common_tr.py +107 -0
  172. setiastro/saspro/config.py +38 -0
  173. setiastro/saspro/config_bootstrap.py +40 -0
  174. setiastro/saspro/config_manager.py +316 -0
  175. setiastro/saspro/continuum_subtract.py +1617 -0
  176. setiastro/saspro/convo.py +1400 -0
  177. setiastro/saspro/convo_preset.py +414 -0
  178. setiastro/saspro/copyastro.py +190 -0
  179. setiastro/saspro/cosmicclarity.py +1589 -0
  180. setiastro/saspro/cosmicclarity_preset.py +407 -0
  181. setiastro/saspro/crop_dialog_pro.py +983 -0
  182. setiastro/saspro/crop_preset.py +189 -0
  183. setiastro/saspro/curve_editor_pro.py +2562 -0
  184. setiastro/saspro/curves_preset.py +375 -0
  185. setiastro/saspro/debayer.py +673 -0
  186. setiastro/saspro/debug_utils.py +29 -0
  187. setiastro/saspro/dnd_mime.py +35 -0
  188. setiastro/saspro/doc_manager.py +2664 -0
  189. setiastro/saspro/exoplanet_detector.py +2166 -0
  190. setiastro/saspro/file_utils.py +284 -0
  191. setiastro/saspro/fitsmodifier.py +748 -0
  192. setiastro/saspro/fix_bom.py +32 -0
  193. setiastro/saspro/free_torch_memory.py +48 -0
  194. setiastro/saspro/frequency_separation.py +1349 -0
  195. setiastro/saspro/function_bundle.py +1596 -0
  196. setiastro/saspro/generate_translations.py +3092 -0
  197. setiastro/saspro/ghs_dialog_pro.py +663 -0
  198. setiastro/saspro/ghs_preset.py +284 -0
  199. setiastro/saspro/graxpert.py +637 -0
  200. setiastro/saspro/graxpert_preset.py +287 -0
  201. setiastro/saspro/gui/__init__.py +0 -0
  202. setiastro/saspro/gui/main_window.py +8792 -0
  203. setiastro/saspro/gui/mixins/__init__.py +33 -0
  204. setiastro/saspro/gui/mixins/dock_mixin.py +375 -0
  205. setiastro/saspro/gui/mixins/file_mixin.py +450 -0
  206. setiastro/saspro/gui/mixins/geometry_mixin.py +503 -0
  207. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  208. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  209. setiastro/saspro/gui/mixins/menu_mixin.py +390 -0
  210. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  211. setiastro/saspro/gui/mixins/toolbar_mixin.py +1619 -0
  212. setiastro/saspro/gui/mixins/update_mixin.py +323 -0
  213. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  214. setiastro/saspro/gui/statistics_dialog.py +47 -0
  215. setiastro/saspro/halobgon.py +488 -0
  216. setiastro/saspro/header_viewer.py +448 -0
  217. setiastro/saspro/headless_utils.py +88 -0
  218. setiastro/saspro/histogram.py +756 -0
  219. setiastro/saspro/history_explorer.py +941 -0
  220. setiastro/saspro/i18n.py +168 -0
  221. setiastro/saspro/image_combine.py +417 -0
  222. setiastro/saspro/image_peeker_pro.py +1604 -0
  223. setiastro/saspro/imageops/__init__.py +37 -0
  224. setiastro/saspro/imageops/mdi_snap.py +292 -0
  225. setiastro/saspro/imageops/scnr.py +36 -0
  226. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  227. setiastro/saspro/imageops/stretch.py +236 -0
  228. setiastro/saspro/isophote.py +1182 -0
  229. setiastro/saspro/layers.py +208 -0
  230. setiastro/saspro/layers_dock.py +714 -0
  231. setiastro/saspro/lazy_imports.py +193 -0
  232. setiastro/saspro/legacy/__init__.py +2 -0
  233. setiastro/saspro/legacy/image_manager.py +2360 -0
  234. setiastro/saspro/legacy/numba_utils.py +3676 -0
  235. setiastro/saspro/legacy/xisf.py +1213 -0
  236. setiastro/saspro/linear_fit.py +537 -0
  237. setiastro/saspro/live_stacking.py +1854 -0
  238. setiastro/saspro/log_bus.py +5 -0
  239. setiastro/saspro/logging_config.py +460 -0
  240. setiastro/saspro/luminancerecombine.py +510 -0
  241. setiastro/saspro/main_helpers.py +201 -0
  242. setiastro/saspro/mask_creation.py +1086 -0
  243. setiastro/saspro/masks_core.py +56 -0
  244. setiastro/saspro/mdi_widgets.py +353 -0
  245. setiastro/saspro/memory_utils.py +666 -0
  246. setiastro/saspro/metadata_patcher.py +75 -0
  247. setiastro/saspro/mfdeconv.py +3909 -0
  248. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  249. setiastro/saspro/mfdeconvcudnn.py +3312 -0
  250. setiastro/saspro/mfdeconvsport.py +2459 -0
  251. setiastro/saspro/minorbodycatalog.py +567 -0
  252. setiastro/saspro/morphology.py +407 -0
  253. setiastro/saspro/multiscale_decomp.py +1747 -0
  254. setiastro/saspro/nbtorgb_stars.py +541 -0
  255. setiastro/saspro/numba_utils.py +3145 -0
  256. setiastro/saspro/numba_warmup.py +141 -0
  257. setiastro/saspro/ops/__init__.py +9 -0
  258. setiastro/saspro/ops/command_help_dialog.py +623 -0
  259. setiastro/saspro/ops/command_runner.py +217 -0
  260. setiastro/saspro/ops/commands.py +1594 -0
  261. setiastro/saspro/ops/script_editor.py +1105 -0
  262. setiastro/saspro/ops/scripts.py +1476 -0
  263. setiastro/saspro/ops/settings.py +637 -0
  264. setiastro/saspro/parallel_utils.py +554 -0
  265. setiastro/saspro/pedestal.py +121 -0
  266. setiastro/saspro/perfect_palette_picker.py +1105 -0
  267. setiastro/saspro/pipeline.py +110 -0
  268. setiastro/saspro/pixelmath.py +1604 -0
  269. setiastro/saspro/plate_solver.py +2445 -0
  270. setiastro/saspro/project_io.py +797 -0
  271. setiastro/saspro/psf_utils.py +136 -0
  272. setiastro/saspro/psf_viewer.py +549 -0
  273. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  274. setiastro/saspro/remove_green.py +331 -0
  275. setiastro/saspro/remove_stars.py +1599 -0
  276. setiastro/saspro/remove_stars_preset.py +446 -0
  277. setiastro/saspro/resources.py +503 -0
  278. setiastro/saspro/rgb_combination.py +208 -0
  279. setiastro/saspro/rgb_extract.py +19 -0
  280. setiastro/saspro/rgbalign.py +723 -0
  281. setiastro/saspro/runtime_imports.py +7 -0
  282. setiastro/saspro/runtime_torch.py +754 -0
  283. setiastro/saspro/save_options.py +73 -0
  284. setiastro/saspro/selective_color.py +1611 -0
  285. setiastro/saspro/sfcc.py +1472 -0
  286. setiastro/saspro/shortcuts.py +3116 -0
  287. setiastro/saspro/signature_insert.py +1102 -0
  288. setiastro/saspro/stacking_suite.py +19066 -0
  289. setiastro/saspro/star_alignment.py +7380 -0
  290. setiastro/saspro/star_alignment_preset.py +329 -0
  291. setiastro/saspro/star_metrics.py +49 -0
  292. setiastro/saspro/star_spikes.py +765 -0
  293. setiastro/saspro/star_stretch.py +507 -0
  294. setiastro/saspro/stat_stretch.py +538 -0
  295. setiastro/saspro/status_log_dock.py +78 -0
  296. setiastro/saspro/subwindow.py +3407 -0
  297. setiastro/saspro/supernovaasteroidhunter.py +1719 -0
  298. setiastro/saspro/swap_manager.py +134 -0
  299. setiastro/saspro/torch_backend.py +89 -0
  300. setiastro/saspro/torch_rejection.py +434 -0
  301. setiastro/saspro/translations/all_source_strings.json +4726 -0
  302. setiastro/saspro/translations/ar_translations.py +4096 -0
  303. setiastro/saspro/translations/de_translations.py +3728 -0
  304. setiastro/saspro/translations/es_translations.py +4169 -0
  305. setiastro/saspro/translations/fr_translations.py +4090 -0
  306. setiastro/saspro/translations/hi_translations.py +3803 -0
  307. setiastro/saspro/translations/integrate_translations.py +271 -0
  308. setiastro/saspro/translations/it_translations.py +4728 -0
  309. setiastro/saspro/translations/ja_translations.py +3834 -0
  310. setiastro/saspro/translations/pt_translations.py +3847 -0
  311. setiastro/saspro/translations/ru_translations.py +3082 -0
  312. setiastro/saspro/translations/saspro_ar.qm +0 -0
  313. setiastro/saspro/translations/saspro_ar.ts +16019 -0
  314. setiastro/saspro/translations/saspro_de.qm +0 -0
  315. setiastro/saspro/translations/saspro_de.ts +14548 -0
  316. setiastro/saspro/translations/saspro_es.qm +0 -0
  317. setiastro/saspro/translations/saspro_es.ts +16202 -0
  318. setiastro/saspro/translations/saspro_fr.qm +0 -0
  319. setiastro/saspro/translations/saspro_fr.ts +15870 -0
  320. setiastro/saspro/translations/saspro_hi.qm +0 -0
  321. setiastro/saspro/translations/saspro_hi.ts +14855 -0
  322. setiastro/saspro/translations/saspro_it.qm +0 -0
  323. setiastro/saspro/translations/saspro_it.ts +19046 -0
  324. setiastro/saspro/translations/saspro_ja.qm +0 -0
  325. setiastro/saspro/translations/saspro_ja.ts +14980 -0
  326. setiastro/saspro/translations/saspro_pt.qm +0 -0
  327. setiastro/saspro/translations/saspro_pt.ts +15024 -0
  328. setiastro/saspro/translations/saspro_ru.qm +0 -0
  329. setiastro/saspro/translations/saspro_ru.ts +11835 -0
  330. setiastro/saspro/translations/saspro_sw.qm +0 -0
  331. setiastro/saspro/translations/saspro_sw.ts +15237 -0
  332. setiastro/saspro/translations/saspro_uk.qm +0 -0
  333. setiastro/saspro/translations/saspro_uk.ts +15248 -0
  334. setiastro/saspro/translations/saspro_zh.qm +0 -0
  335. setiastro/saspro/translations/saspro_zh.ts +15289 -0
  336. setiastro/saspro/translations/sw_translations.py +3897 -0
  337. setiastro/saspro/translations/uk_translations.py +3929 -0
  338. setiastro/saspro/translations/zh_translations.py +3910 -0
  339. setiastro/saspro/versioning.py +77 -0
  340. setiastro/saspro/view_bundle.py +1558 -0
  341. setiastro/saspro/wavescale_hdr.py +645 -0
  342. setiastro/saspro/wavescale_hdr_preset.py +101 -0
  343. setiastro/saspro/wavescalede.py +680 -0
  344. setiastro/saspro/wavescalede_preset.py +230 -0
  345. setiastro/saspro/wcs_update.py +374 -0
  346. setiastro/saspro/whitebalance.py +513 -0
  347. setiastro/saspro/widgets/__init__.py +48 -0
  348. setiastro/saspro/widgets/common_utilities.py +306 -0
  349. setiastro/saspro/widgets/graphics_views.py +122 -0
  350. setiastro/saspro/widgets/image_utils.py +518 -0
  351. setiastro/saspro/widgets/minigame/game.js +991 -0
  352. setiastro/saspro/widgets/minigame/index.html +53 -0
  353. setiastro/saspro/widgets/minigame/style.css +241 -0
  354. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  355. setiastro/saspro/widgets/resource_monitor.py +263 -0
  356. setiastro/saspro/widgets/spinboxes.py +290 -0
  357. setiastro/saspro/widgets/themed_buttons.py +13 -0
  358. setiastro/saspro/widgets/wavelet_utils.py +331 -0
  359. setiastro/saspro/wimi.py +7996 -0
  360. setiastro/saspro/wims.py +578 -0
  361. setiastro/saspro/window_shelf.py +185 -0
  362. setiastro/saspro/xisf.py +1213 -0
  363. setiastrosuitepro-1.6.5.post3.dist-info/METADATA +278 -0
  364. setiastrosuitepro-1.6.5.post3.dist-info/RECORD +368 -0
  365. setiastrosuitepro-1.6.5.post3.dist-info/WHEEL +4 -0
  366. setiastrosuitepro-1.6.5.post3.dist-info/entry_points.txt +6 -0
  367. setiastrosuitepro-1.6.5.post3.dist-info/licenses/LICENSE +674 -0
  368. setiastrosuitepro-1.6.5.post3.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,754 @@
1
+ # pro/runtime_torch.py (hardened against shadowing / broken wheels)
2
+ from __future__ import annotations
3
+ import os
4
+ import sys
5
+ import subprocess
6
+ import platform
7
+ import shutil
8
+ import json
9
+ import time
10
+ import errno
11
+ import importlib
12
+ import re
13
+ from pathlib import Path
14
+ from contextlib import contextmanager
15
+
16
+ import platform as _plat
17
+ from pathlib import Path as _Path
18
+
19
+ def _maybe_find_torch_shm_manager(torch_mod) -> str | None:
20
+ # Only Linux wheels include/use this helper binary.
21
+ if _plat.system() != "Linux":
22
+ return None
23
+ try:
24
+ base = _Path(getattr(torch_mod, "__file__", "")).parent
25
+ p = base / "bin" / "torch_shm_manager"
26
+ return str(p) if p.exists() else None
27
+ except Exception:
28
+ return None
29
+
30
+ # ──────────────────────────────────────────────────────────────────────────────
31
+ # Paths & runtime selection
32
+ # ──────────────────────────────────────────────────────────────────────────────
33
+ def _venv_pyver(venv_python: Path) -> tuple[int, int] | None:
34
+ """Return (major, minor) for the venv interpreter, or None if unknown."""
35
+ try:
36
+ out = subprocess.check_output(
37
+ [str(venv_python), "-c", "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')"],
38
+ text=True,
39
+ ).strip()
40
+ maj, min_ = out.split(".")
41
+ return int(maj), int(min_)
42
+ except Exception:
43
+ return None
44
+
45
+ def _tag_for_pyver(maj: int, min_: int) -> str:
46
+ return f"py{maj}{min_}"
47
+
48
+ def _runtime_base_dir() -> Path:
49
+ """
50
+ Base folder that may contain multiple versioned runtimes (py310, py311, py312...).
51
+ Overridable via SASPRO_RUNTIME_DIR (which points to the parent "runtime" dir).
52
+ """
53
+ env_override = os.getenv("SASPRO_RUNTIME_DIR")
54
+ if env_override:
55
+ base = Path(env_override).expanduser().resolve()
56
+ else:
57
+ sysname = platform.system()
58
+ if sysname == "Windows":
59
+ base = Path(os.getenv("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
60
+ elif sysname == "Darwin":
61
+ base = Path.home() / "Library" / "Application Support"
62
+ else:
63
+ base = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local" / "share"))
64
+ base = base / "SASpro" / "runtime"
65
+ return base
66
+
67
+ def _current_tag() -> str:
68
+ return f"py{sys.version_info.major}{sys.version_info.minor}"
69
+
70
+ def _discover_existing_runtime_dir() -> Path | None:
71
+ """
72
+ Return the newest existing runtime dir that already has a venv python,
73
+ using the venv interpreter's REAL version instead of just the folder name.
74
+ """
75
+ base = _runtime_base_dir()
76
+ if not base.exists():
77
+ return None
78
+ candidates: list[tuple[int, int, Path]] = []
79
+ for p in base.glob("py*"):
80
+ vpy = p / "venv" / ("Scripts/python.exe" if platform.system() == "Windows" else "bin/python")
81
+ if not vpy.exists():
82
+ continue
83
+ ver = _venv_pyver(vpy)
84
+ if ver:
85
+ candidates.append((ver[0], ver[1], p))
86
+ if not candidates:
87
+ return None
88
+ candidates.sort() # pick the highest Python (major, minor)
89
+ return candidates[-1][2]
90
+
91
+ def _user_runtime_dir() -> Path:
92
+ """
93
+ Use an existing runtime if we find one; otherwise select a directory for the
94
+ current interpreter version (py310/py311/py312...).
95
+ """
96
+ existing = _discover_existing_runtime_dir()
97
+ return existing or (_runtime_base_dir() / _current_tag())
98
+
99
+ # ──────────────────────────────────────────────────────────────────────────────
100
+ # Shadowing & sanity checks
101
+ # ──────────────────────────────────────────────────────────────────────────────
102
+
103
+ # ──────────────────────────────────────────────────────────────────────────────
104
+ # Shadowing & sanity checks
105
+ # ──────────────────────────────────────────────────────────────────────────────
106
+
107
+ def _is_compiled_torch_dir(d: Path) -> bool:
108
+ """True if 'torch' directory contains the compiled extension files."""
109
+ return any(d.glob("_C.*.pyd")) or any(d.glob("_C.*.so")) or any(d.glob("_C.cpython*"))
110
+
111
+ def _looks_like_source_tree_torch(d: Path) -> bool:
112
+ """
113
+ True if this is a PyTorch repo / editable install dir (has torch/_C/__init__.py).
114
+ These can *never* satisfy torch._C at runtime.
115
+ """
116
+ return (d / "_C" / "__init__.py").exists()
117
+
118
+ def _ban_shadow_torch_paths(status_cb=print) -> None:
119
+ """
120
+ Remove (not just demote) any sys.path entries that would cause a source-tree
121
+ import of torch to win over the wheel. Also handles CWD ('') and editable installs.
122
+ """
123
+ keep: list[str] = []
124
+ banned: list[str] = []
125
+
126
+ for entry in list(sys.path):
127
+ try:
128
+ base = Path(entry) if entry else Path.cwd()
129
+ td = base / "torch"
130
+ if td.is_dir():
131
+ # (a) repo/editable: has torch/_C/__init__.py → ban outright
132
+ if _looks_like_source_tree_torch(td):
133
+ banned.append(entry or "<cwd>")
134
+ continue
135
+ # (b) any 'torch' dir without compiled _C.* → ban (cannot work at runtime)
136
+ if not _is_compiled_torch_dir(td):
137
+ banned.append(entry or "<cwd>")
138
+ continue
139
+ except Exception:
140
+ # if we can't inspect, keep it
141
+ pass
142
+ keep.append(entry)
143
+
144
+ if banned:
145
+ sys.path[:] = keep
146
+ try:
147
+ status_cb("Removed shadowing torch paths: " + ", ".join(banned))
148
+ except Exception:
149
+ pass
150
+
151
+ _demote_shadow_torch_paths = _ban_shadow_torch_paths
152
+
153
+ def _purge_bad_torch_from_sysmodules(status_cb=print) -> None:
154
+ """
155
+ If 'torch' is already imported from a shadow location, drop it so we can
156
+ re-import from the wheel after cleaning sys.path.
157
+ """
158
+ try:
159
+ import importlib
160
+ if "torch" in sys.modules:
161
+ mod = sys.modules["torch"]
162
+ tf = getattr(mod, "__file__", "") or ""
163
+ if tf and (("site-packages" not in tf) and ("dist-packages" not in tf)):
164
+ # definitely a shadow import
165
+ for k in list(sys.modules.keys()):
166
+ if k == "torch" or k.startswith("torch."):
167
+ sys.modules.pop(k, None)
168
+ status_cb(f"Purged shadowed torch import: {tf}")
169
+ # Always ensure we don't carry a stale extension handle
170
+ sys.modules.pop("torch._C", None)
171
+ importlib.invalidate_caches()
172
+ except Exception:
173
+ pass
174
+
175
+ def _torch_sanity_check(status_cb=print):
176
+ try:
177
+ import torch
178
+ import importlib
179
+ tf = getattr(torch, "__file__", "") or ""
180
+ pkg_dir = Path(tf).parent if tf else None
181
+
182
+ # must come from site/dist packages
183
+ if ("site-packages" not in tf) and ("dist-packages" not in tf):
184
+ raise RuntimeError(f"Shadow import: torch.__file__ = {tf}")
185
+
186
+ # compiled extension must exist, and 'torch/_C/__init__.py' must NOT
187
+ if not _is_compiled_torch_dir(pkg_dir):
188
+ raise RuntimeError(f"Wheel missing torch._C in {pkg_dir}")
189
+ if (pkg_dir / "_C" / "__init__.py").exists():
190
+ raise RuntimeError(f"Found package folder torch/_C at {pkg_dir/'_C'}, this indicates a source tree.")
191
+
192
+ importlib.import_module("torch._C") # force extension load
193
+
194
+ x = torch.ones(1); y = x + 1
195
+ if int(y.item()) != 2:
196
+ raise RuntimeError("Unexpected tensor arithmetic result from torch sanity op.")
197
+ except Exception as e:
198
+ raise RuntimeError(f"PyTorch C-extension check failed: {e}") from e
199
+
200
+ # ──────────────────────────────────────────────────────────────────────────────
201
+ # OS / permissions helpers
202
+ # ──────────────────────────────────────────────────────────────────────────────
203
+
204
+ def _pip_run(venv_python: Path, args: list[str], status_cb=print) -> subprocess.CompletedProcess:
205
+ env = os.environ.copy()
206
+ env.pop("PYTHONPATH", None)
207
+ env.pop("PYTHONHOME", None)
208
+ return subprocess.run([str(venv_python), "-m", "pip", *args],
209
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env)
210
+
211
+ def _pip_ok(venv_python: Path, args: list[str], status_cb=print) -> bool:
212
+ r = _pip_run(venv_python, args, status_cb=status_cb)
213
+ if r.returncode != 0:
214
+ tail = (r.stdout or "").strip()
215
+ try: status_cb(tail[-4000:])
216
+ except Exception as e:
217
+ import logging
218
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
219
+ return r.returncode == 0
220
+
221
+ def _ensure_numpy(venv_python: Path, status_cb=print) -> None:
222
+ """
223
+ Torch wheels may not pull NumPy; ensure NumPy is present in the SAME venv.
224
+ Safe to call repeatedly.
225
+ """
226
+ def _numpy_present() -> bool:
227
+ code = "import importlib.util; print('OK' if importlib.util.find_spec('numpy') else 'MISS')"
228
+ try:
229
+ out = subprocess.check_output([str(venv_python), "-c", code], text=True).strip()
230
+ return (out == "OK")
231
+ except Exception:
232
+ return False
233
+
234
+ if _numpy_present():
235
+ return
236
+
237
+ # Keep tools fresh, then install a compatible NumPy (Torch 2.x is fine with NumPy 1.26–2.x)
238
+ _pip_ok(venv_python, ["install", "--upgrade", "pip", "setuptools", "wheel"], status_cb=status_cb)
239
+
240
+ # Prefer latest available in [1.26, 3.0)
241
+ if not _pip_ok(venv_python, ["install", "--prefer-binary", "--no-cache-dir", "numpy>=1.26,<3"], status_cb=status_cb):
242
+ # Final fallback to a broadly available pin
243
+ _pip_ok(venv_python, ["install", "--prefer-binary", "--no-cache-dir", "numpy==1.26.*"], status_cb=status_cb)
244
+
245
+ # Post-install verification
246
+ if not _numpy_present():
247
+ raise RuntimeError("Failed to install NumPy into the SASpro runtime venv.")
248
+
249
+
250
+ def _is_access_denied(exc: BaseException) -> bool:
251
+ if not isinstance(exc, OSError):
252
+ return False
253
+ if getattr(exc, "errno", None) == errno.EACCES:
254
+ return True
255
+ return getattr(exc, "winerror", None) == 5 # ERROR_ACCESS_DENIED
256
+
257
+ def _access_denied_msg(base_path: Path) -> str:
258
+ return (
259
+ "Access denied while preparing the SASpro runtime at:\n"
260
+ f" {base_path}\n\n"
261
+ "Possible causes:\n"
262
+ " • A corporate policy blocks writing to %LOCALAPPDATA%.\n"
263
+ " • Security software is sandboxing the app.\n\n"
264
+ "Fixes:\n"
265
+ " 1) Run SASpro once as Administrator (right-click → Run as administrator), or\n"
266
+ " 2) Set an alternate writable folder via environment variable SASPRO_RUNTIME_DIR\n"
267
+ " (e.g. C:\\Users\\<you>\\SASproRuntime) and relaunch."
268
+ )
269
+
270
+ # ──────────────────────────────────────────────────────────────────────────────
271
+ # Venv creation & site discovery
272
+ # ──────────────────────────────────────────────────────────────────────────────
273
+
274
+ def _venv_paths(rt: Path):
275
+ return {
276
+ "venv": rt / "venv",
277
+ "python": (rt / "venv" / "Scripts" / "python.exe") if platform.system() == "Windows" else (rt / "venv" / "bin" / "python"),
278
+ "marker": rt / "torch_installed.json",
279
+ }
280
+
281
+ def _site_packages(venv_python: Path) -> Path:
282
+ code = "import site, sys; print([p for p in site.getsitepackages() if 'site-packages' in p][-1])"
283
+ out = subprocess.check_output([str(venv_python), "-c", code], text=True).strip()
284
+ return Path(out)
285
+
286
+ def _ensure_venv(rt: Path, status_cb=print) -> Path:
287
+ p = _venv_paths(rt)
288
+ if not p["python"].exists():
289
+ try:
290
+ status_cb(f"Setting up SASpro runtime venv at: {p['venv']}")
291
+ p["venv"].mkdir(parents=True, exist_ok=True)
292
+
293
+ # choose the system python that will back this venv
294
+ py_cmd = _find_system_python_cmd() if getattr(sys, "frozen", False) else [sys.executable]
295
+ # detect its version to ensure the folder tag matches
296
+ out = subprocess.check_output(py_cmd + ["-c", "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')"], text=True).strip()
297
+ maj, min_ = map(int, out.split("."))
298
+ desired_tag = _tag_for_pyver(maj, min_)
299
+ if rt.name != desired_tag:
300
+ rt = _runtime_base_dir() / desired_tag
301
+ p = _venv_paths(rt)
302
+ status_cb(f"Adjusted runtime folder to match Python {maj}.{min_}: {rt}")
303
+ p["venv"].mkdir(parents=True, exist_ok=True)
304
+
305
+ env = os.environ.copy(); env.pop("PYTHONHOME", None); env.pop("PYTHONPATH", None)
306
+ subprocess.check_call(py_cmd + ["-m", "venv", str(p["venv"])], env=env)
307
+ subprocess.check_call([str(p["python"]), "-m", "ensurepip", "--upgrade"], env=env)
308
+ subprocess.check_call([str(p["python"]), "-m", "pip", "install", "--upgrade", "pip", "wheel", "setuptools"], env=env)
309
+ except subprocess.CalledProcessError:
310
+ try:
311
+ if p["venv"].exists():
312
+ shutil.rmtree(p["venv"], ignore_errors=True)
313
+ finally:
314
+ raise
315
+ except Exception as e:
316
+ if _is_access_denied(e):
317
+ raise OSError(_access_denied_msg(rt)) from e
318
+ raise
319
+ else:
320
+ # venv already exists — verify its interpreter version matches the folder tag
321
+ ver = _venv_pyver(p["python"])
322
+ if ver and rt.name != _tag_for_pyver(*ver):
323
+ status_cb(f"Runtime folder/version mismatch ({rt.name} vs Python {ver[0]}.{ver[1]}). Rebuilding.")
324
+ shutil.rmtree(p["venv"], ignore_errors=True)
325
+ # recreate at the correct tag
326
+ corrected = _runtime_base_dir() / _tag_for_pyver(*ver)
327
+ return _ensure_venv(corrected, status_cb=status_cb)
328
+
329
+ return p["python"]
330
+
331
+ # ──────────────────────────────────────────────────────────────────────────────
332
+ # Install locking & version ladder
333
+ # ──────────────────────────────────────────────────────────────────────────────
334
+
335
+ @contextmanager
336
+ def _install_lock(rt: Path, timeout_s: int = 600):
337
+ """
338
+ Prevent concurrent partial installs into the same runtime.
339
+ """
340
+ lock = rt / ".install.lock"
341
+ rt.mkdir(parents=True, exist_ok=True)
342
+ start = time.time()
343
+ while True:
344
+ try:
345
+ fd = os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
346
+ os.close(fd)
347
+ break
348
+ except FileExistsError:
349
+ if time.time() - start > timeout_s:
350
+ raise RuntimeError(f"Another install is running (lock: {lock})")
351
+ time.sleep(0.5)
352
+ try:
353
+ yield
354
+ finally:
355
+ try:
356
+ lock.unlink()
357
+ except Exception:
358
+ pass
359
+
360
+ # coarse but practical ladder by Python minor
361
+ _TORCH_VERSION_LADDER: dict[tuple[int, int], list[str]] = {
362
+ (3, 12): ["2.4.*", "2.3.*", "2.2.*"],
363
+ (3, 11): ["2.4.*", "2.3.*", "2.2.*", "2.1.*"],
364
+ (3, 10): ["2.4.*", "2.3.*", "2.2.*", "2.1.*", "1.13.*"],
365
+ }
366
+
367
+ # ──────────────────────────────────────────────────────────────────────────────
368
+ # Torch installation with robust fallbacks
369
+ # ──────────────────────────────────────────────────────────────────────────────
370
+
371
+ def _check_cuda_in_venv(venv_python: Path, status_cb=print) -> tuple[bool, str | None, str | None]:
372
+ """
373
+ Run a small script *inside the runtime venv* to see if CUDA is usable.
374
+
375
+ Returns (ok, cuda_tag, error_msg)
376
+ • ok – True if torch imports and torch.cuda.is_available() and a small
377
+ matmul on device='cuda' succeeds.
378
+ • cuda_tag – value of torch.version.cuda (if available)
379
+ • error_msg – text from any exception or stderr, for logging.
380
+ """
381
+ code = r"""
382
+ import json
383
+ import sys
384
+ try:
385
+ import torch
386
+ info = {
387
+ "cuda_tag": getattr(getattr(torch, "version", None), "cuda", None),
388
+ "has_cuda": bool(getattr(torch, "cuda", None) and torch.cuda.is_available()),
389
+ "err": None,
390
+ }
391
+ if info["has_cuda"]:
392
+ # force some real GPU work
393
+ x = torch.rand((256, 256), device="cuda", dtype=torch.float32)
394
+ y = torch.rand((256, 256), device="cuda", dtype=torch.float32)
395
+ _ = (x @ y).sum().item()
396
+ print(json.dumps(info))
397
+ except Exception as e:
398
+ print(json.dumps({"cuda_tag": None, "has_cuda": False, "err": str(e)}))
399
+ sys.exit(1)
400
+ """
401
+ r = subprocess.run(
402
+ [str(venv_python), "-c", code],
403
+ stdout=subprocess.PIPE,
404
+ stderr=subprocess.STDOUT,
405
+ text=True,
406
+ )
407
+
408
+ out = (r.stdout or "").strip()
409
+ # take last line in case pip noise gets mixed in
410
+ last = out.splitlines()[-1] if out else ""
411
+ try:
412
+ data = json.loads(last) if last else {}
413
+ except Exception as e:
414
+ msg = f"Failed to parse CUDA check output: {e}\nRaw output:\n{out}"
415
+ try:
416
+ status_cb(msg)
417
+ except Exception:
418
+ pass
419
+ return False, None, msg
420
+
421
+ ok = bool(data.get("has_cuda"))
422
+ tag = data.get("cuda_tag")
423
+ err = data.get("err")
424
+ return ok, tag, err
425
+
426
+ def _check_xpu_in_venv(venv_python: Path, status_cb=print) -> tuple[bool, str | None]:
427
+ code = r"""
428
+ import json
429
+ import sys
430
+ try:
431
+ import torch
432
+ has_xpu = hasattr(torch, "xpu") and torch.xpu.is_available()
433
+ if has_xpu:
434
+ x = torch.rand((128, 128), device="xpu")
435
+ y = torch.rand((128, 128), device="xpu")
436
+ _ = (x @ y).sum().item()
437
+ print(json.dumps({"has_xpu": bool(has_xpu)}))
438
+ except Exception as e:
439
+ print(json.dumps({"has_xpu": False, "err": str(e)}))
440
+ sys.exit(1)
441
+ """
442
+ r = subprocess.run(
443
+ [str(venv_python), "-c", code],
444
+ stdout=subprocess.PIPE,
445
+ stderr=subprocess.STDOUT,
446
+ text=True,
447
+ )
448
+ out = (r.stdout or "").strip()
449
+ last = out.splitlines()[-1] if out else ""
450
+ try:
451
+ data = json.loads(last) if last else {}
452
+ except Exception as e:
453
+ msg = f"Failed to parse XPU check output: {e}\nRaw output:\n{out}"
454
+ try: status_cb(msg)
455
+ except Exception as e:
456
+ import logging
457
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
458
+ return False, msg
459
+ return bool(data.get("has_xpu")), data.get("err")
460
+
461
+
462
+ def _install_torch(venv_python: Path, prefer_cuda: bool, prefer_xpu: bool, status_cb=print):
463
+ """
464
+ Install torch into the per-user venv with best-effort backend detection:
465
+ • macOS arm64 → PyPI (MPS)
466
+ • Win/Linux + (prefer_cuda True) → try CUDA indices in order: cu124, cu121, cu118
467
+ • else → PyPI (CPU), with Linux fallback to official CPU index
468
+ Uses a version ladder when "no matching distribution" occurs.
469
+ """
470
+ import platform as _plat
471
+ INTEL_XPU_INDEX = "https://pytorch-extension.intel.com/release-whl/stable/xpu/us/"
472
+
473
+ def _pip(*args, env=None) -> subprocess.CompletedProcess:
474
+ e = (os.environ.copy() if env is None else env)
475
+ e.pop("PYTHONPATH", None); e.pop("PYTHONHOME", None)
476
+ return subprocess.run([str(venv_python), "-m", "pip", *args],
477
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=e)
478
+
479
+ def _pip_ok(cmd: list[str]) -> bool:
480
+ r = _pip(*cmd)
481
+ if r.returncode != 0:
482
+ # surface tail of pip log for the UI
483
+ tail = (r.stdout or "").strip()
484
+ status_cb(tail[-4000:])
485
+ return r.returncode == 0
486
+
487
+ def _pyver() -> tuple[int, int]:
488
+ code = "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')"
489
+ out = subprocess.check_output([str(venv_python), "-c", code], text=True).strip()
490
+ major, minor = out.split(".")
491
+ return int(major), int(minor)
492
+
493
+ sysname = _plat.system()
494
+ machine = _plat.machine().lower()
495
+ py_major, py_minor = _pyver()
496
+
497
+ if sysname == "Darwin" and ("arm64" in machine or "aarch64" in machine):
498
+ if py_minor >= 13:
499
+ raise RuntimeError(
500
+ f"PyTorch wheels are not available for macOS arm64 on Python {py_major}.{py_minor}. "
501
+ "Please install Python 3.12 (e.g. `brew install python@3.12`) so SAS Pro can create "
502
+ "its runtime with 3.12 and install the MPS-enabled torch wheel."
503
+ )
504
+
505
+ ladder = _TORCH_VERSION_LADDER.get((py_major, py_minor), ["2.4.*", "2.3.*", "2.2.*"])
506
+
507
+ status_cb(f"Runtime Python: {py_major}.{py_minor}")
508
+
509
+ # Keep venv tools fresh
510
+ _pip_ok(["install", "--upgrade", "pip", "setuptools", "wheel"])
511
+
512
+ def _try_series(index_url: str | None, versions: list[str]) -> bool:
513
+ base = ["install", "--prefer-binary", "--no-cache-dir"]
514
+ if index_url:
515
+ base += ["--index-url", index_url]
516
+ # latest for that index first
517
+ if _pip_ok(base + ["torch"]):
518
+ return True
519
+ # walk the ladder
520
+ for v in versions:
521
+ if _pip_ok(base + [f"torch=={v}"]):
522
+ return True
523
+ return False
524
+
525
+ # macOS Apple Silicon → MPS wheels on PyPI
526
+ if sysname == "Darwin" and ("arm64" in machine or "aarch64" in machine):
527
+ status_cb("Installing PyTorch (macOS arm64, MPS)…")
528
+ if not _try_series(None, ladder):
529
+ raise RuntimeError("Failed to find a matching PyTorch wheel for macOS arm64.")
530
+ return
531
+
532
+ # Windows/Linux – CUDA first if requested, then CPU
533
+ try_cuda = prefer_cuda and sysname in ("Windows", "Linux")
534
+ cuda_indices = [
535
+ ("cu129", "https://download.pytorch.org/whl/cu129"),
536
+ ("cu128", "https://download.pytorch.org/whl/cu128"),
537
+ ("cu124", "https://download.pytorch.org/whl/cu124"),
538
+ ("cu121", "https://download.pytorch.org/whl/cu121"),
539
+ ("cu118", "https://download.pytorch.org/whl/cu118"),
540
+ ]
541
+
542
+ if try_cuda:
543
+ for tag, url in cuda_indices:
544
+ status_cb(f"Trying PyTorch CUDA wheels: {tag} …")
545
+ if _try_series(url, ladder):
546
+ # Verify the wheel just installed in the *runtime venv*, not the GUI env.
547
+ ok, cuda_tag, err = _check_cuda_in_venv(venv_python, status_cb=status_cb)
548
+ if not ok:
549
+ status_cb(
550
+ f"Installed from {tag} but CUDA is not available in the runtime venv "
551
+ f"(torch.version.cuda={cuda_tag!r}, err={err!r}). "
552
+ "Uninstalling and trying next…"
553
+ )
554
+ _pip_ok(["uninstall", "-y", "torch", "torchvision", "torchaudio"])
555
+ continue
556
+
557
+ status_cb(f"Installed PyTorch CUDA ({tag}; torch.version.cuda={cuda_tag}).")
558
+ return
559
+
560
+ status_cb(f"No matching CUDA {tag} wheel for this Python/OS. Trying next…")
561
+
562
+ status_cb("Falling back to CPU wheels (no matching CUDA wheel).")
563
+ try_xpu = prefer_xpu and sysname in ("Windows", "Linux")
564
+ if try_xpu:
565
+ status_cb("Trying PyTorch Intel XPU wheels…")
566
+ if _try_series(INTEL_XPU_INDEX, ladder):
567
+ ok, err = _check_xpu_in_venv(venv_python, status_cb=status_cb)
568
+ if ok:
569
+ status_cb("Installed PyTorch Intel XPU (torch.xpu available).")
570
+ return
571
+ else:
572
+ status_cb(f"XPU runtime test failed in venv: {err!r}. Uninstalling and falling back…")
573
+ _pip_ok(["uninstall", "-y", "torch", "torchvision", "torchaudio"])
574
+ else:
575
+ status_cb("No matching Intel XPU wheel for this Python/OS.")
576
+ # CPU path
577
+ status_cb("Installing PyTorch (CPU)…")
578
+ if _try_series(None, ladder):
579
+ return
580
+ if sysname == "Linux":
581
+ status_cb("Retry with official CPU index…")
582
+ if _try_series("https://download.pytorch.org/whl/cpu", ladder):
583
+ return
584
+ raise RuntimeError("Failed to install any compatible PyTorch wheel (CPU or CUDA).")
585
+
586
+ # ──────────────────────────────────────────────────────────────────────────────
587
+ # Public entry points
588
+ # ──────────────────────────────────────────────────────────────────────────────
589
+
590
+ def import_torch(prefer_cuda: bool = True, prefer_xpu: bool = False, status_cb=print):
591
+ """
592
+ Ensure a per-user venv exists with torch installed; return the imported module.
593
+ Hardened against shadow imports, broken wheels, concurrent installs, and partial markers.
594
+ """
595
+ # Before any attempt, demote shadowing paths (CWD / random folders)
596
+ _ban_shadow_torch_paths(status_cb=status_cb)
597
+ _purge_bad_torch_from_sysmodules(status_cb=status_cb)
598
+
599
+ # Fast path: if torch already importable and sane, use it
600
+ try:
601
+ import torch # noqa
602
+ _torch_sanity_check(status_cb=status_cb)
603
+ return torch
604
+ except Exception:
605
+ pass
606
+
607
+ rt = _user_runtime_dir()
608
+ vp = _ensure_venv(rt, status_cb=status_cb)
609
+ site = _site_packages(vp)
610
+ marker = rt / "torch_installed.json"
611
+
612
+ try:
613
+ _ensure_numpy(vp, status_cb=status_cb)
614
+ except Exception:
615
+ # Non-fatal; we'll try again if torch complains at runtime
616
+ pass
617
+
618
+ # If no marker, perform install under a lock
619
+ if not marker.exists():
620
+ try:
621
+ with _install_lock(rt):
622
+ # Re-check inside lock in case another process finished
623
+ if not marker.exists():
624
+ _install_torch(vp, prefer_cuda=prefer_cuda, prefer_xpu=prefer_xpu, status_cb=status_cb)
625
+ except Exception as e:
626
+ if _is_access_denied(e):
627
+ raise OSError(_access_denied_msg(rt)) from e
628
+ raise
629
+
630
+ # Ensure the venv site is first on sys.path, then demote shadowers again
631
+ if str(site) not in sys.path:
632
+ sys.path.insert(0, str(site))
633
+ _demote_shadow_torch_paths(status_cb=status_cb)
634
+
635
+ # Import + sanity. If broken, force a clean repair (all OSes).
636
+ def _force_repair():
637
+ try:
638
+ status_cb("Detected broken/shadowed Torch import → attempting clean repair…")
639
+ except Exception:
640
+ pass
641
+ subprocess.run([str(vp), "-m", "pip", "uninstall", "-y", "torch"], check=False)
642
+ subprocess.run([str(vp), "-m", "pip", "cache", "purge"], check=False)
643
+ with _install_lock(rt):
644
+ _install_torch(vp, prefer_cuda=prefer_cuda, prefer_xpu=prefer_xpu, status_cb=status_cb)
645
+ importlib.invalidate_caches()
646
+ _demote_shadow_torch_paths(status_cb=status_cb)
647
+
648
+ try:
649
+ _ensure_numpy(vp, status_cb=status_cb)
650
+ except Exception:
651
+ pass
652
+
653
+ try:
654
+ import torch # noqa
655
+ _torch_sanity_check(status_cb=status_cb)
656
+ # write/update marker only when sane
657
+ if not marker.exists():
658
+ pyver = f"{sys.version_info.major}.{sys.version_info.minor}"
659
+ marker.write_text(json.dumps({"installed": True, "python": pyver, "when": int(time.time())}), encoding="utf-8")
660
+ return torch
661
+ except Exception:
662
+ _force_repair()
663
+ _purge_bad_torch_from_sysmodules(status_cb=status_cb)
664
+ _ban_shadow_torch_paths(status_cb=status_cb)
665
+ import torch # retry
666
+ _torch_sanity_check(status_cb=status_cb)
667
+ if not marker.exists():
668
+ pyver = f"{sys.version_info.major}.{sys.version_info.minor}"
669
+ marker.write_text(json.dumps({"installed": True, "python": pyver, "when": int(time.time())}), encoding="utf-8")
670
+ return torch
671
+
672
+ def _find_system_python_cmd() -> list[str]:
673
+ import platform as _plat
674
+ if _plat.system() == "Darwin":
675
+ # Prefer versions that have PyTorch wheels on arm64.
676
+ candidates = [
677
+ "/opt/homebrew/bin/python3.12",
678
+ "/usr/local/bin/python3.12",
679
+ "/usr/bin/python3.12",
680
+ "/opt/homebrew/bin/python3.11",
681
+ "/usr/local/bin/python3.11",
682
+ "/usr/bin/python3.11",
683
+ "/opt/homebrew/bin/python3.10",
684
+ "/usr/local/bin/python3.10",
685
+ "/usr/bin/python3.10",
686
+ # finally, unversioned fallbacks (may be 3.13 — last resort)
687
+ "/opt/homebrew/bin/python3",
688
+ "/usr/local/bin/python3",
689
+ "/usr/bin/python3",
690
+ ]
691
+ for exe in candidates:
692
+ if shutil.which(exe) or os.path.exists(exe):
693
+ try:
694
+ r = subprocess.run([exe, "-c", "import sys; print(sys.version)"],
695
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
696
+ if r.returncode == 0:
697
+ return [exe]
698
+ except Exception:
699
+ pass
700
+ if _plat.system() == "Windows":
701
+ for args in (["py","-3.12"], ["py","-3.11"], ["py","-3.10"], ["py","-3"], ["python3"], ["python"]):
702
+ try:
703
+ r = subprocess.run(args + ["-c","import sys; print(sys.version)"],
704
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
705
+ if r.returncode == 0:
706
+ return args
707
+ except Exception:
708
+ pass
709
+ else:
710
+ for exe in ("python3.12","python3.11","python3.10","python3"):
711
+ p = shutil.which(exe)
712
+ if p:
713
+ try:
714
+ r = subprocess.run([p,"-c","import sys; print(sys.version)"],
715
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
716
+ if r.returncode == 0:
717
+ return [p]
718
+ except Exception:
719
+ pass
720
+ p = shutil.which("python")
721
+ if p:
722
+ return [p]
723
+ raise RuntimeError(
724
+ "Could not find a system Python to create the runtime environment.\n"
725
+ "Install Python 3.10+ or set SASPRO_RUNTIME_DIR to a writable path."
726
+ )
727
+
728
+ def add_runtime_to_sys_path(status_cb=print) -> None:
729
+ """
730
+ Warm up sys.path so a fresh launch can see the runtime immediately.
731
+ """
732
+ rt = _user_runtime_dir()
733
+ p = _venv_paths(rt)
734
+ vpy = p["python"]
735
+ if not vpy.exists():
736
+ return
737
+ try:
738
+ site = _site_packages(vpy)
739
+ sp = str(site)
740
+ if sp not in sys.path:
741
+ sys.path.insert(0, sp)
742
+ try:
743
+ status_cb(f"Added runtime site-packages to sys.path: {sp}")
744
+ except Exception:
745
+ pass
746
+ # also consider sibling dirs:
747
+ for c in (site, site.parent / "site-packages", site.parent / "dist-packages"):
748
+ sc = str(c)
749
+ if c.exists() and sc not in sys.path:
750
+ sys.path.insert(0, sc)
751
+ # After adding, demote any accidental shadowing paths
752
+ _demote_shadow_torch_paths(status_cb=status_cb)
753
+ except Exception:
754
+ return