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,676 @@
1
+ """Strict, Node-free viewer-site preparation and local HTTP serving."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import hashlib
7
+ import http.server
8
+ import ipaddress
9
+ import json
10
+ import os
11
+ import re
12
+ import shutil
13
+ import signal
14
+ import socket
15
+ import tempfile
16
+ import threading
17
+ import unicodedata
18
+ import urllib.parse
19
+ import uuid
20
+ from collections.abc import Iterator
21
+ from dataclasses import asdict, dataclass
22
+ from importlib import metadata, resources
23
+ from pathlib import Path, PurePosixPath
24
+ from typing import Any, Literal, cast
25
+
26
+ from .artifacts import ArtifactRecord, get_package_build_info, load_design_manifest, verify_build_output
27
+ from .determinism import canonical_json_bytes, sha256_file
28
+ from .exceptions import ArtifactIntegrityError, BuildLockedError, SiteError, UnsafePathError
29
+ from .project_config import load_project_config, output_path, resolve_project_root
30
+
31
+ DOWNLOAD_SCHEMA = "urn:python-cad-tools:schema:download-manifest:2"
32
+ SITE_SCHEMA = "urn:python-cad-tools:schema:site-metadata:1"
33
+ ASSET_SCHEMA = "urn:python-cad-tools:schema:viewer-assets:1"
34
+ _HEX = re.compile(r"[0-9a-f]{64}\Z")
35
+ _SITE_FIELDS = {
36
+ "schema_id", "schema_version", "managed_by", "tool_version", "project_id", "design_build_hash", "base_path",
37
+ "destination_path_sha256", "viewer_shell_sha256", "download_manifest_sha256", "managed_inventory",
38
+ }
39
+ _INVENTORY_FIELDS = {"path", "size", "sha256", "media_type", "owner"}
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class SiteEntry:
44
+ source_path: str
45
+ site_path: str
46
+ kind: str
47
+ media_type: str
48
+ size: int
49
+ sha256: str
50
+ viewer_loadable: bool
51
+ downloadable: bool
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class ManagedSiteFile:
56
+ path: str
57
+ size: int
58
+ sha256: str
59
+ media_type: str
60
+ owner: Literal["viewer_shell", "generated_artifact", "site_document"]
61
+
62
+
63
+ @dataclass(frozen=True, slots=True)
64
+ class PreparedSite:
65
+ destination: Path
66
+ base_path: str
67
+ project_id: str
68
+ design_build_hash: str
69
+ viewer_shell_sha256: str
70
+ download_manifest_sha256: str
71
+ file_count: int
72
+
73
+
74
+ @dataclass(frozen=True, slots=True)
75
+ class ServerResult:
76
+ url: str
77
+ interrupted: bool
78
+
79
+
80
+ _ALLOWLIST: tuple[tuple[re.Pattern[str], str, str, bool, bool], ...] = (
81
+ (re.compile(r"glb/[^/]+\.glb\Z"), "glb", "model/gltf-binary", True, True),
82
+ (re.compile(r"glb/manifest\.json\Z"), "glb_manifest", "application/json", True, False),
83
+ (re.compile(r"step/[^/]+\.step\Z"), "step", "model/step", False, True),
84
+ (re.compile(r"step/validation\.json\Z"), "step_validation", "application/json", False, False),
85
+ (re.compile(r"ifc/[^/]+\.ifc\Z"), "ifc", "application/x-step", False, True),
86
+ (re.compile(r"ifc/validation\.json\Z"), "ifc_validation", "application/json", False, False),
87
+ (re.compile(r"drawings/svg/[^/]+\.svg\Z"), "drawing_svg", "image/svg+xml", True, True),
88
+ (re.compile(r"drawings/dxf/[^/]+\.dxf\Z"), "drawing_dxf", "image/vnd.dxf", False, True),
89
+ (re.compile(r"drawings/pdf/[^/]+\.pdf\Z"), "drawing_pdf", "application/pdf", True, True),
90
+ (re.compile(r"drawings/annotation-manifest\.json\Z"), "annotation_manifest", "application/json", True, False),
91
+ (re.compile(r"quantities/quantities\.json\Z"), "quantity_json", "application/json", True, True),
92
+ (re.compile(r"quantities/(?:materials|quantities)\.csv\Z"), "quantity_csv", "text/csv; charset=utf-8", False, True),
93
+ (re.compile(r"quantities/summary\.md\Z"), "quantity_markdown", "text/markdown; charset=utf-8", False, True),
94
+ (re.compile(r"manifests/design-manifest\.json\Z"), "design_manifest", "application/json", True, False),
95
+ (re.compile(r"manifests/build-manifest\.json\Z"), "build_manifest", "application/json", True, False),
96
+ )
97
+
98
+
99
+ def normalize_base_path(value: str) -> str:
100
+ """Validate and normalize a root or absolute deployment base path."""
101
+ if value == "/":
102
+ return value
103
+ if not isinstance(value, str) or not value.startswith("/") or not value.endswith("/"):
104
+ raise SiteError("Base path must have one leading and trailing slash")
105
+ if "\\" in value or "?" in value or "#" in value or ":" in value or "//" in value:
106
+ raise SiteError(f"Unsafe base path: {value!r}")
107
+ try:
108
+ decoded = urllib.parse.unquote(value, errors="strict")
109
+ except UnicodeError as error:
110
+ raise SiteError(f"Unsafe base path: {value!r}") from error
111
+ if decoded != value or any(part in {"", ".", ".."} for part in value[1:-1].split("/")):
112
+ raise SiteError(f"Unsafe base path: {value!r}")
113
+ return value
114
+
115
+
116
+ def destination_identity(destination: Path) -> tuple[Path, str]:
117
+ resolved = destination.expanduser().resolve(strict=False)
118
+ normalized = unicodedata.normalize("NFC", os.path.normcase(str(resolved)))
119
+ if resolved.anchor and normalized != resolved.anchor:
120
+ normalized = normalized.rstrip(os.sep)
121
+ return resolved, hashlib.sha256(normalized.encode("utf-8")).hexdigest()
122
+
123
+
124
+ def prepare_site(project_root: Path, destination: Path, base_path: str = "/") -> PreparedSite:
125
+ """Verify generated artifacts and atomically replace a managed viewer site."""
126
+ root = resolve_project_root(project_root)
127
+ config = load_project_config(root)
128
+ output = output_path(root, config)
129
+ base = normalize_base_path(base_path)
130
+ target, identity = destination_identity(destination)
131
+ _validate_destination(root, output, target)
132
+ target.parent.mkdir(parents=False, exist_ok=True)
133
+ with _destination_lock(target.parent, identity):
134
+ _recover_site_transaction(target, identity)
135
+ _validate_destination(root, output, target)
136
+ verification = verify_build_output(root)
137
+ design = load_design_manifest(output / "manifests/design-manifest.json")
138
+ project_id = str(design["model_id"])
139
+ _validate_existing_site(target, identity, project_id)
140
+ entries, omitted = _site_entries(output, verification.artifacts)
141
+ shell, viewer_hash = _viewer_shell()
142
+ version = metadata.version("python-cad-tools")
143
+ download = {
144
+ "schema_id": DOWNLOAD_SCHEMA,
145
+ "schema_version": 2,
146
+ "generator_distribution": "python-cad-tools",
147
+ "generator_version": version,
148
+ "project_id": project_id,
149
+ "design_build_hash": verification.stable_artifact_set_hash,
150
+ "base_path": base,
151
+ "entries": [asdict(entry) for entry in entries],
152
+ "omitted": omitted,
153
+ }
154
+ download_bytes = _canonical_json(download)
155
+ download_hash = _sha256_bytes(download_bytes)
156
+ _check_site_collisions(shell, entries)
157
+ prepared = _stage_site(
158
+ target=target,
159
+ identity=identity,
160
+ output=output,
161
+ shell=shell,
162
+ entries=entries,
163
+ download_bytes=download_bytes,
164
+ project_id=project_id,
165
+ design_build_hash=verification.stable_artifact_set_hash,
166
+ base_path=base,
167
+ viewer_shell_sha256=viewer_hash,
168
+ download_manifest_sha256=download_hash,
169
+ tool_version=version,
170
+ )
171
+ _promote_site(target, identity, prepared)
172
+ metadata_value = _verify_managed_site(target, identity, project_id)
173
+ return PreparedSite(
174
+ destination=target,
175
+ base_path=base,
176
+ project_id=project_id,
177
+ design_build_hash=verification.stable_artifact_set_hash,
178
+ viewer_shell_sha256=viewer_hash,
179
+ download_manifest_sha256=download_hash,
180
+ file_count=len(cast(list[Any], metadata_value["managed_inventory"])) + 1,
181
+ )
182
+
183
+
184
+ def serve_project(
185
+ project_root: Path,
186
+ *,
187
+ host: str = "127.0.0.1",
188
+ port: int = 8000,
189
+ build: bool = False,
190
+ allow_unsafe_bind: bool = False,
191
+ ) -> ServerResult:
192
+ """Prepare an ephemeral verified site and serve it until interrupted."""
193
+ if not 0 <= port <= 65535:
194
+ raise SiteError(f"Invalid port: {port}")
195
+ if not _is_loopback(host) and not allow_unsafe_bind:
196
+ raise SiteError(f"Refusing non-loopback host {host!r}; pass --allow-unsafe-bind to opt in")
197
+ root = resolve_project_root(project_root)
198
+ if build:
199
+ from .build import BuildOptions, build_project
200
+
201
+ build_project(BuildOptions(root))
202
+ with tempfile.TemporaryDirectory(prefix="python-cad-site-") as directory:
203
+ prepared = prepare_site(root, Path(directory) / "site")
204
+ return serve_prepared_site(prepared.destination, host=host, port=port, allow_unsafe_bind=allow_unsafe_bind)
205
+
206
+
207
+ def serve_prepared_site(site_root: Path, *, host: str = "127.0.0.1", port: int = 8000, allow_unsafe_bind: bool = False) -> ServerResult:
208
+ """Serve an already prepared, independently verified managed site."""
209
+ if not _is_loopback(host) and not allow_unsafe_bind:
210
+ raise SiteError(f"Refusing non-loopback host {host!r}; explicit opt-in is required")
211
+ root = site_root.resolve()
212
+ site = _verify_managed_site(root)
213
+ handler = _handler(root, site)
214
+ try:
215
+ server = http.server.ThreadingHTTPServer((host, port), handler)
216
+ except OSError as error:
217
+ raise SiteError(f"Unable to bind {host}:{port}: {error}") from error
218
+ server.daemon_threads = True
219
+ server.timeout = 0.2
220
+ actual_host, actual_port = cast(tuple[str, int], server.server_address[:2])
221
+ display_host = f"[{actual_host}]" if ":" in actual_host else actual_host
222
+ url = f"http://{display_host}:{actual_port}{site['base_path']}"
223
+ print(f"READY {url}", flush=True)
224
+ stopped = threading.Event()
225
+ previous: dict[signal.Signals, Any] = {}
226
+
227
+ def stop(_signum: int, _frame: Any) -> None:
228
+ stopped.set()
229
+
230
+ if threading.current_thread() is threading.main_thread():
231
+ for signum in (signal.SIGINT, signal.SIGTERM):
232
+ previous[signum] = signal.getsignal(signum)
233
+ signal.signal(signum, stop)
234
+ try:
235
+ while not stopped.is_set():
236
+ server.handle_request()
237
+ finally:
238
+ server.server_close()
239
+ for signum, callback in previous.items():
240
+ signal.signal(signum, callback)
241
+ return ServerResult(url=url, interrupted=stopped.is_set())
242
+
243
+
244
+ def _site_entries(output: Path, records: tuple[ArtifactRecord, ...]) -> tuple[tuple[SiteEntry, ...], list[dict[str, str]]]:
245
+ all_records = list(records)
246
+ build_path = output / "manifests/build-manifest.json"
247
+ all_records.append(ArtifactRecord(
248
+ path="manifests/build-manifest.json", size=build_path.stat().st_size, sha256=sha256_file(build_path),
249
+ media_type="application/json", producer="python-cad-tools",
250
+ schema_id="urn:python-cad-tools:schema:build-manifest:1", schema_version=1, volatile=False,
251
+ ))
252
+ entries: list[SiteEntry] = []
253
+ omitted: list[dict[str, str]] = []
254
+ seen: set[str] = set()
255
+ for record in sorted(all_records, key=lambda item: item.path):
256
+ if record.path in seen:
257
+ raise ArtifactIntegrityError(f"Duplicate site source path: {record.path}")
258
+ seen.add(record.path)
259
+ if record.path == "manifests/run-metadata.json":
260
+ omitted.append({"source_path": record.path, "reason": "volatile_run_metadata"})
261
+ continue
262
+ if record.path == ".gitkeep":
263
+ omitted.append({"source_path": record.path, "reason": "non_web_anchor"})
264
+ continue
265
+ rule = next((value for value in _ALLOWLIST if value[0].fullmatch(record.path)), None)
266
+ if rule is None:
267
+ raise SiteError(f"Generated artifact is not allowed in a viewer site: {record.path}")
268
+ _, kind, media_type, viewer_loadable, downloadable = rule
269
+ if record.media_type != media_type:
270
+ raise SiteError(f"Artifact media type mismatch for {record.path}: expected {media_type}, got {record.media_type}")
271
+ entries.append(SiteEntry(record.path, f"artifacts/{record.path}", kind, media_type, record.size, record.sha256, viewer_loadable, downloadable))
272
+ if sum(entry.kind == "glb" for entry in entries) != 1:
273
+ raise SiteError("A prepared site requires exactly one manifest-listed GLB")
274
+ required = {"glb_manifest", "design_manifest", "build_manifest"}
275
+ missing = sorted(required - {entry.kind for entry in entries})
276
+ if missing:
277
+ raise SiteError(f"Prepared site is missing required artifact kind(s): {', '.join(missing)}")
278
+ return tuple(sorted(entries, key=lambda entry: entry.site_path)), sorted(omitted, key=lambda item: item["source_path"])
279
+
280
+
281
+ def _viewer_shell() -> tuple[dict[str, tuple[bytes, str]], str]:
282
+ root = resources.files("python_cad_tools").joinpath("_viewer_static")
283
+ try:
284
+ manifest = json.loads(root.joinpath("asset-manifest.json").read_text(encoding="utf-8"))
285
+ except (FileNotFoundError, json.JSONDecodeError) as error:
286
+ raise ArtifactIntegrityError("Installed viewer asset manifest is missing or invalid") from error
287
+ if not isinstance(manifest, dict) or set(manifest) != {"schema_id", "schema_version", "entries"} or manifest.get("schema_id") != ASSET_SCHEMA or manifest.get("schema_version") != 1:
288
+ raise ArtifactIntegrityError("Unsupported viewer asset manifest schema")
289
+ raw_entries = manifest.get("entries")
290
+ if not isinstance(raw_entries, list):
291
+ raise ArtifactIntegrityError("Viewer asset manifest entries must be an array")
292
+ shell: dict[str, tuple[bytes, str]] = {}
293
+ canonical_records: list[dict[str, Any]] = []
294
+ for value in raw_entries:
295
+ if not isinstance(value, dict) or set(value) != {"path", "size", "sha256", "media_type"}:
296
+ raise ArtifactIntegrityError("Invalid viewer asset record")
297
+ relative = _safe_relative(value.get("path"))
298
+ if relative in shell or relative.casefold() in {path.casefold() for path in shell}:
299
+ raise ArtifactIntegrityError(f"Duplicate viewer shell path: {relative}")
300
+ if relative.startswith("artifacts/") or relative in {"asset-manifest.json", "download-manifest.json", "site-metadata.json"}:
301
+ raise ArtifactIntegrityError(f"Reserved viewer shell path: {relative}")
302
+ data = root.joinpath(*PurePosixPath(relative).parts).read_bytes()
303
+ if type(value.get("size")) is not int or len(data) != value["size"] or _sha256_bytes(data) != value.get("sha256"):
304
+ raise ArtifactIntegrityError(f"Viewer shell integrity mismatch: {relative}")
305
+ media_type = value.get("media_type")
306
+ if not isinstance(media_type, str):
307
+ raise ArtifactIntegrityError(f"Invalid viewer shell media type: {relative}")
308
+ shell[relative] = (data, media_type)
309
+ canonical_records.append(cast(dict[str, Any], value))
310
+ if [item["path"] for item in canonical_records] != sorted(item["path"] for item in canonical_records):
311
+ raise ArtifactIntegrityError("Viewer asset records are not path-sorted")
312
+ digest = _sha256_bytes(canonical_json_bytes(canonical_records))
313
+ if get_package_build_info().viewer_shell_sha256 != digest:
314
+ raise ArtifactIntegrityError("Viewer shell hash does not match build-info.json")
315
+ return shell, digest
316
+
317
+
318
+ def _stage_site(
319
+ *, target: Path, identity: str, output: Path, shell: dict[str, tuple[bytes, str]], entries: tuple[SiteEntry, ...],
320
+ download_bytes: bytes, project_id: str, design_build_hash: str, base_path: str, viewer_shell_sha256: str,
321
+ download_manifest_sha256: str, tool_version: str,
322
+ ) -> Path:
323
+ staging = target.parent / f".{target.name}.python-cad-stage-{uuid.uuid4().hex}"
324
+ staging.mkdir(mode=0o700)
325
+ owners: dict[str, tuple[str, Literal["viewer_shell", "generated_artifact", "site_document"]]] = {}
326
+ try:
327
+ for relative, (data, media_type) in shell.items():
328
+ _write_bytes(staging, relative, data)
329
+ owners[relative] = (media_type, "viewer_shell")
330
+ for entry in entries:
331
+ source = output.joinpath(*PurePosixPath(entry.source_path).parts)
332
+ _write_bytes(staging, entry.site_path, source.read_bytes())
333
+ owners[entry.site_path] = (entry.media_type, "generated_artifact")
334
+ _write_bytes(staging, "download-manifest.json", download_bytes)
335
+ owners["download-manifest.json"] = ("application/json", "site_document")
336
+ inventory = _managed_inventory(staging, owners)
337
+ site_metadata = {
338
+ "schema_id": SITE_SCHEMA,
339
+ "schema_version": 1,
340
+ "managed_by": "python-cad-tools",
341
+ "tool_version": tool_version,
342
+ "project_id": project_id,
343
+ "design_build_hash": design_build_hash,
344
+ "base_path": base_path,
345
+ "destination_path_sha256": identity,
346
+ "viewer_shell_sha256": viewer_shell_sha256,
347
+ "download_manifest_sha256": download_manifest_sha256,
348
+ "managed_inventory": [asdict(item) for item in inventory],
349
+ }
350
+ _write_bytes(staging, "site-metadata.json", _canonical_json(site_metadata))
351
+ _verify_managed_site(staging, identity, project_id, destination_for_identity=target)
352
+ return staging
353
+ except BaseException:
354
+ shutil.rmtree(staging, ignore_errors=True)
355
+ raise
356
+
357
+
358
+ def _promote_site(target: Path, identity: str, staging: Path) -> None:
359
+ journal = _journal_path(target, identity)
360
+ backup = target.parent / f".{target.name}.python-cad-backup-{uuid.uuid4().hex}"
361
+ state = {"schema_version": 1, "destination_path_sha256": identity, "destination": str(target), "staging": str(staging), "backup": str(backup), "phase": "staged"}
362
+ _write_journal(journal, state)
363
+ try:
364
+ _recheck_rename_target(target, identity)
365
+ if target.exists():
366
+ target.rename(backup)
367
+ state["phase"] = "backup_created"
368
+ _write_journal(journal, state)
369
+ _recheck_rename_target(target, identity)
370
+ staging.rename(target)
371
+ state["phase"] = "promoted"
372
+ _write_journal(journal, state)
373
+ _verify_managed_site(target, identity)
374
+ shutil.rmtree(backup, ignore_errors=True)
375
+ journal.unlink(missing_ok=True)
376
+ except BaseException:
377
+ if target.exists():
378
+ shutil.rmtree(target, ignore_errors=True)
379
+ if backup.exists():
380
+ backup.rename(target)
381
+ shutil.rmtree(staging, ignore_errors=True)
382
+ journal.unlink(missing_ok=True)
383
+ raise
384
+
385
+
386
+ def _recover_site_transaction(target: Path, identity: str) -> None:
387
+ journal = _journal_path(target, identity)
388
+ if not journal.exists():
389
+ return
390
+ try:
391
+ state = json.loads(journal.read_text(encoding="utf-8"))
392
+ if state.get("destination_path_sha256") != identity or Path(state.get("destination", "")) != target:
393
+ raise UnsafePathError(f"Refusing foreign site transaction journal: {journal}")
394
+ staging = _transaction_path(target, state.get("staging"), "stage")
395
+ backup = _transaction_path(target, state.get("backup"), "backup")
396
+ if target.exists():
397
+ _verify_managed_site(target, identity)
398
+ shutil.rmtree(staging, ignore_errors=True)
399
+ shutil.rmtree(backup, ignore_errors=True)
400
+ elif backup.exists():
401
+ backup.rename(target)
402
+ shutil.rmtree(staging, ignore_errors=True)
403
+ elif staging.exists():
404
+ _verify_managed_site(staging, identity, destination_for_identity=target)
405
+ staging.rename(target)
406
+ else:
407
+ raise UnsafePathError("Site recovery has no complete destination, staging, or backup tree")
408
+ journal.unlink(missing_ok=True)
409
+ except (OSError, ValueError, TypeError, json.JSONDecodeError) as error:
410
+ raise UnsafePathError(f"Unable to recover viewer-site transaction: {error}") from error
411
+
412
+
413
+ def _validate_destination(project_root: Path, output: Path, target: Path) -> None:
414
+ home = Path.home().resolve()
415
+ git = project_root / ".git"
416
+ root = Path(target.anchor)
417
+ forbidden = (root, home, project_root, git.resolve(strict=False), output.resolve(strict=False))
418
+ if any(target == value or value.is_relative_to(target) for value in forbidden):
419
+ raise UnsafePathError(f"Unsafe viewer-site destination: {target}")
420
+ if target.is_relative_to(git.resolve(strict=False)) or target.is_relative_to(output.resolve(strict=False)):
421
+ raise UnsafePathError(f"Viewer-site destination may not be inside .git or generated output: {target}")
422
+ if target.is_symlink():
423
+ raise UnsafePathError(f"Viewer-site destination may not be a symlink: {target}")
424
+ parent = target.parent
425
+ if not parent.exists() or not parent.is_dir() or parent.is_symlink():
426
+ raise UnsafePathError(f"Viewer-site destination parent must be an existing regular directory: {parent}")
427
+
428
+
429
+ def _validate_existing_site(target: Path, identity: str, project_id: str) -> None:
430
+ if not target.exists():
431
+ return
432
+ if not target.is_dir() or target.is_symlink():
433
+ raise UnsafePathError(f"Viewer-site destination is not a regular directory: {target}")
434
+ if not any(target.iterdir()):
435
+ return
436
+ _verify_managed_site(target, identity, project_id)
437
+
438
+
439
+ def _verify_managed_site(root: Path, identity: str | None = None, project_id: str | None = None, *, destination_for_identity: Path | None = None) -> dict[str, Any]:
440
+ marker = root / "site-metadata.json"
441
+ try:
442
+ value = json.loads(marker.read_text(encoding="utf-8"))
443
+ except (FileNotFoundError, OSError, json.JSONDecodeError) as error:
444
+ raise UnsafePathError(f"Destination is not a valid Python CAD Tools managed site: {root}") from error
445
+ if not isinstance(value, dict) or set(value) != _SITE_FIELDS or value.get("schema_id") != SITE_SCHEMA or value.get("schema_version") != 1 or value.get("managed_by") != "python-cad-tools":
446
+ raise UnsafePathError(f"Destination has invalid ownership metadata: {root}")
447
+ expected_identity = identity
448
+ if expected_identity is None:
449
+ _, expected_identity = destination_identity(destination_for_identity or root)
450
+ if value.get("destination_path_sha256") != expected_identity:
451
+ raise UnsafePathError("Viewer-site destination identity does not match ownership metadata")
452
+ if project_id is not None and value.get("project_id") != project_id:
453
+ raise UnsafePathError(f"Viewer-site destination belongs to foreign project {value.get('project_id')!r}")
454
+ if not isinstance(value.get("base_path"), str) or normalize_base_path(value["base_path"]) != value["base_path"]:
455
+ raise UnsafePathError("Viewer-site metadata has an invalid base path")
456
+ inventory = value.get("managed_inventory")
457
+ if not isinstance(inventory, list):
458
+ raise UnsafePathError("Viewer-site managed_inventory must be an array")
459
+ actual = _regular_files(root)
460
+ actual.pop("site-metadata.json", None)
461
+ expected: dict[str, dict[str, Any]] = {}
462
+ for record in inventory:
463
+ if not isinstance(record, dict) or set(record) != _INVENTORY_FIELDS:
464
+ raise UnsafePathError("Viewer-site inventory contains an invalid record")
465
+ relative = _safe_relative(record.get("path"))
466
+ if relative in expected or record.get("owner") not in {"viewer_shell", "generated_artifact", "site_document"}:
467
+ raise UnsafePathError(f"Viewer-site inventory contains an invalid path or owner: {relative}")
468
+ expected[relative] = record
469
+ if [record["path"] for record in inventory] != sorted(expected):
470
+ raise UnsafePathError("Viewer-site inventory is not path-sorted")
471
+ if set(actual) != set(expected):
472
+ raise UnsafePathError(f"Viewer-site inventory mismatch; missing={sorted(set(expected)-set(actual))}, unexpected={sorted(set(actual)-set(expected))}")
473
+ for relative, path in actual.items():
474
+ record = expected[relative]
475
+ if path.stat().st_size != record.get("size") or sha256_file(path) != record.get("sha256"):
476
+ raise UnsafePathError(f"Viewer-site file does not match ownership inventory: {relative}")
477
+ download = actual.get("download-manifest.json")
478
+ if download is None or sha256_file(download) != value.get("download_manifest_sha256"):
479
+ raise UnsafePathError("Viewer-site download manifest hash mismatch")
480
+ return value
481
+
482
+
483
+ def _managed_inventory(root: Path, owners: dict[str, tuple[str, Literal["viewer_shell", "generated_artifact", "site_document"]]]) -> tuple[ManagedSiteFile, ...]:
484
+ actual = _regular_files(root)
485
+ if set(actual) != set(owners):
486
+ raise SiteError("Internal prepared-site inventory mismatch")
487
+ return tuple(
488
+ ManagedSiteFile(relative, path.stat().st_size, sha256_file(path), owners[relative][0], owners[relative][1])
489
+ for relative, path in sorted(actual.items())
490
+ )
491
+
492
+
493
+ def _regular_files(root: Path) -> dict[str, Path]:
494
+ if root.is_symlink() or not root.is_dir():
495
+ raise UnsafePathError(f"Managed site is not a regular directory: {root}")
496
+ result: dict[str, Path] = {}
497
+ for directory, names, files in os.walk(root, followlinks=False):
498
+ current = Path(directory)
499
+ for name in names:
500
+ if (current / name).is_symlink():
501
+ raise UnsafePathError(f"Managed site contains a symlink: {(current / name).relative_to(root)}")
502
+ for name in files:
503
+ path = current / name
504
+ if path.is_symlink() or not path.is_file():
505
+ raise UnsafePathError(f"Managed site contains a non-regular file: {path.relative_to(root)}")
506
+ relative = _safe_relative(path.relative_to(root).as_posix())
507
+ if relative in result or relative.casefold() in {value.casefold() for value in result}:
508
+ raise UnsafePathError(f"Managed site contains a path collision: {relative}")
509
+ result[relative] = path
510
+ return dict(sorted(result.items()))
511
+
512
+
513
+ def _check_site_collisions(shell: dict[str, tuple[bytes, str]], entries: tuple[SiteEntry, ...]) -> None:
514
+ paths = list(shell) + [entry.site_path for entry in entries] + ["download-manifest.json", "site-metadata.json"]
515
+ folded = [path.casefold() for path in paths]
516
+ if len(folded) != len(set(folded)):
517
+ raise SiteError("Viewer shell and generated artifacts contain a case-folded path collision")
518
+
519
+
520
+ def _handler(root: Path, site: dict[str, Any]) -> type[http.server.BaseHTTPRequestHandler]:
521
+ inventory = {record["path"]: record for record in site["managed_inventory"]}
522
+ inventory["site-metadata.json"] = {
523
+ "media_type": "application/json", "sha256": sha256_file(root / "site-metadata.json"), "size": (root / "site-metadata.json").stat().st_size,
524
+ }
525
+ health = _canonical_json({"status": "ok", "tool_version": site["tool_version"], "design_build_hash": site["design_build_hash"]})
526
+ base = cast(str, site["base_path"])
527
+
528
+ class Handler(http.server.BaseHTTPRequestHandler):
529
+ def do_HEAD(self) -> None: # noqa: N802
530
+ self._serve(send_body=False)
531
+
532
+ def do_GET(self) -> None: # noqa: N802
533
+ self._serve(send_body=True)
534
+
535
+ def _serve(self, *, send_body: bool) -> None:
536
+ parsed = urllib.parse.urlsplit(self.path)
537
+ if parsed.query or parsed.fragment or "\\" in self.path or "%2f" in self.path.lower() or "%5c" in self.path.lower():
538
+ return self.send_error(400)
539
+ if parsed.path in {"/healthz", f"{base.rstrip('/')}/healthz"}:
540
+ return self._bytes(health, "application/json", _sha256_bytes(health), send_body)
541
+ if "Range" in self.headers:
542
+ return self.send_error(416)
543
+ try:
544
+ decoded = urllib.parse.unquote(parsed.path, errors="strict")
545
+ except UnicodeError:
546
+ return self.send_error(400)
547
+ if not decoded.startswith(base):
548
+ return self.send_error(404)
549
+ relative = decoded[len(base):] or "index.html"
550
+ try:
551
+ relative = _safe_relative(relative)
552
+ except ArtifactIntegrityError:
553
+ return self.send_error(400)
554
+ record = inventory.get(relative)
555
+ if record is None:
556
+ return self.send_error(404)
557
+ data = (root / relative).read_bytes()
558
+ self._bytes(data, record["media_type"], record["sha256"], send_body)
559
+
560
+ def _bytes(self, data: bytes, media_type: str, digest: str, send_body: bool) -> None:
561
+ etag = f'"sha256-{digest}"'
562
+ if self.headers.get("If-None-Match") == etag:
563
+ self.send_response(304)
564
+ self.send_header("ETag", etag)
565
+ self.end_headers()
566
+ return
567
+ self.send_response(200)
568
+ self.send_header("Content-Type", media_type)
569
+ self.send_header("Content-Length", str(len(data)))
570
+ self.send_header("ETag", etag)
571
+ self.send_header("X-Content-Type-Options", "nosniff")
572
+ self.send_header("Cache-Control", "no-cache")
573
+ self.end_headers()
574
+ if send_body:
575
+ self.wfile.write(data)
576
+
577
+ def log_message(self, _format: str, *_args: Any) -> None:
578
+ return
579
+
580
+ return Handler
581
+
582
+
583
+ @contextlib.contextmanager
584
+ def _destination_lock(parent: Path, identity: str) -> Iterator[None]:
585
+ path = parent / f".python-cad-site-{identity}.lock"
586
+ handle = path.open("a+b")
587
+ try:
588
+ if os.name == "nt":
589
+ import msvcrt
590
+ try:
591
+ msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1) # type: ignore[attr-defined]
592
+ except OSError as error:
593
+ raise BuildLockedError(f"Viewer-site destination is locked: {path}") from error
594
+ else:
595
+ import fcntl
596
+ try:
597
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
598
+ except OSError as error:
599
+ raise BuildLockedError(f"Viewer-site destination is locked: {path}") from error
600
+ yield
601
+ finally:
602
+ if os.name == "nt":
603
+ import msvcrt
604
+ with contextlib.suppress(OSError):
605
+ handle.seek(0)
606
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) # type: ignore[attr-defined]
607
+ else:
608
+ import fcntl
609
+ with contextlib.suppress(OSError):
610
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
611
+ handle.close()
612
+
613
+
614
+ def _write_bytes(root: Path, relative: str, data: bytes) -> None:
615
+ safe = _safe_relative(relative)
616
+ path = root.joinpath(*PurePosixPath(safe).parts)
617
+ path.parent.mkdir(parents=True, exist_ok=True)
618
+ path.write_bytes(data)
619
+
620
+
621
+ def _safe_relative(value: object) -> str:
622
+ if not isinstance(value, str) or not value or "\\" in value or "\x00" in value:
623
+ raise ArtifactIntegrityError(f"Unsafe site path: {value!r}")
624
+ path = PurePosixPath(value)
625
+ if path.is_absolute() or path.as_posix() != value or any(part in {"", ".", ".."} for part in path.parts):
626
+ raise ArtifactIntegrityError(f"Unsafe site path: {value!r}")
627
+ return value
628
+
629
+
630
+ def _canonical_json(value: Any) -> bytes:
631
+ return canonical_json_bytes(value)
632
+
633
+
634
+ def _sha256_bytes(value: bytes) -> str:
635
+ return hashlib.sha256(value).hexdigest()
636
+
637
+
638
+ def _journal_path(target: Path, identity: str) -> Path:
639
+ return target.parent / f".python-cad-site-{identity}.journal.json"
640
+
641
+
642
+ def _write_journal(path: Path, state: dict[str, Any]) -> None:
643
+ temporary = path.with_suffix(".tmp")
644
+ temporary.write_bytes(_canonical_json(state))
645
+ os.replace(temporary, path)
646
+
647
+
648
+ def _transaction_path(target: Path, value: object, kind: str) -> Path:
649
+ if not isinstance(value, str):
650
+ raise UnsafePathError(f"Site recovery has no valid {kind} path")
651
+ path = Path(value)
652
+ prefix = f".{target.name}.python-cad-{kind}-"
653
+ if path.parent != target.parent or not path.name.startswith(prefix):
654
+ raise UnsafePathError(f"Unsafe site recovery {kind} path: {path}")
655
+ return path
656
+
657
+
658
+ def _recheck_rename_target(target: Path, identity: str) -> None:
659
+ resolved, current = destination_identity(target)
660
+ if resolved != target or current != identity or target.parent.is_symlink() or not target.parent.is_dir() or target.is_symlink():
661
+ raise UnsafePathError("Viewer-site destination changed during promotion")
662
+
663
+
664
+ def _is_loopback(host: str) -> bool:
665
+ if host.lower() == "localhost":
666
+ return True
667
+ try:
668
+ return ipaddress.ip_address(host).is_loopback
669
+ except ValueError:
670
+ try:
671
+ return all(ipaddress.ip_address(item[4][0]).is_loopback for item in socket.getaddrinfo(host, None))
672
+ except (OSError, ValueError):
673
+ return False
674
+
675
+
676
+ __all__ = ["PreparedSite", "ServerResult", "destination_identity", "normalize_base_path", "prepare_site", "serve_prepared_site", "serve_project"]