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
python_cad_tools/site.py
ADDED
|
@@ -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"]
|