python-cad-tools 0.1.0rc1__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 (236) hide show
  1. python_cad_tools/__init__.py +10 -0
  2. python_cad_tools/_locking.py +63 -0
  3. python_cad_tools/_project_loader.py +273 -0
  4. python_cad_tools/_project_state.py +65 -0
  5. python_cad_tools/_scaffold.py +66 -0
  6. python_cad_tools/_transaction.py +208 -0
  7. python_cad_tools/_viewer_static/app.webmanifest +18 -0
  8. python_cad_tools/_viewer_static/asset-manifest.json +1 -0
  9. python_cad_tools/_viewer_static/assets/animationGroup-Sd34ntWg.js +1 -0
  10. python_cad_tools/_viewer_static/assets/bakedVertexAnimation-Cd_pRdrn.js +316 -0
  11. python_cad_tools/_viewer_static/assets/basisTextureLoader-DWgvS_1G.js +1 -0
  12. python_cad_tools/_viewer_static/assets/brdfTextureTools-CWGiG1W2.js +2 -0
  13. python_cad_tools/_viewer_static/assets/bumpFragment-CWULheF1.js +127 -0
  14. python_cad_tools/_viewer_static/assets/bumpVertex-DqzFZayE.js +6 -0
  15. python_cad_tools/_viewer_static/assets/bumpVertexDeclaration-C5EFyTio.js +6 -0
  16. python_cad_tools/_viewer_static/assets/clipPlaneFragment-BXRtt4jj.js +46 -0
  17. python_cad_tools/_viewer_static/assets/clipPlaneVertex-B_VhD1oQ.js +37 -0
  18. python_cad_tools/_viewer_static/assets/cubemapToSphericalPolynomial-DRpaukza.js +1 -0
  19. python_cad_tools/_viewer_static/assets/dds-Dkb2Cx2N.js +1 -0
  20. python_cad_tools/_viewer_static/assets/dds.pure-DtvJsKvY.js +1 -0
  21. python_cad_tools/_viewer_static/assets/ddsTextureLoader-H78xux1g.js +1 -0
  22. python_cad_tools/_viewer_static/assets/default.fragment-oSQ-tWhe.js +469 -0
  23. python_cad_tools/_viewer_static/assets/default.vertex-DhFjw_Da.js +209 -0
  24. python_cad_tools/_viewer_static/assets/defaultUboDeclaration-BMDHNppx.js +5 -0
  25. python_cad_tools/_viewer_static/assets/dumpTools-BaNZPjUX.js +2 -0
  26. python_cad_tools/_viewer_static/assets/envTextureLoader-V0YnLcIM.js +1 -0
  27. python_cad_tools/_viewer_static/assets/exrTextureLoader-CjGyIK3x.js +1 -0
  28. python_cad_tools/_viewer_static/assets/flowGraphApplyForceBlock-CW5nuobR.js +1 -0
  29. python_cad_tools/_viewer_static/assets/flowGraphApplyImpulseBlock-LFqYIkhj.js +1 -0
  30. python_cad_tools/_viewer_static/assets/flowGraphArrayIndexBlock-BMOHUbBm.js +1 -0
  31. python_cad_tools/_viewer_static/assets/flowGraphBezierCurveEasingBlock-knmTiuEk.js +1 -0
  32. python_cad_tools/_viewer_static/assets/flowGraphBinaryOperationBlock-DFLuCAKJ.js +1 -0
  33. python_cad_tools/_viewer_static/assets/flowGraphBranchBlock-BJRHMXMo.js +1 -0
  34. python_cad_tools/_viewer_static/assets/flowGraphCachedOperationBlock-Dng-6jE0.js +1 -0
  35. python_cad_tools/_viewer_static/assets/flowGraphCancelDelayBlock-CgbK3CQZ.js +1 -0
  36. python_cad_tools/_viewer_static/assets/flowGraphCodeExecutionBlock-8zMtq9tg.js +1 -0
  37. python_cad_tools/_viewer_static/assets/flowGraphConditionalDataBlock-BiLr2rkI.js +1 -0
  38. python_cad_tools/_viewer_static/assets/flowGraphConsoleLogBlock-J1vcCtNP.js +1 -0
  39. python_cad_tools/_viewer_static/assets/flowGraphConstantBlock-CFb5nuaV.js +1 -0
  40. python_cad_tools/_viewer_static/assets/flowGraphContextBlock-DAeGVzhC.js +1 -0
  41. python_cad_tools/_viewer_static/assets/flowGraphCounterBlock-lZlAQiJd.js +1 -0
  42. python_cad_tools/_viewer_static/assets/flowGraphDataSwitchBlock-BIzvmbVy.js +1 -0
  43. python_cad_tools/_viewer_static/assets/flowGraphDebounceBlock-DvNuMVwy.js +1 -0
  44. python_cad_tools/_viewer_static/assets/flowGraphDebugBlock-Dymd2VEE.js +1 -0
  45. python_cad_tools/_viewer_static/assets/flowGraphDoNBlock-v68aoNLH.js +1 -0
  46. python_cad_tools/_viewer_static/assets/flowGraphEasingBlock-CTnGv92q.js +1 -0
  47. python_cad_tools/_viewer_static/assets/flowGraphFlipFlopBlock-DXsO0Chw.js +1 -0
  48. python_cad_tools/_viewer_static/assets/flowGraphForLoopBlock-CPpe9T75.js +1 -0
  49. python_cad_tools/_viewer_static/assets/flowGraphFunctionReferenceBlock-DjDev4Pq.js +1 -0
  50. python_cad_tools/_viewer_static/assets/flowGraphGLTFDataProvider-GIYCz4AQ.js +1 -0
  51. python_cad_tools/_viewer_static/assets/flowGraphGetAngularVelocityBlock-BStkOVeL.js +1 -0
  52. python_cad_tools/_viewer_static/assets/flowGraphGetAssetBlock-Eiqoivbz.js +1 -0
  53. python_cad_tools/_viewer_static/assets/flowGraphGetLinearVelocityBlock-B0cpV5ws.js +1 -0
  54. python_cad_tools/_viewer_static/assets/flowGraphGetPhysicsMassPropertiesBlock-B-H64yaI.js +1 -0
  55. python_cad_tools/_viewer_static/assets/flowGraphGetPropertyBlock-CUyOsPw0.js +1 -0
  56. python_cad_tools/_viewer_static/assets/flowGraphGetSoundVolumeBlock-C0FjrsUz.js +1 -0
  57. python_cad_tools/_viewer_static/assets/flowGraphGetVariableBlock-O4597zBl.js +1 -0
  58. python_cad_tools/_viewer_static/assets/flowGraphIndexOfBlock-E00Z3KXM.js +1 -0
  59. python_cad_tools/_viewer_static/assets/flowGraphInterpolationBlock-DnkA9MNH.js +1 -0
  60. python_cad_tools/_viewer_static/assets/flowGraphIsKeyPressedBlock-DDfeuAyf.js +1 -0
  61. python_cad_tools/_viewer_static/assets/flowGraphIsSoundPlayingBlock-DMXg8jdM.js +1 -0
  62. python_cad_tools/_viewer_static/assets/flowGraphJsonPointerParserBlock-OmCorK0y.js +1 -0
  63. python_cad_tools/_viewer_static/assets/flowGraphKeyDownEventBlock-RuVtj6g2.js +1 -0
  64. python_cad_tools/_viewer_static/assets/flowGraphKeyUpEventBlock-DO4pAfHi.js +1 -0
  65. python_cad_tools/_viewer_static/assets/flowGraphKeyboardEventBlock-Bx3pAzzh.js +1 -0
  66. python_cad_tools/_viewer_static/assets/flowGraphMathBlocks-7W1XYOC8.js +1 -0
  67. python_cad_tools/_viewer_static/assets/flowGraphMathCombineExtractBlocks-a4f0Kv6e.js +1 -0
  68. python_cad_tools/_viewer_static/assets/flowGraphMatrixMathBlocks-C5Z-y1Au.js +1 -0
  69. python_cad_tools/_viewer_static/assets/flowGraphMeshPickEventBlock-D4m-I4CS.js +1 -0
  70. python_cad_tools/_viewer_static/assets/flowGraphMultiGateBlock-BISVSMw6.js +1 -0
  71. python_cad_tools/_viewer_static/assets/flowGraphPauseAnimationBlock-BYP2Z-HG.js +1 -0
  72. python_cad_tools/_viewer_static/assets/flowGraphPauseSoundBlock-Drh1EeW6.js +1 -0
  73. python_cad_tools/_viewer_static/assets/flowGraphPhysicsCollisionEventBlock-BoziAQ75.js +1 -0
  74. python_cad_tools/_viewer_static/assets/flowGraphPlayAnimationBlock-Clry-68M.js +1 -0
  75. python_cad_tools/_viewer_static/assets/flowGraphPlaySoundBlock-LbBVxGBE.js +1 -0
  76. python_cad_tools/_viewer_static/assets/flowGraphPointerDownEventBlock-DIVXCSHh.js +1 -0
  77. python_cad_tools/_viewer_static/assets/flowGraphPointerMoveEventBlock-BNNOzY2W.js +1 -0
  78. python_cad_tools/_viewer_static/assets/flowGraphPointerOutEventBlock-B8f2dOFT.js +1 -0
  79. python_cad_tools/_viewer_static/assets/flowGraphPointerOverEventBlock-CQr8LCee.js +1 -0
  80. python_cad_tools/_viewer_static/assets/flowGraphPointerUpEventBlock-DMg_iEHl.js +1 -0
  81. python_cad_tools/_viewer_static/assets/flowGraphReceiveCustomEventBlock-B8GJm9Ai.js +1 -0
  82. python_cad_tools/_viewer_static/assets/flowGraphSceneReadyEventBlock-Dcdb29K2.js +1 -0
  83. python_cad_tools/_viewer_static/assets/flowGraphSceneTickEventBlock-BLE5p4mi.js +1 -0
  84. python_cad_tools/_viewer_static/assets/flowGraphSendCustomEventBlock-BMzDItQ5.js +1 -0
  85. python_cad_tools/_viewer_static/assets/flowGraphSequenceBlock-DRURovjn.js +1 -0
  86. python_cad_tools/_viewer_static/assets/flowGraphSetAngularVelocityBlock-Ba8dhkLM.js +1 -0
  87. python_cad_tools/_viewer_static/assets/flowGraphSetDelayBlock-CnQR--Gn.js +1 -0
  88. python_cad_tools/_viewer_static/assets/flowGraphSetLinearVelocityBlock-Dm1r3vkh.js +1 -0
  89. python_cad_tools/_viewer_static/assets/flowGraphSetPhysicsMotionTypeBlock-BNtMLimi.js +1 -0
  90. python_cad_tools/_viewer_static/assets/flowGraphSetPropertyBlock-C-uv2jhP.js +1 -0
  91. python_cad_tools/_viewer_static/assets/flowGraphSetSoundVolumeBlock-D9LDx3lg.js +1 -0
  92. python_cad_tools/_viewer_static/assets/flowGraphSetVariableBlock-DszzRB5X.js +1 -0
  93. python_cad_tools/_viewer_static/assets/flowGraphSoundEndedEventBlock-DeTDSudb.js +1 -0
  94. python_cad_tools/_viewer_static/assets/flowGraphStopAnimationBlock-DWuwoFWr.js +1 -0
  95. python_cad_tools/_viewer_static/assets/flowGraphStopSoundBlock-DEnj2cQw.js +1 -0
  96. python_cad_tools/_viewer_static/assets/flowGraphSwitchBlock-zs8Fp9ky.js +1 -0
  97. python_cad_tools/_viewer_static/assets/flowGraphThrottleBlock-BYAKUQXd.js +1 -0
  98. python_cad_tools/_viewer_static/assets/flowGraphTransformCoordinatesSystemBlock-DYP54m5P.js +1 -0
  99. python_cad_tools/_viewer_static/assets/flowGraphTypeToTypeBlocks-vFwn79bS.js +1 -0
  100. python_cad_tools/_viewer_static/assets/flowGraphUnaryOperationBlock-CaRh4Ef0.js +1 -0
  101. python_cad_tools/_viewer_static/assets/flowGraphVectorMathBlocks-BEIhMs-A.js +1 -0
  102. python_cad_tools/_viewer_static/assets/flowGraphWaitAllBlock-DFbELP_j.js +1 -0
  103. python_cad_tools/_viewer_static/assets/flowGraphWhileLoopBlock-C1OKQ-_F.js +1 -0
  104. python_cad_tools/_viewer_static/assets/fogFragment-DtDlQBnz.js +25 -0
  105. python_cad_tools/_viewer_static/assets/gaussianSplatting-Cfigjb15.js +242 -0
  106. python_cad_tools/_viewer_static/assets/gaussianSplatting-DIn8R8oF.js +296 -0
  107. python_cad_tools/_viewer_static/assets/gaussianSplatting.fragment-BxeRJ2k-.js +38 -0
  108. python_cad_tools/_viewer_static/assets/gaussianSplatting.fragment-DNDmvRs-.js +37 -0
  109. python_cad_tools/_viewer_static/assets/gaussianSplatting.vertex-D92z5jYn.js +86 -0
  110. python_cad_tools/_viewer_static/assets/gaussianSplatting.vertex-DSWfFnKQ.js +86 -0
  111. python_cad_tools/_viewer_static/assets/gaussianSplattingVoxel.fragment-DBQE2gDk.js +12 -0
  112. python_cad_tools/_viewer_static/assets/gaussianSplattingVoxel.fragment-qwx5ntqJ.js +17 -0
  113. python_cad_tools/_viewer_static/assets/gaussianSplattingVoxel.vertex-CP2kd3jU.js +30 -0
  114. python_cad_tools/_viewer_static/assets/gaussianSplattingVoxel.vertex-Dr-fEg_W.js +19 -0
  115. python_cad_tools/_viewer_static/assets/geometry.fragment-CsJIm2H0.js +242 -0
  116. python_cad_tools/_viewer_static/assets/geometry.fragment-Dph3muZt.js +262 -0
  117. python_cad_tools/_viewer_static/assets/geometry.vertex-BXGMTA_J.js +212 -0
  118. python_cad_tools/_viewer_static/assets/geometry.vertex-Bm9Wg3Gi.js +214 -0
  119. python_cad_tools/_viewer_static/assets/glowBlurPostProcess.fragment-BJSwKIJD.js +7 -0
  120. python_cad_tools/_viewer_static/assets/glowMapGeneration.fragment-DcyS1cTF.js +63 -0
  121. python_cad_tools/_viewer_static/assets/glowMapGeneration.vertex-BGq1Fakj.js +75 -0
  122. python_cad_tools/_viewer_static/assets/glowMapMerge.fragment-DxcneU_F.js +25 -0
  123. python_cad_tools/_viewer_static/assets/glowMapMerge.vertex-DaJJKqMV.js +8 -0
  124. python_cad_tools/_viewer_static/assets/harmonicsFunctions-DzJcvNWG.js +16 -0
  125. python_cad_tools/_viewer_static/assets/hdrTextureLoader-BGQvgh0h.js +2 -0
  126. python_cad_tools/_viewer_static/assets/helperFunctions-Cugfp1Fz.js +87 -0
  127. python_cad_tools/_viewer_static/assets/iesTextureLoader-CO4G0p2M.js +2 -0
  128. python_cad_tools/_viewer_static/assets/index-B2zBEWsq.js +7007 -0
  129. python_cad_tools/_viewer_static/assets/index-DcTTrc-8.css +1 -0
  130. python_cad_tools/_viewer_static/assets/kernelBlur.fragment-B6CTXTxY.js +57 -0
  131. python_cad_tools/_viewer_static/assets/kernelBlur.fragment-BkKbudbW.js +57 -0
  132. python_cad_tools/_viewer_static/assets/kernelBlur.vertex-BYwFU5IS.js +11 -0
  133. python_cad_tools/_viewer_static/assets/kernelBlur.vertex-D371zUfT.js +11 -0
  134. python_cad_tools/_viewer_static/assets/kernelBlurVaryingDeclaration-CgBGaCHg.js +1 -0
  135. python_cad_tools/_viewer_static/assets/kernelBlurVaryingDeclaration-YS8JpqdO.js +1 -0
  136. python_cad_tools/_viewer_static/assets/ktxTextureLoader-ncMSJTjl.js +1 -0
  137. python_cad_tools/_viewer_static/assets/lightFragment-skHiFbKB.js +398 -0
  138. python_cad_tools/_viewer_static/assets/logDepthDeclaration-BR8XRz9F.js +4 -0
  139. python_cad_tools/_viewer_static/assets/logDepthVertex-UKrhEIvg.js +14 -0
  140. python_cad_tools/_viewer_static/assets/mainUVVaryingDeclaration-BxQWGFcG.js +4 -0
  141. python_cad_tools/_viewer_static/assets/mesh.vertexData.functions-ayNlGC5K.js +1 -0
  142. python_cad_tools/_viewer_static/assets/meshUboDeclaration-BnX046aQ.js +4 -0
  143. python_cad_tools/_viewer_static/assets/oitFragment-CTMCOfNP.js +605 -0
  144. python_cad_tools/_viewer_static/assets/openpbr.fragment-DPJPqi6p.js +1530 -0
  145. python_cad_tools/_viewer_static/assets/openpbr.fragment-Dv6qTVVi.js +1384 -0
  146. python_cad_tools/_viewer_static/assets/openpbr.vertex-Bn2wrdGL.js +364 -0
  147. python_cad_tools/_viewer_static/assets/openpbr.vertex-CHESW3gB.js +251 -0
  148. python_cad_tools/_viewer_static/assets/openpbrMaterial.pure-BdK7VgO_.js +3 -0
  149. python_cad_tools/_viewer_static/assets/openpbrMaterialLoadingAdapter-Bm_AHDUN.js +4 -0
  150. python_cad_tools/_viewer_static/assets/openpbrTransmissionLayerData-BDa9qEoN.js +339 -0
  151. python_cad_tools/_viewer_static/assets/openpbrTransmissionLayerData-Ba_ly4LE.js +343 -0
  152. python_cad_tools/_viewer_static/assets/openpbrUboDeclaration-BY2lSH2C.js +5 -0
  153. python_cad_tools/_viewer_static/assets/openpbrUboDeclaration-DajhikDS.js +6 -0
  154. python_cad_tools/_viewer_static/assets/packingFunctions-BHPwcGG2.js +4 -0
  155. python_cad_tools/_viewer_static/assets/packingFunctions-DTwzBRBx.js +4 -0
  156. python_cad_tools/_viewer_static/assets/pass.fragment-9oKU4pI3.js +4 -0
  157. python_cad_tools/_viewer_static/assets/passCube.fragment-CpAeiYx1.js +23 -0
  158. python_cad_tools/_viewer_static/assets/passCube.fragment-DHtSWVRF.js +23 -0
  159. python_cad_tools/_viewer_static/assets/pbr.fragment-cIhMKwLs.js +2560 -0
  160. python_cad_tools/_viewer_static/assets/pbr.vertex-BfdONsf2.js +255 -0
  161. python_cad_tools/_viewer_static/assets/pbrBRDFFunctions-B_ppOzPO.js +173 -0
  162. python_cad_tools/_viewer_static/assets/pbrDebug-DDdwL5_t.js +693 -0
  163. python_cad_tools/_viewer_static/assets/pbrIBLFunctions-b1zdpzOP.js +43 -0
  164. python_cad_tools/_viewer_static/assets/pbrMaterial.pure-BZA7yVhu.js +2 -0
  165. python_cad_tools/_viewer_static/assets/pbrMaterialLoadingAdapter-BRvKRnjl.js +1 -0
  166. python_cad_tools/_viewer_static/assets/pbrUboDeclaration-CMZp25k3.js +5 -0
  167. python_cad_tools/_viewer_static/assets/postprocess.vertex-DzdUpNbW.js +9 -0
  168. python_cad_tools/_viewer_static/assets/procedural.vertex-BnbuQ-1f.js +7 -0
  169. python_cad_tools/_viewer_static/assets/procedural.vertex-Dp-Ng7Pc.js +8 -0
  170. python_cad_tools/_viewer_static/assets/reflectionFunction-DBlr3oDs.js +83 -0
  171. python_cad_tools/_viewer_static/assets/rgbdDecode.fragment-CyvnZFOJ.js +5 -0
  172. python_cad_tools/_viewer_static/assets/rgbdEncode.fragment-Dz4zLnoD.js +5 -0
  173. python_cad_tools/_viewer_static/assets/rgbdEncode.fragment-iscgpA1_.js +5 -0
  174. python_cad_tools/_viewer_static/assets/sceneUboDeclaration-BzFIc4mb.js +11 -0
  175. python_cad_tools/_viewer_static/assets/textureProcessor.fragment-C9D0rd-M.js +151 -0
  176. python_cad_tools/_viewer_static/assets/textureProcessor.fragment-JMKyWZng.js +146 -0
  177. python_cad_tools/_viewer_static/assets/tgaTextureLoader-oxTElIQJ.js +1 -0
  178. python_cad_tools/_viewer_static/assets/vertexColorMixing-mdC9xZe1.js +418 -0
  179. python_cad_tools/_viewer_static/favicon.svg +10 -0
  180. python_cad_tools/_viewer_static/index.html +18 -0
  181. python_cad_tools/_viewer_static/sw.js +61 -0
  182. python_cad_tools/artifacts.py +546 -0
  183. python_cad_tools/build-info.json +1 -0
  184. python_cad_tools/build.py +270 -0
  185. python_cad_tools/cli.py +212 -0
  186. python_cad_tools/context.py +67 -0
  187. python_cad_tools/determinism.py +66 -0
  188. python_cad_tools/drawings/__init__.py +124 -0
  189. python_cad_tools/drawings/_catalog.py +79 -0
  190. python_cad_tools/drawings/_validation.py +238 -0
  191. python_cad_tools/elements.py +121 -0
  192. python_cad_tools/exceptions.py +45 -0
  193. python_cad_tools/exporters/__init__.py +8 -0
  194. python_cad_tools/exporters/base.py +24 -0
  195. python_cad_tools/exporters/drawings/__init__.py +5 -0
  196. python_cad_tools/exporters/drawings/annotations.py +444 -0
  197. python_cad_tools/exporters/drawings/dxf.py +110 -0
  198. python_cad_tools/exporters/drawings/model.py +70 -0
  199. python_cad_tools/exporters/drawings/pdf.py +119 -0
  200. python_cad_tools/exporters/drawings/primitives.py +93 -0
  201. python_cad_tools/exporters/drawings/projection.py +64 -0
  202. python_cad_tools/exporters/drawings/svg.py +135 -0
  203. python_cad_tools/exporters/glb.py +100 -0
  204. python_cad_tools/exporters/ifc.py +229 -0
  205. python_cad_tools/exporters/quantities.py +136 -0
  206. python_cad_tools/exporters/step.py +117 -0
  207. python_cad_tools/geometry.py +187 -0
  208. python_cad_tools/manifests.py +338 -0
  209. python_cad_tools/project_config.py +174 -0
  210. python_cad_tools/py.typed +1 -0
  211. python_cad_tools/runtime_sync.py +126 -0
  212. python_cad_tools/schemas/build-manifest-1.json +1 -0
  213. python_cad_tools/schemas/design-manifest-1.json +1 -0
  214. python_cad_tools/schemas/download-manifest-2.json +46 -0
  215. python_cad_tools/schemas/drawing-annotations-1.json +66 -0
  216. python_cad_tools/schemas/glb-manifest-1.json +1 -0
  217. python_cad_tools/schemas/ifc-validation-1.json +1 -0
  218. python_cad_tools/schemas/quantities-1.json +1 -0
  219. python_cad_tools/schemas/run-metadata-1.json +1 -0
  220. python_cad_tools/schemas/site-metadata-1.json +34 -0
  221. python_cad_tools/schemas/step-validation-1.json +1 -0
  222. python_cad_tools/site.py +676 -0
  223. python_cad_tools/templates/project/.gitignore +9 -0
  224. python_cad_tools/templates/project/README.md +38 -0
  225. python_cad_tools/templates/project/config.py +36 -0
  226. python_cad_tools/templates/project/drawing_annotations.py +117 -0
  227. python_cad_tools/templates/project/model.py +87 -0
  228. python_cad_tools/templates/project/pyproject.toml +27 -0
  229. python_cad_tools/units.py +207 -0
  230. python_cad_tools/validation.py +153 -0
  231. python_cad_tools-0.1.0rc1.dist-info/METADATA +149 -0
  232. python_cad_tools-0.1.0rc1.dist-info/RECORD +236 -0
  233. python_cad_tools-0.1.0rc1.dist-info/WHEEL +5 -0
  234. python_cad_tools-0.1.0rc1.dist-info/entry_points.txt +2 -0
  235. python_cad_tools-0.1.0rc1.dist-info/licenses/LICENSE +679 -0
  236. python_cad_tools-0.1.0rc1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,10 @@
1
+ """Python CAD Tools package metadata."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("python-cad-tools")
7
+ except PackageNotFoundError:
8
+ __version__ = "unknown"
9
+
10
+ __all__ = ["__version__"]
@@ -0,0 +1,63 @@
1
+ """Portable-enough process locks for project operations on claimed platforms."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import sys
8
+ import tempfile
9
+ from collections.abc import Iterator
10
+ from contextlib import contextmanager
11
+ from pathlib import Path
12
+
13
+ from .exceptions import BuildLockedError
14
+
15
+
16
+ @contextmanager
17
+ def project_lock(project_root: Path) -> Iterator[None]:
18
+ """Hold the non-blocking interprocess lock for one canonical project path."""
19
+ root = project_root.resolve()
20
+ identity = hashlib.sha256(os.fsencode(root)).hexdigest()
21
+ lock_dir = Path(tempfile.gettempdir()) / "python-cad-tools-locks"
22
+ lock_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
23
+ path = lock_dir / f"{identity}.lock"
24
+ fd = os.open(path, os.O_CREAT | os.O_RDWR, 0o700)
25
+ try:
26
+ _acquire(fd, root)
27
+ os.lseek(fd, 0, os.SEEK_SET)
28
+ os.ftruncate(fd, 0)
29
+ os.write(fd, f"pid={os.getpid()}\nproject_root={root}\n".encode())
30
+ os.fsync(fd)
31
+ yield
32
+ finally:
33
+ _release(fd)
34
+ os.close(fd)
35
+
36
+
37
+ def _acquire(fd: int, root: Path) -> None:
38
+ if sys.platform == "win32":
39
+ import msvcrt
40
+
41
+ try:
42
+ msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
43
+ except OSError as error:
44
+ raise BuildLockedError(f"Another Python CAD operation is active for {root}") from error
45
+ else:
46
+ import fcntl
47
+
48
+ try:
49
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
50
+ except BlockingIOError as error:
51
+ raise BuildLockedError(f"Another Python CAD operation is active for {root}") from error
52
+
53
+
54
+ def _release(fd: int) -> None:
55
+ if sys.platform == "win32":
56
+ import msvcrt
57
+
58
+ os.lseek(fd, 0, os.SEEK_SET)
59
+ msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
60
+ else:
61
+ import fcntl
62
+
63
+ fcntl.flock(fd, fcntl.LOCK_UN)
@@ -0,0 +1,273 @@
1
+ """Isolated, serialized loading of trusted project entry points."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import inspect
7
+ import itertools
8
+ import os
9
+ import subprocess
10
+ import sys
11
+ import threading
12
+ from collections.abc import Callable, Iterator
13
+ from contextlib import contextmanager
14
+ from dataclasses import dataclass
15
+ from importlib.machinery import ModuleSpec
16
+ from importlib.util import source_from_cache
17
+ from pathlib import Path
18
+ from types import ModuleType
19
+ from typing import Any, cast
20
+
21
+ from ._project_state import authoritative_path, begin_source_tracking, end_source_tracking, record_source
22
+ from .context import BuildContext, DrawingContext
23
+ from .drawings import DrawingAnnotationSet, DrawingSheet
24
+ from .drawings._catalog import standard_sheet_catalog
25
+ from .drawings._validation import validate_annotation_set
26
+ from .elements import DesignModel
27
+ from .exceptions import BuildLockedError, CadToolsError, ModelValidationError, ProjectConfigError
28
+ from .project_config import ProjectConfig, load_project_config, split_callable_target
29
+ from .validation import ValidationIssue, validate_model
30
+
31
+ _PROJECT_EXECUTION_LOCK = threading.Lock()
32
+ _PROJECT_EXECUTION_LOCAL = threading.local()
33
+ _NAMESPACE_COUNTER = itertools.count()
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class LoadedProject:
38
+ project_root: Path
39
+ project_config: ProjectConfig
40
+ runtime_config: Any
41
+ model: DesignModel
42
+ issues: tuple[ValidationIssue, ...]
43
+ source_inputs: tuple[str, ...]
44
+ source_revision: str | None
45
+ source_dirty: bool
46
+ drawing_sheets: tuple[DrawingSheet, ...]
47
+ annotations: DrawingAnnotationSet | None
48
+
49
+
50
+ @dataclass(slots=True)
51
+ class _ProcessSnapshot:
52
+ modules: dict[str, ModuleType]
53
+ path: list[str]
54
+ path_importer_cache: dict[str, Any]
55
+ meta_path: list[Any]
56
+ path_hooks: list[Any]
57
+ argv: list[str]
58
+ environment: dict[str, str]
59
+ cwd: Path
60
+
61
+ @classmethod
62
+ def capture(cls) -> _ProcessSnapshot:
63
+ return cls(
64
+ modules=dict(sys.modules),
65
+ path=list(sys.path),
66
+ path_importer_cache=dict(sys.path_importer_cache),
67
+ meta_path=list(sys.meta_path),
68
+ path_hooks=list(sys.path_hooks),
69
+ argv=list(sys.argv),
70
+ environment=dict(os.environ),
71
+ cwd=Path.cwd(),
72
+ )
73
+
74
+ def restore(self) -> None:
75
+ os.chdir(self.cwd)
76
+ os.environ.clear()
77
+ os.environ.update(self.environment)
78
+ sys.argv[:] = self.argv
79
+ sys.path[:] = self.path
80
+ sys.meta_path[:] = self.meta_path
81
+ sys.path_hooks[:] = self.path_hooks
82
+ sys.path_importer_cache.clear()
83
+ sys.path_importer_cache.update(self.path_importer_cache)
84
+ sys.modules.clear()
85
+ sys.modules.update(self.modules)
86
+
87
+
88
+ def load_project_model(project_root: Path, *, include_drawings: bool = False) -> LoadedProject:
89
+ """Load and invoke one project model with complete process-state cleanup."""
90
+ with project_execution():
91
+ return load_project_model_guarded(project_root, include_drawings=include_drawings)
92
+
93
+
94
+ @contextmanager
95
+ def project_execution() -> Iterator[None]:
96
+ """Serialize the complete trusted project execution/build lifecycle."""
97
+ reject_nested_project_execution()
98
+ with _PROJECT_EXECUTION_LOCK:
99
+ _PROJECT_EXECUTION_LOCAL.active = True
100
+ try:
101
+ yield
102
+ finally:
103
+ _PROJECT_EXECUTION_LOCAL.active = False
104
+
105
+
106
+ def load_project_model_guarded(project_root: Path, *, include_drawings: bool = False) -> LoadedProject:
107
+ if not getattr(_PROJECT_EXECUTION_LOCAL, "active", False):
108
+ raise BuildLockedError("Project loading requires the active project execution guard")
109
+ return _load_project_model_locked(project_root.resolve(), include_drawings=include_drawings)
110
+
111
+
112
+ def reject_nested_project_execution() -> None:
113
+ if getattr(_PROJECT_EXECUTION_LOCAL, "active", False):
114
+ raise BuildLockedError("Nested Python CAD project execution is not allowed")
115
+
116
+
117
+ def _load_project_model_locked(project_root: Path, *, include_drawings: bool) -> LoadedProject:
118
+ project_config = load_project_config(project_root)
119
+ revision, dirty = _source_state(project_root)
120
+ snapshot = _ProcessSnapshot.capture()
121
+ token = begin_source_tracking()
122
+ tracking_active = True
123
+ namespace = f"_python_cad_project_{next(_NAMESPACE_COUNTER):x}"
124
+ accessed: set[str] = set()
125
+ try:
126
+ record_source("pyproject.toml")
127
+ os.chdir(project_root)
128
+ sys.path.insert(0, str(project_root))
129
+ _install_namespace(namespace, project_root)
130
+ runtime_config = _import_project_module(namespace, project_config.config_target, "configuration")
131
+ model_module_name, callable_name = split_callable_target(project_config.model_target)
132
+ model_module = _import_project_module(namespace, model_module_name, "model")
133
+ builder = getattr(model_module, callable_name, None)
134
+ _validate_entry_point(builder, project_config.model_target, "Model")
135
+ typed_builder = cast(Callable[[BuildContext], Any], builder)
136
+ context = BuildContext(project_root, runtime_config, revision, dirty)
137
+ try:
138
+ model = typed_builder(context)
139
+ except CadToolsError:
140
+ raise
141
+ except Exception as error:
142
+ raise ModelValidationError(f"Model entry point {project_config.model_target!r} failed: {error}") from error
143
+ if not isinstance(model, DesignModel):
144
+ raise ModelValidationError(
145
+ f"Model entry point {project_config.model_target!r} returned {type(model).__name__}; expected DesignModel"
146
+ )
147
+ issues = tuple(validate_model(model))
148
+ drawing_sheets: tuple[DrawingSheet, ...] = ()
149
+ annotations: DrawingAnnotationSet | None = None
150
+ if include_drawings:
151
+ drawing_sheets = standard_sheet_catalog(model)
152
+ if project_config.drawing_annotations_target is not None:
153
+ annotation_module_name, annotation_callable_name = split_callable_target(
154
+ project_config.drawing_annotations_target
155
+ )
156
+ annotation_module = _import_project_module(namespace, annotation_module_name, "drawing annotation")
157
+ provider = getattr(annotation_module, annotation_callable_name, None)
158
+ _validate_entry_point(provider, project_config.drawing_annotations_target, "Drawing annotation")
159
+ typed_provider = cast(Callable[[DrawingContext], Any], provider)
160
+ drawing_context = DrawingContext(project_root, runtime_config, model, drawing_sheets)
161
+ try:
162
+ annotations = validate_annotation_set(typed_provider(drawing_context), drawing_sheets)
163
+ except CadToolsError:
164
+ raise
165
+ except Exception as error:
166
+ raise ModelValidationError(
167
+ f"Drawing annotation entry point {project_config.drawing_annotations_target!r} failed: {error}"
168
+ ) from error
169
+ accessed.update(_project_module_sources(project_root, snapshot.modules))
170
+ accessed.update(end_source_tracking(token))
171
+ tracking_active = False
172
+ undeclared = sorted(accessed - project_config.declared_sources)
173
+ if undeclared:
174
+ raise ProjectConfigError(
175
+ "Loaded authoritative project input(s) missing from source-inputs: " + ", ".join(undeclared)
176
+ )
177
+ return LoadedProject(
178
+ project_root=project_root,
179
+ project_config=project_config,
180
+ runtime_config=runtime_config,
181
+ model=model,
182
+ issues=issues,
183
+ source_inputs=tuple(sorted(accessed)),
184
+ source_revision=revision,
185
+ source_dirty=dirty,
186
+ drawing_sheets=drawing_sheets,
187
+ annotations=annotations,
188
+ )
189
+ finally:
190
+ if tracking_active:
191
+ end_source_tracking(token)
192
+ snapshot.restore()
193
+
194
+
195
+ def _install_namespace(namespace: str, project_root: Path) -> None:
196
+ package = ModuleType(namespace)
197
+ package.__file__ = None
198
+ package.__package__ = namespace
199
+ package.__path__ = [str(project_root)]
200
+ package.__spec__ = ModuleSpec(namespace, loader=None, is_package=True)
201
+ sys.modules[namespace] = package
202
+ importlib.invalidate_caches()
203
+
204
+
205
+ def _import_project_module(namespace: str, target: str, label: str) -> ModuleType:
206
+ qualified = f"{namespace}.{target}"
207
+ try:
208
+ return importlib.import_module(qualified)
209
+ except CadToolsError:
210
+ raise
211
+ except Exception as error:
212
+ raise ProjectConfigError(f"Unable to import project {label} module {target!r}: {error}") from error
213
+
214
+
215
+ def _validate_entry_point(builder: Any, target: str, label: str) -> None:
216
+ if not callable(builder):
217
+ raise ProjectConfigError(f"{label} target {target!r} does not resolve to a callable")
218
+ try:
219
+ signature = inspect.signature(builder)
220
+ except (TypeError, ValueError) as error:
221
+ raise ProjectConfigError(f"Unable to inspect {label.lower()} target {target!r}: {error}") from error
222
+ parameters = tuple(signature.parameters.values())
223
+ valid = (
224
+ len(parameters) == 1
225
+ and parameters[0].name == "context"
226
+ and parameters[0].kind in {inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD}
227
+ and parameters[0].default is inspect.Parameter.empty
228
+ )
229
+ if not valid:
230
+ required = "build_model(context)" if label == "Model" else "build_annotations(context)"
231
+ raise ProjectConfigError(f"{label} target {target!r} must have the exact signature {required}")
232
+
233
+
234
+ def _project_module_sources(project_root: Path, original_modules: dict[str, ModuleType]) -> set[str]:
235
+ sources: set[str] = set()
236
+ for name, module in sys.modules.items():
237
+ if original_modules.get(name) is module:
238
+ continue
239
+ raw = getattr(module, "__file__", None)
240
+ if not raw:
241
+ continue
242
+ path = Path(raw)
243
+ if path.suffix in {".pyc", ".pyo"}:
244
+ try:
245
+ raw = source_from_cache(str(path))
246
+ except ValueError:
247
+ continue
248
+ path = Path(raw)
249
+ lexical = Path(os.path.abspath(path))
250
+ if lexical.is_relative_to(project_root):
251
+ _, relative = authoritative_path(project_root, lexical.relative_to(project_root).as_posix())
252
+ sources.add(relative)
253
+ return sources
254
+
255
+
256
+ def _source_state(project_root: Path) -> tuple[str | None, bool]:
257
+ revision = subprocess.run(
258
+ ["git", "rev-parse", "HEAD"],
259
+ cwd=project_root,
260
+ text=True,
261
+ capture_output=True,
262
+ check=False,
263
+ )
264
+ if revision.returncode != 0:
265
+ return None, False
266
+ status = subprocess.run(
267
+ ["git", "status", "--short", "--untracked-files=normal"],
268
+ cwd=project_root,
269
+ text=True,
270
+ capture_output=True,
271
+ check=False,
272
+ )
273
+ return revision.stdout.strip(), status.returncode != 0 or bool(status.stdout.strip())
@@ -0,0 +1,65 @@
1
+ """Operation-local authoritative source tracking for project contexts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextvars import ContextVar, Token
6
+ from pathlib import Path
7
+ from typing import BinaryIO, Literal, TextIO
8
+
9
+ from .exceptions import ProjectConfigError, UnsafePathError
10
+
11
+ _accessed_sources: ContextVar[set[str] | None] = ContextVar("python_cad_accessed_sources", default=None)
12
+
13
+
14
+ def begin_source_tracking() -> Token[set[str] | None]:
15
+ return _accessed_sources.set(set())
16
+
17
+
18
+ def end_source_tracking(token: Token[set[str] | None]) -> set[str]:
19
+ accessed = _accessed_sources.get()
20
+ _accessed_sources.reset(token)
21
+ return set() if accessed is None else accessed
22
+
23
+
24
+ def record_source(relative_path: str) -> None:
25
+ accessed = _accessed_sources.get()
26
+ if accessed is None:
27
+ raise ProjectConfigError("Authoritative project files may be accessed only while a project entry point is running")
28
+ accessed.add(relative_path)
29
+
30
+
31
+ def authoritative_path(project_root: Path, relative_path: str | Path) -> tuple[Path, str]:
32
+ raw = str(relative_path)
33
+ parts = raw.split("/")
34
+ if not raw or "\\" in raw or "\x00" in raw or ":" in raw or any(part in {"", ".", ".."} for part in parts):
35
+ raise UnsafePathError(f"Unsafe project-relative source path: {raw!r}")
36
+ candidate = Path(*parts)
37
+ if candidate.is_absolute():
38
+ raise UnsafePathError(f"Unsafe project-relative source path: {raw!r}")
39
+ root = project_root.resolve()
40
+ target = root
41
+ traverses_symlink = False
42
+ for part in candidate.parts:
43
+ target /= part
44
+ traverses_symlink = traverses_symlink or target.is_symlink()
45
+ if traverses_symlink or not target.is_file():
46
+ raise ProjectConfigError(f"Authoritative source must be a regular, non-symlink file: {candidate.as_posix()}")
47
+ resolved = target.resolve()
48
+ if not resolved.is_relative_to(root):
49
+ raise UnsafePathError(f"Authoritative source escapes the project root: {candidate.as_posix()}")
50
+ return resolved, candidate.as_posix()
51
+
52
+
53
+ def open_authoritative_source(
54
+ project_root: Path,
55
+ relative_path: str | Path,
56
+ mode: Literal["r", "rb"],
57
+ encoding: str | None,
58
+ ) -> TextIO | BinaryIO:
59
+ if mode not in {"r", "rb"}:
60
+ raise ValueError("BuildContext.open_source supports only 'r' and 'rb'")
61
+ target, relative = authoritative_path(project_root, relative_path)
62
+ record_source(relative)
63
+ if mode == "rb":
64
+ return target.open("rb")
65
+ return target.open("r", encoding=encoding or "utf-8")
@@ -0,0 +1,66 @@
1
+ """Generic project scaffold creation for the ``python-cad init`` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from .exceptions import ProjectConfigError, UnsafePathError
9
+
10
+ _TEMPLATE_DIR = Path(__file__).resolve().parent / "templates" / "project"
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class ScaffoldResult:
15
+ destination: Path
16
+ created_files: tuple[str, ...]
17
+
18
+
19
+ def scaffold_project(destination: Path, *, force: bool = False) -> ScaffoldResult:
20
+ root = destination.expanduser().resolve()
21
+ if not root.parent.exists():
22
+ raise ProjectConfigError(f"Parent directory does not exist: {root.parent}")
23
+ if root.is_symlink():
24
+ raise UnsafePathError(f"Destination must not be a symlink: {root}")
25
+ _validate_safe_path(root)
26
+
27
+ existing: list[Path] = []
28
+ if root.exists():
29
+ for child in root.iterdir():
30
+ if child.name == ".git":
31
+ continue
32
+ existing.append(child)
33
+ if existing and not force:
34
+ raise ProjectConfigError(
35
+ f"Destination is not empty: {root}; use --force to overwrite scaffold files"
36
+ )
37
+
38
+ root.mkdir(parents=True, exist_ok=True)
39
+ created: list[str] = []
40
+ for template in sorted(_TEMPLATE_DIR.rglob("*")):
41
+ if not template.is_file():
42
+ continue
43
+ relative = template.relative_to(_TEMPLATE_DIR)
44
+ target = root / relative
45
+ target.parent.mkdir(parents=True, exist_ok=True)
46
+ if target.exists() or target.is_symlink():
47
+ if not force:
48
+ continue
49
+ if target.is_dir() and not target.is_symlink():
50
+ target.rmdir()
51
+ else:
52
+ target.unlink()
53
+ target.write_bytes(template.read_bytes())
54
+ created.append(relative.as_posix())
55
+ return ScaffoldResult(root, tuple(created))
56
+
57
+
58
+ def _validate_safe_path(path: Path) -> None:
59
+ resolved = path.resolve(strict=False)
60
+ parts = resolved.parts
61
+ if not parts:
62
+ raise UnsafePathError(f"Unsafe destination: {path}")
63
+ dangerous = {"", ".", ".."}
64
+ for part in parts:
65
+ if part in dangerous:
66
+ raise UnsafePathError(f"Unsafe destination component: {part!r} in {path}")
@@ -0,0 +1,208 @@
1
+ """Recoverable, journaled same-filesystem directory promotion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import shutil
8
+ import sys
9
+ import uuid
10
+ from collections.abc import Callable
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from .determinism import canonical_json_bytes
15
+ from .exceptions import ArtifactIntegrityError, UnsafePathError
16
+
17
+ PROMOTION_SCHEMA = "urn:python-cad-tools:schema:promotion-journal:1"
18
+ VerifyDirectory = Callable[[Path], object]
19
+
20
+
21
+ def staging_directory(output: Path) -> Path:
22
+ return output.with_name(f".{output.name}-staging-{uuid.uuid4().hex}")
23
+
24
+
25
+ def recover_promotion(output: Path, verify: VerifyDirectory) -> None:
26
+ journal = _journal_path(output)
27
+ if not journal.exists():
28
+ _recover_orphans(output, verify)
29
+ return
30
+ document = _read_journal(journal, output)
31
+ staging = output.parent / document["staging_name"]
32
+ backup = output.parent / document["backup_name"]
33
+
34
+ output_valid = _is_valid(output, verify)
35
+ backup_valid = _is_valid(backup, verify)
36
+ if output_valid:
37
+ _safe_remove(backup)
38
+ _safe_remove(staging)
39
+ journal.unlink(missing_ok=True)
40
+ _fsync_directory(output.parent)
41
+ return
42
+ if backup_valid:
43
+ _safe_remove(output)
44
+ os.replace(backup, output)
45
+ _safe_remove(staging)
46
+ journal.unlink(missing_ok=True)
47
+ _fsync_directory(output.parent)
48
+ return
49
+ if document["state"] == "prepared" and output.exists() and not backup.exists():
50
+ _safe_remove(staging)
51
+ journal.unlink(missing_ok=True)
52
+ _fsync_directory(output.parent)
53
+ return
54
+ raise UnsafePathError(f"Cannot safely recover interrupted promotion recorded by {journal}")
55
+
56
+
57
+ def _recover_orphans(output: Path, verify: VerifyDirectory) -> None:
58
+ staging = sorted(output.parent.glob(f".{output.name}-staging-*"))
59
+ backups = sorted(output.parent.glob(f".{output.name}-backup-*"))
60
+ if not staging and not backups:
61
+ return
62
+ if _is_valid(output, verify):
63
+ for path in [*staging, *backups]:
64
+ _safe_remove(path)
65
+ _fsync_directory(output.parent)
66
+ return
67
+ valid_backups = [path for path in backups if _is_valid(path, verify)]
68
+ if len(valid_backups) == 1:
69
+ _safe_remove(output)
70
+ os.replace(valid_backups[0], output)
71
+ for path in [*staging, *backups]:
72
+ if path != valid_backups[0]:
73
+ _safe_remove(path)
74
+ _fsync_directory(output.parent)
75
+ return
76
+ raise UnsafePathError(f"Cannot safely recover stale promotion paths adjacent to {output}")
77
+
78
+
79
+ def promote_directory(staging: Path, output: Path, verify: VerifyDirectory) -> None:
80
+ _validate_destination(output)
81
+ if staging.parent != output.parent or not staging.name.startswith(f".{output.name}-staging-"):
82
+ raise UnsafePathError(f"Invalid staging directory for {output}: {staging}")
83
+ transaction = uuid.uuid4().hex
84
+ backup = output.with_name(f".{output.name}-backup-{transaction}")
85
+ journal = _journal_path(output)
86
+ document: dict[str, Any] = {
87
+ "schema_id": PROMOTION_SCHEMA,
88
+ "schema_version": 1,
89
+ "output_name": output.name,
90
+ "staging_name": staging.name,
91
+ "backup_name": backup.name,
92
+ "state": "prepared",
93
+ }
94
+ _write_state(journal, document, "prepared")
95
+ try:
96
+ if output.exists():
97
+ _around("rename_output_to_backup", lambda: os.replace(output, backup))
98
+ _write_state(journal, document, "backup")
99
+ _around("rename_staging_to_output", lambda: os.replace(staging, output))
100
+ _write_state(journal, document, "promoted")
101
+ verify(output)
102
+ except BaseException:
103
+ if backup.exists():
104
+ _safe_remove(output)
105
+ os.replace(backup, output)
106
+ raise
107
+ else:
108
+ _safe_remove(backup)
109
+ _safe_remove(staging)
110
+ journal.unlink(missing_ok=True)
111
+ _fsync_directory(output.parent)
112
+
113
+
114
+ def _journal_path(output: Path) -> Path:
115
+ return output.with_name(f".{output.name}-promotion.json")
116
+
117
+
118
+ def _write_state(path: Path, document: dict[str, Any], state: str) -> None:
119
+ document["state"] = state
120
+
121
+ def write() -> None:
122
+ temporary = path.with_suffix(f".tmp-{uuid.uuid4().hex}")
123
+ try:
124
+ temporary.write_bytes(canonical_json_bytes(document))
125
+ with temporary.open("r+b") as stream:
126
+ os.fsync(stream.fileno())
127
+ os.replace(temporary, path)
128
+ _fsync_directory(path.parent)
129
+ finally:
130
+ temporary.unlink(missing_ok=True)
131
+
132
+ _around(f"write_journal_{state}", write)
133
+
134
+
135
+ def _around(name: str, operation: Callable[[], None]) -> None:
136
+ _crash(f"before_{name}")
137
+ operation()
138
+ _crash(f"after_{name}")
139
+
140
+
141
+ def _crash(point: str) -> None:
142
+ if os.environ.get("PYTHON_CAD_TEST_CRASH_POINT") == point:
143
+ os._exit(91)
144
+
145
+
146
+ def _read_journal(path: Path, output: Path) -> dict[str, Any]:
147
+ if path.is_symlink() or not path.is_file():
148
+ raise UnsafePathError(f"Unsafe promotion journal: {path}")
149
+ try:
150
+ value = json.loads(path.read_text(encoding="utf-8"))
151
+ except (OSError, UnicodeError, json.JSONDecodeError) as error:
152
+ raise UnsafePathError(f"Invalid promotion journal {path}: {error}") from error
153
+ required = {"schema_id", "schema_version", "output_name", "staging_name", "backup_name", "state"}
154
+ if not isinstance(value, dict) or set(value) != required:
155
+ raise UnsafePathError(f"Invalid promotion journal shape: {path}")
156
+ if value["schema_id"] != PROMOTION_SCHEMA or value["schema_version"] != 1:
157
+ raise UnsafePathError(f"Unsupported promotion journal schema: {path}")
158
+ if value["output_name"] != output.name or value["state"] not in {"prepared", "backup", "promoted"}:
159
+ raise UnsafePathError(f"Promotion journal does not match {output}")
160
+ if not _safe_transaction_name(value["staging_name"], output.name, "staging"):
161
+ raise UnsafePathError(f"Unsafe staging name in {path}")
162
+ if not _safe_transaction_name(value["backup_name"], output.name, "backup"):
163
+ raise UnsafePathError(f"Unsafe backup name in {path}")
164
+ return value
165
+
166
+
167
+ def _safe_transaction_name(value: object, output_name: str, kind: str) -> bool:
168
+ return (
169
+ isinstance(value, str)
170
+ and value.startswith(f".{output_name}-{kind}-")
171
+ and "/" not in value
172
+ and "\\" not in value
173
+ and value not in {".", ".."}
174
+ )
175
+
176
+
177
+ def _validate_destination(output: Path) -> None:
178
+ if output.is_symlink() or not output.parent.is_dir():
179
+ raise UnsafePathError(f"Unsafe output directory: {output}")
180
+ if output.exists() and not output.is_dir():
181
+ raise UnsafePathError(f"Configured output is not a directory: {output}")
182
+
183
+
184
+ def _is_valid(path: Path, verify: VerifyDirectory) -> bool:
185
+ if not path.is_dir() or path.is_symlink():
186
+ return False
187
+ try:
188
+ verify(path)
189
+ except (ArtifactIntegrityError, OSError, ValueError):
190
+ return False
191
+ return True
192
+
193
+
194
+ def _safe_remove(path: Path) -> None:
195
+ if path.is_symlink() or path.is_file():
196
+ path.unlink(missing_ok=True)
197
+ elif path.is_dir():
198
+ shutil.rmtree(path)
199
+
200
+
201
+ def _fsync_directory(path: Path) -> None:
202
+ if sys.platform == "win32":
203
+ return
204
+ descriptor = os.open(path, os.O_RDONLY)
205
+ try:
206
+ os.fsync(descriptor)
207
+ finally:
208
+ os.close(descriptor)