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.
- python_cad_tools/__init__.py +10 -0
- python_cad_tools/_locking.py +63 -0
- python_cad_tools/_project_loader.py +273 -0
- python_cad_tools/_project_state.py +65 -0
- python_cad_tools/_scaffold.py +66 -0
- python_cad_tools/_transaction.py +208 -0
- python_cad_tools/_viewer_static/app.webmanifest +18 -0
- python_cad_tools/_viewer_static/asset-manifest.json +1 -0
- python_cad_tools/_viewer_static/assets/animationGroup-Sd34ntWg.js +1 -0
- python_cad_tools/_viewer_static/assets/bakedVertexAnimation-Cd_pRdrn.js +316 -0
- python_cad_tools/_viewer_static/assets/basisTextureLoader-DWgvS_1G.js +1 -0
- python_cad_tools/_viewer_static/assets/brdfTextureTools-CWGiG1W2.js +2 -0
- python_cad_tools/_viewer_static/assets/bumpFragment-CWULheF1.js +127 -0
- python_cad_tools/_viewer_static/assets/bumpVertex-DqzFZayE.js +6 -0
- python_cad_tools/_viewer_static/assets/bumpVertexDeclaration-C5EFyTio.js +6 -0
- python_cad_tools/_viewer_static/assets/clipPlaneFragment-BXRtt4jj.js +46 -0
- python_cad_tools/_viewer_static/assets/clipPlaneVertex-B_VhD1oQ.js +37 -0
- python_cad_tools/_viewer_static/assets/cubemapToSphericalPolynomial-DRpaukza.js +1 -0
- python_cad_tools/_viewer_static/assets/dds-Dkb2Cx2N.js +1 -0
- python_cad_tools/_viewer_static/assets/dds.pure-DtvJsKvY.js +1 -0
- python_cad_tools/_viewer_static/assets/ddsTextureLoader-H78xux1g.js +1 -0
- python_cad_tools/_viewer_static/assets/default.fragment-oSQ-tWhe.js +469 -0
- python_cad_tools/_viewer_static/assets/default.vertex-DhFjw_Da.js +209 -0
- python_cad_tools/_viewer_static/assets/defaultUboDeclaration-BMDHNppx.js +5 -0
- python_cad_tools/_viewer_static/assets/dumpTools-BaNZPjUX.js +2 -0
- python_cad_tools/_viewer_static/assets/envTextureLoader-V0YnLcIM.js +1 -0
- python_cad_tools/_viewer_static/assets/exrTextureLoader-CjGyIK3x.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphApplyForceBlock-CW5nuobR.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphApplyImpulseBlock-LFqYIkhj.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphArrayIndexBlock-BMOHUbBm.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphBezierCurveEasingBlock-knmTiuEk.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphBinaryOperationBlock-DFLuCAKJ.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphBranchBlock-BJRHMXMo.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphCachedOperationBlock-Dng-6jE0.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphCancelDelayBlock-CgbK3CQZ.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphCodeExecutionBlock-8zMtq9tg.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphConditionalDataBlock-BiLr2rkI.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphConsoleLogBlock-J1vcCtNP.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphConstantBlock-CFb5nuaV.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphContextBlock-DAeGVzhC.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphCounterBlock-lZlAQiJd.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphDataSwitchBlock-BIzvmbVy.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphDebounceBlock-DvNuMVwy.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphDebugBlock-Dymd2VEE.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphDoNBlock-v68aoNLH.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphEasingBlock-CTnGv92q.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphFlipFlopBlock-DXsO0Chw.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphForLoopBlock-CPpe9T75.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphFunctionReferenceBlock-DjDev4Pq.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphGLTFDataProvider-GIYCz4AQ.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphGetAngularVelocityBlock-BStkOVeL.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphGetAssetBlock-Eiqoivbz.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphGetLinearVelocityBlock-B0cpV5ws.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphGetPhysicsMassPropertiesBlock-B-H64yaI.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphGetPropertyBlock-CUyOsPw0.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphGetSoundVolumeBlock-C0FjrsUz.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphGetVariableBlock-O4597zBl.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphIndexOfBlock-E00Z3KXM.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphInterpolationBlock-DnkA9MNH.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphIsKeyPressedBlock-DDfeuAyf.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphIsSoundPlayingBlock-DMXg8jdM.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphJsonPointerParserBlock-OmCorK0y.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphKeyDownEventBlock-RuVtj6g2.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphKeyUpEventBlock-DO4pAfHi.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphKeyboardEventBlock-Bx3pAzzh.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphMathBlocks-7W1XYOC8.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphMathCombineExtractBlocks-a4f0Kv6e.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphMatrixMathBlocks-C5Z-y1Au.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphMeshPickEventBlock-D4m-I4CS.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphMultiGateBlock-BISVSMw6.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphPauseAnimationBlock-BYP2Z-HG.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphPauseSoundBlock-Drh1EeW6.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphPhysicsCollisionEventBlock-BoziAQ75.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphPlayAnimationBlock-Clry-68M.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphPlaySoundBlock-LbBVxGBE.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphPointerDownEventBlock-DIVXCSHh.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphPointerMoveEventBlock-BNNOzY2W.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphPointerOutEventBlock-B8f2dOFT.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphPointerOverEventBlock-CQr8LCee.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphPointerUpEventBlock-DMg_iEHl.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphReceiveCustomEventBlock-B8GJm9Ai.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphSceneReadyEventBlock-Dcdb29K2.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphSceneTickEventBlock-BLE5p4mi.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphSendCustomEventBlock-BMzDItQ5.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphSequenceBlock-DRURovjn.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphSetAngularVelocityBlock-Ba8dhkLM.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphSetDelayBlock-CnQR--Gn.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphSetLinearVelocityBlock-Dm1r3vkh.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphSetPhysicsMotionTypeBlock-BNtMLimi.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphSetPropertyBlock-C-uv2jhP.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphSetSoundVolumeBlock-D9LDx3lg.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphSetVariableBlock-DszzRB5X.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphSoundEndedEventBlock-DeTDSudb.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphStopAnimationBlock-DWuwoFWr.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphStopSoundBlock-DEnj2cQw.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphSwitchBlock-zs8Fp9ky.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphThrottleBlock-BYAKUQXd.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphTransformCoordinatesSystemBlock-DYP54m5P.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphTypeToTypeBlocks-vFwn79bS.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphUnaryOperationBlock-CaRh4Ef0.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphVectorMathBlocks-BEIhMs-A.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphWaitAllBlock-DFbELP_j.js +1 -0
- python_cad_tools/_viewer_static/assets/flowGraphWhileLoopBlock-C1OKQ-_F.js +1 -0
- python_cad_tools/_viewer_static/assets/fogFragment-DtDlQBnz.js +25 -0
- python_cad_tools/_viewer_static/assets/gaussianSplatting-Cfigjb15.js +242 -0
- python_cad_tools/_viewer_static/assets/gaussianSplatting-DIn8R8oF.js +296 -0
- python_cad_tools/_viewer_static/assets/gaussianSplatting.fragment-BxeRJ2k-.js +38 -0
- python_cad_tools/_viewer_static/assets/gaussianSplatting.fragment-DNDmvRs-.js +37 -0
- python_cad_tools/_viewer_static/assets/gaussianSplatting.vertex-D92z5jYn.js +86 -0
- python_cad_tools/_viewer_static/assets/gaussianSplatting.vertex-DSWfFnKQ.js +86 -0
- python_cad_tools/_viewer_static/assets/gaussianSplattingVoxel.fragment-DBQE2gDk.js +12 -0
- python_cad_tools/_viewer_static/assets/gaussianSplattingVoxel.fragment-qwx5ntqJ.js +17 -0
- python_cad_tools/_viewer_static/assets/gaussianSplattingVoxel.vertex-CP2kd3jU.js +30 -0
- python_cad_tools/_viewer_static/assets/gaussianSplattingVoxel.vertex-Dr-fEg_W.js +19 -0
- python_cad_tools/_viewer_static/assets/geometry.fragment-CsJIm2H0.js +242 -0
- python_cad_tools/_viewer_static/assets/geometry.fragment-Dph3muZt.js +262 -0
- python_cad_tools/_viewer_static/assets/geometry.vertex-BXGMTA_J.js +212 -0
- python_cad_tools/_viewer_static/assets/geometry.vertex-Bm9Wg3Gi.js +214 -0
- python_cad_tools/_viewer_static/assets/glowBlurPostProcess.fragment-BJSwKIJD.js +7 -0
- python_cad_tools/_viewer_static/assets/glowMapGeneration.fragment-DcyS1cTF.js +63 -0
- python_cad_tools/_viewer_static/assets/glowMapGeneration.vertex-BGq1Fakj.js +75 -0
- python_cad_tools/_viewer_static/assets/glowMapMerge.fragment-DxcneU_F.js +25 -0
- python_cad_tools/_viewer_static/assets/glowMapMerge.vertex-DaJJKqMV.js +8 -0
- python_cad_tools/_viewer_static/assets/harmonicsFunctions-DzJcvNWG.js +16 -0
- python_cad_tools/_viewer_static/assets/hdrTextureLoader-BGQvgh0h.js +2 -0
- python_cad_tools/_viewer_static/assets/helperFunctions-Cugfp1Fz.js +87 -0
- python_cad_tools/_viewer_static/assets/iesTextureLoader-CO4G0p2M.js +2 -0
- python_cad_tools/_viewer_static/assets/index-B2zBEWsq.js +7007 -0
- python_cad_tools/_viewer_static/assets/index-DcTTrc-8.css +1 -0
- python_cad_tools/_viewer_static/assets/kernelBlur.fragment-B6CTXTxY.js +57 -0
- python_cad_tools/_viewer_static/assets/kernelBlur.fragment-BkKbudbW.js +57 -0
- python_cad_tools/_viewer_static/assets/kernelBlur.vertex-BYwFU5IS.js +11 -0
- python_cad_tools/_viewer_static/assets/kernelBlur.vertex-D371zUfT.js +11 -0
- python_cad_tools/_viewer_static/assets/kernelBlurVaryingDeclaration-CgBGaCHg.js +1 -0
- python_cad_tools/_viewer_static/assets/kernelBlurVaryingDeclaration-YS8JpqdO.js +1 -0
- python_cad_tools/_viewer_static/assets/ktxTextureLoader-ncMSJTjl.js +1 -0
- python_cad_tools/_viewer_static/assets/lightFragment-skHiFbKB.js +398 -0
- python_cad_tools/_viewer_static/assets/logDepthDeclaration-BR8XRz9F.js +4 -0
- python_cad_tools/_viewer_static/assets/logDepthVertex-UKrhEIvg.js +14 -0
- python_cad_tools/_viewer_static/assets/mainUVVaryingDeclaration-BxQWGFcG.js +4 -0
- python_cad_tools/_viewer_static/assets/mesh.vertexData.functions-ayNlGC5K.js +1 -0
- python_cad_tools/_viewer_static/assets/meshUboDeclaration-BnX046aQ.js +4 -0
- python_cad_tools/_viewer_static/assets/oitFragment-CTMCOfNP.js +605 -0
- python_cad_tools/_viewer_static/assets/openpbr.fragment-DPJPqi6p.js +1530 -0
- python_cad_tools/_viewer_static/assets/openpbr.fragment-Dv6qTVVi.js +1384 -0
- python_cad_tools/_viewer_static/assets/openpbr.vertex-Bn2wrdGL.js +364 -0
- python_cad_tools/_viewer_static/assets/openpbr.vertex-CHESW3gB.js +251 -0
- python_cad_tools/_viewer_static/assets/openpbrMaterial.pure-BdK7VgO_.js +3 -0
- python_cad_tools/_viewer_static/assets/openpbrMaterialLoadingAdapter-Bm_AHDUN.js +4 -0
- python_cad_tools/_viewer_static/assets/openpbrTransmissionLayerData-BDa9qEoN.js +339 -0
- python_cad_tools/_viewer_static/assets/openpbrTransmissionLayerData-Ba_ly4LE.js +343 -0
- python_cad_tools/_viewer_static/assets/openpbrUboDeclaration-BY2lSH2C.js +5 -0
- python_cad_tools/_viewer_static/assets/openpbrUboDeclaration-DajhikDS.js +6 -0
- python_cad_tools/_viewer_static/assets/packingFunctions-BHPwcGG2.js +4 -0
- python_cad_tools/_viewer_static/assets/packingFunctions-DTwzBRBx.js +4 -0
- python_cad_tools/_viewer_static/assets/pass.fragment-9oKU4pI3.js +4 -0
- python_cad_tools/_viewer_static/assets/passCube.fragment-CpAeiYx1.js +23 -0
- python_cad_tools/_viewer_static/assets/passCube.fragment-DHtSWVRF.js +23 -0
- python_cad_tools/_viewer_static/assets/pbr.fragment-cIhMKwLs.js +2560 -0
- python_cad_tools/_viewer_static/assets/pbr.vertex-BfdONsf2.js +255 -0
- python_cad_tools/_viewer_static/assets/pbrBRDFFunctions-B_ppOzPO.js +173 -0
- python_cad_tools/_viewer_static/assets/pbrDebug-DDdwL5_t.js +693 -0
- python_cad_tools/_viewer_static/assets/pbrIBLFunctions-b1zdpzOP.js +43 -0
- python_cad_tools/_viewer_static/assets/pbrMaterial.pure-BZA7yVhu.js +2 -0
- python_cad_tools/_viewer_static/assets/pbrMaterialLoadingAdapter-BRvKRnjl.js +1 -0
- python_cad_tools/_viewer_static/assets/pbrUboDeclaration-CMZp25k3.js +5 -0
- python_cad_tools/_viewer_static/assets/postprocess.vertex-DzdUpNbW.js +9 -0
- python_cad_tools/_viewer_static/assets/procedural.vertex-BnbuQ-1f.js +7 -0
- python_cad_tools/_viewer_static/assets/procedural.vertex-Dp-Ng7Pc.js +8 -0
- python_cad_tools/_viewer_static/assets/reflectionFunction-DBlr3oDs.js +83 -0
- python_cad_tools/_viewer_static/assets/rgbdDecode.fragment-CyvnZFOJ.js +5 -0
- python_cad_tools/_viewer_static/assets/rgbdEncode.fragment-Dz4zLnoD.js +5 -0
- python_cad_tools/_viewer_static/assets/rgbdEncode.fragment-iscgpA1_.js +5 -0
- python_cad_tools/_viewer_static/assets/sceneUboDeclaration-BzFIc4mb.js +11 -0
- python_cad_tools/_viewer_static/assets/textureProcessor.fragment-C9D0rd-M.js +151 -0
- python_cad_tools/_viewer_static/assets/textureProcessor.fragment-JMKyWZng.js +146 -0
- python_cad_tools/_viewer_static/assets/tgaTextureLoader-oxTElIQJ.js +1 -0
- python_cad_tools/_viewer_static/assets/vertexColorMixing-mdC9xZe1.js +418 -0
- python_cad_tools/_viewer_static/favicon.svg +10 -0
- python_cad_tools/_viewer_static/index.html +18 -0
- python_cad_tools/_viewer_static/sw.js +61 -0
- python_cad_tools/artifacts.py +546 -0
- python_cad_tools/build-info.json +1 -0
- python_cad_tools/build.py +270 -0
- python_cad_tools/cli.py +212 -0
- python_cad_tools/context.py +67 -0
- python_cad_tools/determinism.py +66 -0
- python_cad_tools/drawings/__init__.py +124 -0
- python_cad_tools/drawings/_catalog.py +79 -0
- python_cad_tools/drawings/_validation.py +238 -0
- python_cad_tools/elements.py +121 -0
- python_cad_tools/exceptions.py +45 -0
- python_cad_tools/exporters/__init__.py +8 -0
- python_cad_tools/exporters/base.py +24 -0
- python_cad_tools/exporters/drawings/__init__.py +5 -0
- python_cad_tools/exporters/drawings/annotations.py +444 -0
- python_cad_tools/exporters/drawings/dxf.py +110 -0
- python_cad_tools/exporters/drawings/model.py +70 -0
- python_cad_tools/exporters/drawings/pdf.py +119 -0
- python_cad_tools/exporters/drawings/primitives.py +93 -0
- python_cad_tools/exporters/drawings/projection.py +64 -0
- python_cad_tools/exporters/drawings/svg.py +135 -0
- python_cad_tools/exporters/glb.py +100 -0
- python_cad_tools/exporters/ifc.py +229 -0
- python_cad_tools/exporters/quantities.py +136 -0
- python_cad_tools/exporters/step.py +117 -0
- python_cad_tools/geometry.py +187 -0
- python_cad_tools/manifests.py +338 -0
- python_cad_tools/project_config.py +174 -0
- python_cad_tools/py.typed +1 -0
- python_cad_tools/runtime_sync.py +126 -0
- python_cad_tools/schemas/build-manifest-1.json +1 -0
- python_cad_tools/schemas/design-manifest-1.json +1 -0
- python_cad_tools/schemas/download-manifest-2.json +46 -0
- python_cad_tools/schemas/drawing-annotations-1.json +66 -0
- python_cad_tools/schemas/glb-manifest-1.json +1 -0
- python_cad_tools/schemas/ifc-validation-1.json +1 -0
- python_cad_tools/schemas/quantities-1.json +1 -0
- python_cad_tools/schemas/run-metadata-1.json +1 -0
- python_cad_tools/schemas/site-metadata-1.json +34 -0
- python_cad_tools/schemas/step-validation-1.json +1 -0
- python_cad_tools/site.py +676 -0
- python_cad_tools/templates/project/.gitignore +9 -0
- python_cad_tools/templates/project/README.md +38 -0
- python_cad_tools/templates/project/config.py +36 -0
- python_cad_tools/templates/project/drawing_annotations.py +117 -0
- python_cad_tools/templates/project/model.py +87 -0
- python_cad_tools/templates/project/pyproject.toml +27 -0
- python_cad_tools/units.py +207 -0
- python_cad_tools/validation.py +153 -0
- python_cad_tools-0.1.0rc1.dist-info/METADATA +149 -0
- python_cad_tools-0.1.0rc1.dist-info/RECORD +236 -0
- python_cad_tools-0.1.0rc1.dist-info/WHEEL +5 -0
- python_cad_tools-0.1.0rc1.dist-info/entry_points.txt +2 -0
- python_cad_tools-0.1.0rc1.dist-info/licenses/LICENSE +679 -0
- python_cad_tools-0.1.0rc1.dist-info/top_level.txt +1 -0
|
@@ -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)
|