From 09c40299780765150a39efbb65d376f2657e5d5c Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Sat, 17 Oct 2026 09:07:22 +0200 Subject: [PATCH 1/9] Gruppo uv windows per il pacchetto Windows: aiosqlite, waitress, vs-license, nuitka (TieMeasureFlow#76) Co-Authored-By: Claude Sonnet 5.5 --- pyproject.toml | 13 +++++ uv.lock | 133 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 646b3a8..79dc65e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,6 +81,18 @@ vision-worker = [ "python-multipart>=0.0.6; python_version>='3.13'", ] +# Pacchetto Windows (packaging/windows, TieMeasureFlow#76): solo per build.ps1, non per +# i container. Si usa con --python 3.13 insieme agli extra server, client e vision-worker. +[dependency-groups] +windows = [ + "aiosqlite>=0.20.0", # database SQLite su file (DB_URL, piano A) + "waitress>=3.0.0", # server WSGI del frontend Flask (gunicorn non gira su Windows) + "vs-license; python_version>='3.13'", # licenza Tielogic v1 (impronta macchina, firma Ed25519) + "nuitka>=2.5", # compilazione standalone + "ordered-set>=4.1", # richiesto da Nuitka per la modalita' standalone + "zstandard>=0.22", # compressione dei dati incorporati di Nuitka +] + [project.scripts] # Backend server = "uvicorn:run" # placeholder, real CMD lives in Dockerfile @@ -105,6 +117,7 @@ vs-measure = { path = "vendor/visionsuite/packages/vs-measure", editable = true vs-pm2d = { path = "vendor/visionsuite/packages/vs-pm2d", editable = true } vs-tools = { path = "vendor/visionsuite/packages/vs-tools", editable = true } # #46 vs-dxf = { path = "vendor/visionsuite/packages/vs-dxf", editable = true } # #46 +vs-license = { path = "vendor/visionsuite/packages/vs-license", editable = true } # #76 licenza del pacchetto Windows [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/uv.lock b/uv.lock index 956e07a..1273cd0 100644 --- a/uv.lock +++ b/uv.lock @@ -1113,6 +1113,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/69/f24d3d1c38ad69e256138b4ec2452a8c7cf66be49dc214771ae99dd4f0a0/narwhals-2.20.0-py3-none-any.whl", hash = "sha256:16e750ea5507d4ba6e8d03455b5f93a535e0405976561baea235bca5dc9f475d", size = 449373, upload-time = "2026-04-20T12:11:43.596Z" }, ] +[[package]] +name = "nuitka" +version = "4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/5f/ba7cb858da0c98c24f85c54454a5ea18f2b784f658a91531fa051b2f2ee9/nuitka-4.3.tar.gz", hash = "sha256:8b102c6bf30d9504e82e69674d396972729092b25cefa680e8fd05678fdfa30b", size = 4766934, upload-time = "2026-10-10T11:13:15.577Z" } + [[package]] name = "numba" version = "0.67.0" @@ -1238,6 +1244,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/21/f0/9fa6e85cb10c8eb36a0222d27e50fe381b86ce49a55446bf39f491727564/opencv_python-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:f90ba04b8f73bc5c3814037699739f0156f597338a98f05956c684e7c3ca10d2", size = 44000345, upload-time = "2026-07-02T05:49:54.971Z" }, ] +[[package]] +name = "ordered-set" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/ca/bfac8bc689799bcca4157e0e0ced07e70ce125193fc2e166d2e685b7e2fe/ordered-set-4.1.0.tar.gz", hash = "sha256:694a8e44c87657c59292ede72891eb91d34131f6531463aab3009191c77364a8", size = 12826, upload-time = "2022-01-26T14:38:56.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/55/af02708f230eb77084a299d7b08175cff006dea4f2721074b92cdb0296c0/ordered_set-4.1.0-py3-none-any.whl", hash = "sha256:046e1132c71fcf3330438a539928932caf51ddbc582496833e23de611de14562", size = 7634, upload-time = "2022-01-26T14:38:48.677Z" }, +] + [[package]] name = "orjson" version = "3.11.8" @@ -2043,6 +2058,16 @@ vision-worker = [ { name = "vs-tools", extra = ["dxf", "locate"], marker = "python_full_version >= '3.13'" }, ] +[package.dev-dependencies] +windows = [ + { name = "aiosqlite" }, + { name = "nuitka" }, + { name = "ordered-set" }, + { name = "vs-license", marker = "python_full_version >= '3.13'" }, + { name = "waitress" }, + { name = "zstandard" }, +] + [package.metadata] requires-dist = [ { name = "aiosqlite", marker = "extra == 'dev'", specifier = ">=0.20.0" }, @@ -2087,6 +2112,16 @@ requires-dist = [ ] provides-extras = ["server", "client", "dev", "vision", "vision-worker"] +[package.metadata.requires-dev] +windows = [ + { name = "aiosqlite", specifier = ">=0.20.0" }, + { name = "nuitka", specifier = ">=2.5" }, + { name = "ordered-set", specifier = ">=4.1" }, + { name = "vs-license", marker = "python_full_version >= '3.13'", editable = "vendor/visionsuite/packages/vs-license" }, + { name = "waitress", specifier = ">=3.0.0" }, + { name = "zstandard", specifier = ">=0.22" }, +] + [[package]] name = "tinycss2" version = "1.5.1" @@ -2256,6 +2291,21 @@ requires-dist = [ ] provides-extras = ["dev"] +[[package]] +name = "vs-license" +version = "0.1.0" +source = { editable = "vendor/visionsuite/packages/vs-license" } +dependencies = [ + { name = "cryptography" }, +] + +[package.metadata] +requires-dist = [ + { name = "cryptography", specifier = ">=42" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7" }, +] +provides-extras = ["dev"] + [[package]] name = "vs-measure" version = "0.1.0" @@ -2360,6 +2410,15 @@ requires-dist = [ ] provides-extras = ["locate", "dxf", "dev"] +[[package]] +name = "waitress" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/cb/04ddb054f45faa306a230769e868c28b8065ea196891f09004ebace5b184/waitress-3.0.2.tar.gz", hash = "sha256:682aaaf2af0c44ada4abfb70ded36393f0e307f4ab9456a215ce0020baefc31f", size = 179901, upload-time = "2024-11-16T20:02:35.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/57/a27182528c90ef38d82b636a11f606b0cbb0e17588ed205435f8affe3368/waitress-3.0.2-py3-none-any.whl", hash = "sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e", size = 56232, upload-time = "2024-11-16T20:02:33.858Z" }, +] + [[package]] name = "watchfiles" version = "1.1.1" @@ -2576,3 +2635,77 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/23/f0/ad6e26aa06943ce9f1be4ae6738513a7b69d8ea1f3b13e46009a249a3f73/zopfli-0.4.1-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb136a74d14a4ecfae29cb0fdecece58a6c115abc9a74c12bc6ac62e80f229d7", size = 124371, upload-time = "2026-02-13T14:17:24.976Z" }, { url = "https://files.pythonhosted.org/packages/7b/36/3c15d564db6dfdd740919b205bdb69be75113e9919c422cde658e6d013c0/zopfli-0.4.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2f992ac7d83cbddd889e1813ace576cbc91a05d5d7a0a21b366e2e5f492e7707", size = 102199, upload-time = "2026-02-13T14:17:26.246Z" }, ] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" }, + { url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" }, + { url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" }, + { url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" }, + { url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" }, + { url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" }, + { url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" }, + { url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" }, + { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, + { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, + { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, + { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" }, + { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" }, + { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" }, + { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" }, + { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" }, + { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" }, + { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" }, + { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" }, + { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" }, +] From d89a2e6c8dafb4cb212e5237e632e62a5d9ee07c Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Sat, 17 Oct 2026 09:10:05 +0200 Subject: [PATCH 2/9] Pacchetto Windows: avvio.py (licenza, config.env, migrazioni, quattro server in thread), vs-camera nel gruppo windows (TieMeasureFlow#76) Co-Authored-By: Claude Sonnet 5.5 --- packaging/windows/avvio.py | 453 +++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 + uv.lock | 67 ++++++ 3 files changed, 522 insertions(+) create mode 100644 packaging/windows/avvio.py diff --git a/packaging/windows/avvio.py b/packaging/windows/avvio.py new file mode 100644 index 0000000..1b13c92 --- /dev/null +++ b/packaging/windows/avvio.py @@ -0,0 +1,453 @@ +"""Entry point del pacchetto Windows di TieMeasureFlow (TieMeasureFlow#76). + +Cosa fa: controlla la licenza (prodotto "tiemeasureflow"); se non e' valida mostra il +motivo e l'impronta del computer, scrive l'impronta in +%PROGRAMDATA%\\Tielogic\\licenze\\impronta-tiemeasureflow.txt ed esce con 1. +Se e' valida: prepara la cartella dati (%PROGRAMDATA%\\Tielogic\\TieMeasureFlow, con +config.env dei segreti generati al primo avvio), imposta le variabili d'ambiente del +contratto, esegue le migrazioni Alembic (SQLite su file) e avvia in un solo processo +quattro server, ciascuno nel suo thread: + web Flask (src/frontend/flask_app) servito da waitress, --host (default 127.0.0.1) :8780 + backend FastAPI src.backend.main:app, 127.0.0.1:8000 + vision FastAPI src.vision_worker.main:app, 127.0.0.1:8100 + camera scripts/camera_service.py (camere della stazione), 127.0.0.1:8765 +Poi apre il browser. Se un'altra copia e' gia' in esecuzione apre solo il browser. + +Come si usa: e' l'exe compilato con Nuitka (build.ps1); da sorgente, dalla radice del repo +con l'ambiente 3.13 del pacchetto: `python packaging/windows/avvio.py [--impronta] [--host H]`. +Opzioni di servizio: `--senza-finestre` (niente MessageBox, solo stderr: serve alle prove +automatiche; NON salta il controllo di licenza) e `--senza-browser`. + +Dipende da: visionsuite.license (vs-license, contratto licenza v1), uvicorn, waitress, alembic. + +⚠️ L'exe e' senza console: stdout/stderr possono essere None, quindi il log va su file +(\\log\\tiemeasureflow.log) e uvicorn gira con log_config=None. +⚠️ Le variabili d'ambiente vanno impostate PRIMA di importare backend e frontend (leggono +la configurazione all'import): per questo gli import pesanti stanno dentro le funzioni. +⚠️ La cartella ProgramData la rende scrivibile agli utenti l'installer (.iss). +""" +from __future__ import annotations + +import argparse +import ctypes +import json +import logging +import os +import secrets +import socket +import sqlite3 +import sys +import threading +import time +import urllib.request +import webbrowser +from pathlib import Path + +from visionsuite import license as lic # contratto licenza v1: impronta, cerca_licenza, verifica + +PRODOTTO = "tiemeasureflow" +TITOLO = "TieMeasureFlow" +PORTA_WEB = 8780 +PORTA_BACKEND = 8000 +PORTA_VISION = 8100 +PORTA_CAMERA = 8765 +log = logging.getLogger("tmf.avvio") + +# Nuitka definisce __compiled__ nel modulo compilato; da sorgente non esiste +COMPILATO = "__compiled__" in globals() +# Da sorgente (python packaging/windows/avvio.py) la radice e' due livelli sopra questo file +RADICE_SORGENTI = Path(__file__).resolve().parents[2] +# Segreti generati al primo avvio e riletti dopo (nome -> generatore) +SEGRETI = ("SERVER_SECRET_KEY", "CLIENT_SECRET_KEY", "SETUP_PASSWORD", "DB_PASSWORD") + + +def _stream_sicuri() -> None: + # Exe senza console: stdout/stderr sono None e print/logging/fileConfig di alembic + # andrebbero in errore; li si sostituisce con il device nullo + if sys.stdout is None: + sys.stdout = open(os.devnull, "w", encoding="utf-8") + if sys.stderr is None: + sys.stderr = open(os.devnull, "w", encoding="utf-8") + + +def cartella_tielogic() -> Path: + # %PROGRAMDATA% e' sempre impostata su Windows; il ripiego serve solo a sviluppo/test su altri sistemi + base = os.environ.get("PROGRAMDATA") or str(Path.home() / ".tielogic-dati") + return Path(base) / "Tielogic" + + +def cartella_dati() -> Path: + return cartella_tielogic() / "TieMeasureFlow" + + +def cartella_programma() -> Path: + # Cartella dell'exe (P); da sorgente, la radice del repo + if COMPILATO: + return Path(sys.argv[0]).resolve().parent + return RADICE_SORGENTI + + +def percorso_impronta() -> Path: + return cartella_tielogic() / "licenze" / f"impronta-{PRODOTTO}.txt" + + +def messaggio(testo: str, titolo: str = TITOLO, errore: bool = True, senza_finestre: bool = False) -> None: + # Sempre su stderr (se esiste); la MessageBox solo su Windows e se non soppressa + if sys.stderr is not None: + print(testo, file=sys.stderr) + if senza_finestre or sys.platform != "win32": + return + # MB_OK | MB_ICONERROR (0x10) oppure MB_ICONINFORMATION (0x40) | MB_TOPMOST (0x40000) + ctypes.windll.user32.MessageBoxW(None, testo, titolo, 0x40000 | (0x10 if errore else 0x40)) + + +# ---------------------------------------------------------------- licenza + +def _scrivi_impronta(impronta: str) -> None: + try: + p = percorso_impronta() + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(impronta + "\n", encoding="utf-8") + except OSError as e: + log.warning("impronta non scritta: %s", e) + + +def controlla_licenza(senza_finestre: bool = False) -> bool: + """True se la licenza e' valida; altrimenti mostra il motivo e l'impronta e torna False.""" + try: + percorso = lic.cerca_licenza(PRODOTTO) + lic.verifica(percorso, PRODOTTO) + return True + except lic.LicenzaNonValida as e: + motivo = str(e) + except Exception as e: # impronta non calcolabile, file illeggibile: mai un'eccezione muta nell'exe + motivo = f"Controllo licenza non riuscito: {e}" + try: + impronta = lic.impronta() + except Exception as e: + impronta = None + motivo += f"\nImpronta macchina non disponibile: {e}" + testo = f"{motivo}\n\n" + if impronta: + _scrivi_impronta(impronta) + testo += (f"Impronta di questo computer:\n{impronta}\n\n" + f"Comunicala a Tielogic per ottenere la licenza (salvata anche in {percorso_impronta()}). " + f"La licenza va copiata in {lic.cerca_licenza(PRODOTTO)}.") + messaggio(testo, f"{TITOLO} - licenza", senza_finestre=senza_finestre) + return False + + +# ---------------------------------------------------------------- configurazione + +def _leggi_env(percorso: Path) -> dict[str, str]: + valori: dict[str, str] = {} + for riga in percorso.read_text(encoding="utf-8").splitlines(): + riga = riga.strip() + if riga and not riga.startswith("#") and "=" in riga: + k, v = riga.split("=", 1) + valori[k.strip()] = v.strip() + return valori + + +def carica_segreti(dati: Path) -> dict[str, str]: + """Legge \\config.env; i segreti mancanti si generano e si riscrivono (una volta sola).""" + dati.mkdir(parents=True, exist_ok=True) + percorso = dati / "config.env" + valori = _leggi_env(percorso) if percorso.exists() else {} + nuovi = [k for k in SEGRETI if not valori.get(k)] + if nuovi: + for k in nuovi: + valori[k] = secrets.token_urlsafe(32) + righe = ["# Generato da TieMeasureFlow al primo avvio. Non condividere questo file.", + "# SETUP_PASSWORD serve per la pagina http://127.0.0.1:8000/api/setup (primo amministratore)."] + righe += [f"{k}={v}" for k, v in valori.items()] + percorso.write_text("\n".join(righe) + "\n", encoding="utf-8") + log.info("segreti generati in %s", percorso) + return valori + + +def trova_browser() -> str | None: + """Chrome o Edge per kaleido (grafici dei report PDF, offline).""" + pf = os.environ.get("ProgramFiles", r"C:\Program Files") + pf86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)") + local = os.environ.get("LOCALAPPDATA", "") + candidati = [ + Path(pf) / "Google/Chrome/Application/chrome.exe", + Path(pf86) / "Google/Chrome/Application/chrome.exe", + Path(local) / "Google/Chrome/Application/chrome.exe" if local else None, + Path(pf86) / "Microsoft/Edge/Application/msedge.exe", + Path(pf) / "Microsoft/Edge/Application/msedge.exe", + ] + for c in candidati: + if c is not None and c.is_file(): + return str(c) + return None + + +def imposta_ambiente(dati: Path, host: str, valori: dict[str, str]) -> None: + """Variabili d'ambiente del contratto v1; da chiamare PRIMA di importare backend e frontend.""" + uploads = dati / "uploads" + env = { + "APP_ENV": "production", + # barre "/" nel percorso: l'URL SQLAlchemy non vuole le barre rovesciate + "DB_URL": f"sqlite+aiosqlite:///{(dati / 'tmf.db').as_posix()}", + "UPLOAD_DIR": str(uploads), + "VISION_WORKER_URL": f"http://127.0.0.1:{PORTA_VISION}", + "VISION_RECIPES_ROOT": str(uploads), + "API_SERVER_URL": f"http://127.0.0.1:{PORTA_BACKEND}", + "SESSION_COOKIE_SECURE": "0", # niente HTTPS in locale: un cookie Secure verrebbe scartato + "SERVER_CORS_ORIGINS": f"http://127.0.0.1:{PORTA_WEB},http://localhost:{PORTA_WEB}", + **{k: valori[k] for k in SEGRETI}, + } + programma = cartella_programma() + versione = programma / "vision-engine-version.txt" # scritto da build.ps1 + if versione.is_file(): + env["VISION_ENGINE_VERSION"] = versione.read_text(encoding="utf-8").strip() + gtk = programma / "gtk" # DLL di WeasyPrint (piano B) + if gtk.is_dir(): + env["WEASYPRINT_DLL_DIRECTORIES"] = str(gtk) + browser = trova_browser() + if browser: + env["BROWSER_PATH"] = browser + os.environ.update(env) + for cartella in (uploads, dati / "camera", dati / "log"): + cartella.mkdir(parents=True, exist_ok=True) + + +def _prepara_percorsi_sorgenti() -> None: + # Da sorgente i moduli del frontend (config, services, blueprints, app) e lo script camera + # non sono pacchetti: si aggiungono al path. Compilati, Nuitka li ha gia' incorporati. + if COMPILATO: + return + for p in (RADICE_SORGENTI / "scripts", RADICE_SORGENTI / "src" / "frontend" / "flask_app", RADICE_SORGENTI): + if str(p) not in sys.path: + sys.path.insert(0, str(p)) + + +# ---------------------------------------------------------------- log + +def _prepara_log(dati: Path | None = None) -> None: + # Exe senza console: il log va su file; se la cartella non e' scrivibile si prosegue senza. + # Idempotente: alembic (fileConfig in env.py) azzera i gestori del root, quindi si richiama dopo. + try: + d = (dati or cartella_dati()) / "log" + d.mkdir(parents=True, exist_ok=True) + percorso = str(d / "tiemeasureflow.log") + root = logging.getLogger() + for h in root.handlers: + if isinstance(h, logging.FileHandler) and h.baseFilename.lower() == os.path.abspath(percorso).lower(): + break + else: + h = logging.FileHandler(percorso, encoding="utf-8") + h.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")) + root.addHandler(h) + root.setLevel(logging.INFO) + for nome in ("tmf.avvio",): + logging.getLogger(nome).disabled = False # fileConfig disattiva i logger esistenti + except OSError: + pass + + +# ---------------------------------------------------------------- rete + +def _porta_aperta(porta: int) -> bool: + try: + with socket.create_connection(("127.0.0.1", porta), timeout=0.5): + return True + except OSError: + return False + + +def _e_tmf() -> bool: + # Una copia gia' in esecuzione: il backend risponde su /api/health con il nome del servizio + try: + with urllib.request.urlopen(f"http://127.0.0.1:{PORTA_BACKEND}/api/health", timeout=2) as r: + return "TieMeasureFlow" in str(json.load(r).get("service", "")) + except Exception: + return False + + +def _nessun_utente(db: Path) -> bool: + """True se il database esiste ed e' senza utenti (primo avvio: serve la pagina di setup).""" + try: + con = sqlite3.connect(f"file:{db.as_posix()}?mode=ro", uri=True, timeout=2) + try: + return con.execute("SELECT COUNT(*) FROM users").fetchone()[0] == 0 + finally: + con.close() + except Exception: + return False + + +def _apri_browser_quando_pronto(url: str, porte: tuple[int, ...]) -> None: + # Aspetta che i server ascoltino (max ~60 s), poi apre il browser predefinito + for _ in range(300): + if all(_porta_aperta(p) for p in porte): + break + time.sleep(0.2) + webbrowser.open(url) + + +# ---------------------------------------------------------------- migrazioni + +def esegui_migrazioni() -> None: + """alembic upgrade head. Compilato: env.py e versions\\ sono soli .pyc, quindi sourceless.""" + from alembic import command + from alembic.config import Config + import src.backend as pacchetto + migrazioni = Path(pacchetto.__file__).resolve().parent / "migrations" + cfg = Config(str(migrazioni / "alembic.ini")) + if COMPILATO: + cfg.set_main_option("sourceless", "true") + command.upgrade(cfg, "head") + + +# ---------------------------------------------------------------- server + +class ServerThread(threading.Thread): + """Un server (uvicorn o waitress) in un thread; `errore` resta valorizzato se muore.""" + + def __init__(self, nome: str, costruisci): + super().__init__(name=nome, daemon=True) + self._costruisci = costruisci + self.server = None + self.errore: BaseException | None = None + + def run(self) -> None: + try: + self.server = self._costruisci() + self.server.run() + except BaseException as e: # un server morto deve comparire nel log e fermare l'insieme + self.errore = e + log.exception("server %s terminato con errore", self.name) + + def ferma(self) -> None: + s = self.server + try: + if hasattr(s, "should_exit"): + s.should_exit = True # uvicorn + elif s is not None: + s.close() # waitress + except Exception: + pass + + +def _uvicorn(app, porta: int): + import uvicorn + # log_config=None: niente console; i thread non toccano i signal handler (uvicorn lo gestisce) + return uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=porta, log_config=None, workers=1)) + + +def costruisci_web(host: str): + import waitress + from app import create_app # src/frontend/flask_app/app.py + return waitress.create_server(create_app(), host=host, port=PORTA_WEB, threads=8) + + +def costruisci_backend(): + from src.backend.main import app + return _uvicorn(app, PORTA_BACKEND) + + +def costruisci_vision(): + from src.vision_worker.main import app + return _uvicorn(app, PORTA_VISION) + + +def costruisci_camera(cartella: Path): + from camera_service import create_app # scripts/camera_service.py (non e' un pacchetto) + return _uvicorn(create_app(cartella), PORTA_CAMERA) + + +def avvia_server(host: str, dati: Path) -> list[ServerThread]: + thread = [ + ServerThread("backend", costruisci_backend), + ServerThread("vision", costruisci_vision), + ServerThread("camera", lambda: costruisci_camera(dati / "camera")), + ServerThread("web", lambda: costruisci_web(host)), + ] + for t in thread: + t.start() + return thread + + +def main(argv=None) -> int: + _stream_sicuri() + p = argparse.ArgumentParser(prog="TieMeasureFlow", description=TITOLO) + p.add_argument("--impronta", action="store_true", help="mostra l'impronta di questo computer") + p.add_argument("--host", default="127.0.0.1", help="indirizzo di ascolto dell'interfaccia web (default 127.0.0.1)") + p.add_argument("--senza-finestre", action="store_true", help="niente MessageBox (prove automatiche)") + p.add_argument("--senza-browser", action="store_true", help="non aprire il browser (prove automatiche)") + args = p.parse_args(argv) + _prepara_log() + + if args.impronta: + try: + impronta = lic.impronta() + except Exception as e: + messaggio(f"Impronta non disponibile: {e}", senza_finestre=args.senza_finestre) + return 1 + print(impronta) if sys.stdout is not None else None + messaggio(f"Impronta di questo computer:\n{impronta}", errore=False, senza_finestre=args.senza_finestre) + return 0 + + if not controlla_licenza(args.senza_finestre): + return 1 + + if _porta_aperta(PORTA_WEB): + if _e_tmf(): + # TieMeasureFlow e' gia' in esecuzione: basta riaprire la pagina + if not args.senza_browser: + webbrowser.open(f"http://127.0.0.1:{PORTA_WEB}/") + return 0 + messaggio(f"La porta {PORTA_WEB} e' occupata da un altro programma: chiuderlo e riprovare.", + senza_finestre=args.senza_finestre) + return 2 + occupate = [str(x) for x in (PORTA_BACKEND, PORTA_VISION, PORTA_CAMERA) if _porta_aperta(x)] + if occupate: + messaggio(f"Porte occupate da altri programmi: {', '.join(occupate)}. Chiuderli e riprovare.", + senza_finestre=args.senza_finestre) + return 2 + + dati = cartella_dati() + try: + valori = carica_segreti(dati) + imposta_ambiente(dati, args.host, valori) + _prepara_percorsi_sorgenti() + primo_avvio = not (dati / "tmf.db").exists() + esegui_migrazioni() + except Exception as e: + _prepara_log(dati) + log.exception("avvio non riuscito") + messaggio(f"Avvio non riuscito: {e}\nDettagli in {dati / 'log' / 'tiemeasureflow.log'}", + senza_finestre=args.senza_finestre) + return 3 + _prepara_log(dati) # alembic ha riconfigurato il logging + log.info("migrazioni applicate (primo avvio: %s)", primo_avvio) + + thread = avvia_server(args.host, dati) + if not args.senza_browser: + # database senza utenti: si apre la pagina di setup del backend (SETUP_PASSWORD in config.env) + url = (f"http://127.0.0.1:{PORTA_BACKEND}/api/setup" if _nessun_utente(dati / "tmf.db") + else f"http://127.0.0.1:{PORTA_WEB}/") + threading.Thread(target=_apri_browser_quando_pronto, + args=(url, (PORTA_WEB, PORTA_BACKEND)), daemon=True).start() + + # Si resta vivi finche' tutti i server girano; se uno muore si ferma tutto + try: + while all(t.is_alive() for t in thread): + time.sleep(1) + except KeyboardInterrupt: + pass + morti = [t for t in thread if not t.is_alive()] + for t in thread: + t.ferma() + if morti: + messaggio("Un componente si e' fermato: " + ", ".join(f"{t.name} ({t.errore})" for t in morti) + + f"\nDettagli in {dati / 'log' / 'tiemeasureflow.log'}", senza_finestre=args.senza_finestre) + return 4 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml index 79dc65e..c322a72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,6 +87,7 @@ vision-worker = [ windows = [ "aiosqlite>=0.20.0", # database SQLite su file (DB_URL, piano A) "waitress>=3.0.0", # server WSGI del frontend Flask (gunicorn non gira su Windows) + "vs-camera[genicam]; python_version>='3.13'", # scripts/camera_service.py: camere GigE/GenTL (producer .cti del vendor) "vs-license; python_version>='3.13'", # licenza Tielogic v1 (impronta macchina, firma Ed25519) "nuitka>=2.5", # compilazione standalone "ordered-set>=4.1", # richiesto da Nuitka per la modalita' standalone @@ -117,6 +118,7 @@ vs-measure = { path = "vendor/visionsuite/packages/vs-measure", editable = true vs-pm2d = { path = "vendor/visionsuite/packages/vs-pm2d", editable = true } vs-tools = { path = "vendor/visionsuite/packages/vs-tools", editable = true } # #46 vs-dxf = { path = "vendor/visionsuite/packages/vs-dxf", editable = true } # #46 +vs-camera = { path = "vendor/visionsuite/packages/vs-camera", editable = true } # #76 camera di stazione vs-license = { path = "vendor/visionsuite/packages/vs-license", editable = true } # #76 licenza del pacchetto Windows [tool.pytest.ini_options] diff --git a/uv.lock b/uv.lock index 1273cd0..df7b206 100644 --- a/uv.lock +++ b/uv.lock @@ -783,6 +783,33 @@ woff = [ { name = "zopfli" }, ] +[[package]] +name = "genicam" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/39/97c9a39fde61441731a383bde6a86b079a5a9d1d22c6d9eb16556b77016e/genicam-1.6.0-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:2a5bfeb3b9796cf6667192c2957f8ea7ec240ff42e76cc6e3e05d1d0230bf68a", size = 2556729, upload-time = "2026-09-01T08:26:57.558Z" }, + { url = "https://files.pythonhosted.org/packages/bd/1b/bf993221a155d2fd35c8201be97cd0376e005bf1cc82dec27105a712d3b1/genicam-1.6.0-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:4207d7ccee9deb755ad372c0b473fd2f23e4e42da61e06e4a7873d199b883a0f", size = 2604157, upload-time = "2026-09-01T08:26:58.847Z" }, + { url = "https://files.pythonhosted.org/packages/38/92/68c0033f076ad1defed2e6bf1cec8fc720a24622fb0389013b058e841234/genicam-1.6.0-cp311-cp311-manylinux2014_armv7l.whl", hash = "sha256:a750ffb11194f02740531bc0f62bf7013d39f2eea65f5e283c7811ab757fc987", size = 2190252, upload-time = "2026-09-01T08:27:00.126Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b3/252e5d64022984de4a78220c456f1f91742dbd7bb02d5b3926ed4c228015/genicam-1.6.0-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:589b1152f22ca766f8b5e1dff9c1b4006c2eb7f5ee33152619ef6ece8a5a3f47", size = 2800290, upload-time = "2026-09-01T08:27:01.364Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ec/19092034d0f66646d97b85e1549b6b4e2448f5287bb3a76b5a8cd677ba4a/genicam-1.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:ef1d25c7e9823689dbfb9e08d8fdb46e0f05982ca9c27d1eeacc1aa8fcd69f6e", size = 1802065, upload-time = "2026-09-01T08:27:02.555Z" }, + { url = "https://files.pythonhosted.org/packages/b7/84/0dc939a435deef267d82bc6aaa4b7ed9c89510766454640622608d0e7ce5/genicam-1.6.0-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:d0becabb469d0f51f9d4820d655d2788c620a8ee1c8abcfa4703cac87ba27f53", size = 2557117, upload-time = "2026-09-01T08:27:03.87Z" }, + { url = "https://files.pythonhosted.org/packages/07/68/499657bf3e5111fe1c87e3364c2ae6b59fe912d7184fccf591eafd762ceb/genicam-1.6.0-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:3a1a16b89fec69d52142781f280ce2c01a4c1422da1cd08893ed1cd630d11c30", size = 2607960, upload-time = "2026-09-01T08:27:05.149Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ac/3b8496c446715008e77dda7439735c428f163aab0a3f643352407a259a52/genicam-1.6.0-cp312-cp312-manylinux2014_armv7l.whl", hash = "sha256:b65beca4b425442922a2f2beefae1e3109ce166cfd726fcd9d2ac59ff1d57e24", size = 2189851, upload-time = "2026-09-01T08:27:06.516Z" }, + { url = "https://files.pythonhosted.org/packages/30/9c/49d68f5055e970e4e4d834bc3df686a01f7bd82e070888fc7ad2c86e2eaf/genicam-1.6.0-cp312-cp312-manylinux2014_x86_64.whl", hash = "sha256:83da90404da189a87273864f2c97c761c1807fc6fc532fabd3bf50c0ae28f8ae", size = 2799503, upload-time = "2026-09-01T08:27:07.963Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4a/e643cac4031ad7b1780ab19bcdeb6b0545e829b2ee10ea37ca1185d9417d/genicam-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d2bb3f39443c5bc248fcbde83a3a973370ca43588a7c1ded7d7324d7d9427d3", size = 1802200, upload-time = "2026-09-01T08:27:09.766Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f1/b5f641ad64c296462ab2ca314b8a471827142b949606ebe8b69bff7e6a08/genicam-1.6.0-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:f5c35275014f4793b34a21ebe8bbcb269a87b5d1bf129daf2818c6160f8758c9", size = 2557108, upload-time = "2026-09-01T08:27:11.211Z" }, + { url = "https://files.pythonhosted.org/packages/10/59/86ae5cbc1a89f70c2b3b906e692a46b977e9230664781cd5a304012f6b20/genicam-1.6.0-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:f4d6496660507b32a7f6ef05f5344bcd305ea3e2158a41bf53169663d7a72e70", size = 2608137, upload-time = "2026-09-01T08:27:12.405Z" }, + { url = "https://files.pythonhosted.org/packages/41/37/25a84938b5ec34fc6123006a3f4173fead4fde3b460b516749a8ae8c1b02/genicam-1.6.0-cp313-cp313-manylinux2014_armv7l.whl", hash = "sha256:efe646df5e8ca76208c6621e8a5dbea3e9909641801787993ea2d7ec4a704c09", size = 2189839, upload-time = "2026-09-01T08:27:13.834Z" }, + { url = "https://files.pythonhosted.org/packages/75/eb/37363beb1880674ff0976ff7607e2e045933c6388d08d98bc0b512df941c/genicam-1.6.0-cp313-cp313-manylinux2014_x86_64.whl", hash = "sha256:a342980e80735b7b1e4f18f6c33d64eccc3e95887dd72f302409f1296bef3dac", size = 2799503, upload-time = "2026-09-01T08:27:15.253Z" }, + { url = "https://files.pythonhosted.org/packages/ae/38/895e9effffc858ff06996a9eed28decdc9b5b2176b2cfd82df8c4f65bdec/genicam-1.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:740156c5fa8d82330d36314112f4b755e8333c16f82792174f1a2a2e150519ae", size = 1802243, upload-time = "2026-09-01T08:27:16.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/26/98a864061d25e78b96b7d621814b399a78fb7603ae6a6ac871648587bc49/genicam-1.6.0-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:064a87188b6102bac03a8ab1ccff5280e8fa4cf6378448e6286ed6f4cc9f7a64", size = 2557508, upload-time = "2026-09-01T08:27:17.763Z" }, + { url = "https://files.pythonhosted.org/packages/f1/32/2ef1193d27bb375e3d9822be883e987d75203fcb417bafa96fd77fe8be18/genicam-1.6.0-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:3dcf4af1fabb1cee06cac0bed6560f0e49180dab1e078501949eaf07596736c3", size = 2609251, upload-time = "2026-09-01T08:27:19.161Z" }, + { url = "https://files.pythonhosted.org/packages/45/06/ea8b6c2f4536cd8f54961040edae1ad9ff8a3dced2081a2377dac600bb73/genicam-1.6.0-cp314-cp314-manylinux2014_armv7l.whl", hash = "sha256:e3f9ff542bbd2fe389e1a94e4a694ec34f94ab378247c5f7e761b78ff5891d24", size = 2189734, upload-time = "2026-09-01T08:27:20.541Z" }, + { url = "https://files.pythonhosted.org/packages/38/ff/a8c90b9a7d99fa6008437491f1198264c22d49572a0013a05bc1c579e034/genicam-1.6.0-cp314-cp314-manylinux2014_x86_64.whl", hash = "sha256:d1e12e276eab6dbe8dec37e060ea5a8cc5758cc8c3502a048782e38a0451517d", size = 2799526, upload-time = "2026-09-01T08:27:21.77Z" }, + { url = "https://files.pythonhosted.org/packages/24/18/c6f0b7f3da480aac0c86d104664e9cf144675986cc4e3f108c909bec0ddf/genicam-1.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:7630b7844b273080f21f01881c56667cc68521186b75dd15798f5dee1f8f1037", size = 1846842, upload-time = "2026-09-01T08:27:23.18Z" }, +] + [[package]] name = "greenlet" version = "3.4.0" @@ -861,6 +888,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "harvesters" +version = "1.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "genicam" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/18/ee45d896fee51dee2e35df9d371e2d213ed4488ddea4dd4bd1df6f92a5fb/harvesters-1.4.3.tar.gz", hash = "sha256:c237dd402735a66ebd570865113a3e80ae19161a21706aed80691627a8cb7da2", size = 104391, upload-time = "2024-05-16T17:09:41.805Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/ab/295e5ec52cc2859ad8fa0d56f953cefa26f3e1eb28cc937162d7df7df872/harvesters-1.4.3-py3-none-any.whl", hash = "sha256:74994cedc941051b9ce9527c5b336e611589a1bc209bc03186c49102a55f1f1f", size = 100253, upload-time = "2024-05-16T17:09:27.933Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -2063,6 +2103,7 @@ windows = [ { name = "aiosqlite" }, { name = "nuitka" }, { name = "ordered-set" }, + { name = "vs-camera", extra = ["genicam"], marker = "python_full_version >= '3.13'" }, { name = "vs-license", marker = "python_full_version >= '3.13'" }, { name = "waitress" }, { name = "zstandard" }, @@ -2117,6 +2158,7 @@ windows = [ { name = "aiosqlite", specifier = ">=0.20.0" }, { name = "nuitka", specifier = ">=2.5" }, { name = "ordered-set", specifier = ">=4.1" }, + { name = "vs-camera", extras = ["genicam"], marker = "python_full_version >= '3.13'", editable = "vendor/visionsuite/packages/vs-camera" }, { name = "vs-license", marker = "python_full_version >= '3.13'", editable = "vendor/visionsuite/packages/vs-license" }, { name = "waitress", specifier = ">=3.0.0" }, { name = "zstandard", specifier = ">=0.22" }, @@ -2265,6 +2307,31 @@ requires-dist = [ ] provides-extras = ["dev", "fast", "dxf"] +[[package]] +name = "vs-camera" +version = "0.1.0" +source = { editable = "vendor/visionsuite/packages/vs-camera" } +dependencies = [ + { name = "numpy" }, + { name = "opencv-python" }, +] + +[package.optional-dependencies] +genicam = [ + { name = "harvesters" }, +] + +[package.metadata] +requires-dist = [ + { name = "flask", marker = "extra == 'dev'", specifier = ">=3.0" }, + { name = "harvesters", marker = "extra == 'genicam'", specifier = ">=1.4" }, + { name = "netifaces", marker = "extra == 'rete'", specifier = ">=0.11" }, + { name = "numpy", specifier = ">=1.24" }, + { name = "opencv-python", specifier = ">=4.8" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, +] +provides-extras = ["genicam", "rete", "dev"] + [[package]] name = "vs-dxf" version = "0.1.0" From 69cbf53060609332492dfe8dac0e9fe94fcd239e Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Sat, 17 Oct 2026 09:14:01 +0200 Subject: [PATCH 3/9] Pacchetto Windows: build.ps1 (Nuitka standalone + migrazioni .pyc + Inno Setup), installer .iss, icona (TieMeasureFlow#76) Co-Authored-By: Claude Sonnet 5.5 --- packaging/windows/TieMeasureFlow.ico | Bin 0 -> 31848 bytes packaging/windows/TieMeasureFlow.iss | 81 +++++++++++ packaging/windows/build.ps1 | 205 +++++++++++++++++++++++++++ 3 files changed, 286 insertions(+) create mode 100644 packaging/windows/TieMeasureFlow.ico create mode 100644 packaging/windows/TieMeasureFlow.iss create mode 100644 packaging/windows/build.ps1 diff --git a/packaging/windows/TieMeasureFlow.ico b/packaging/windows/TieMeasureFlow.ico new file mode 100644 index 0000000000000000000000000000000000000000..6079069f211577783baf469fc74863d1bc9fc756 GIT binary patch literal 31848 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 literal 0 HcmV?d00001 diff --git a/packaging/windows/TieMeasureFlow.iss b/packaging/windows/TieMeasureFlow.iss new file mode 100644 index 0000000..9d35c11 --- /dev/null +++ b/packaging/windows/TieMeasureFlow.iss @@ -0,0 +1,81 @@ +; Installer di TieMeasureFlow (TieMeasureFlow#76). +; Cosa fa: copia la cartella standalone di Nuitka in Program Files\Tielogic\TieMeasureFlow, +; crea in ProgramData le cartelle scrivibili da tutti gli utenti (licenze, uploads, camera, log) +; e i collegamenti in menu Start e sul desktop. +; Come si usa: lo lancia build.ps1 con /DVersione /DVersione4 /DOrigine /DUscita; a mano: +; ISCC /DVersione=3.0.0 /DVersione4=3.0.0.0 /DOrigine=build\nuitka\avvio.dist /DUscita=dist TieMeasureFlow.iss +; Setup silenzioso: TieMeasureFlow-setup-X.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART +; (/CURRENTUSER per installare senza amministratore, in %LOCALAPPDATA%\Programs). +; ATTENZIONE: la licenza NON e' nel setup: va copiata in {commonappdata}\Tielogic\licenze\tiemeasureflow.json +; (la cartella e' scrivibile dagli utenti). La disinstallazione lascia licenze, database (tmf.db), +; uploads e config.env: sono dati del cliente. + +#ifndef Versione + #define Versione "0.0.0" +#endif +#ifndef Versione4 + #define Versione4 "0.0.0.0" +#endif +#ifndef Origine + #define Origine "..\..\build\nuitka\avvio.dist" +#endif +#ifndef Uscita + #define Uscita "..\..\dist" +#endif + +[Setup] +; AppId proprio di TieMeasureFlow (diverso da quello di TieVision Studio) +AppId={{B7D4E2A1-5C93-4F6E-8A0B-3D1F7E9C2A64} +AppName=TieMeasureFlow +AppVersion={#Versione} +AppVerName=TieMeasureFlow {#Versione} +AppPublisher=Tielogic +DefaultDirName={autopf}\Tielogic\TieMeasureFlow +DefaultGroupName=Tielogic\TieMeasureFlow +PrivilegesRequired=admin +; /CURRENTUSER solo da riga di comando: serve alle prove senza amministratore +PrivilegesRequiredOverridesAllowed=commandline +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +OutputDir={#Uscita} +OutputBaseFilename=TieMeasureFlow-setup-{#Versione} +SetupIconFile=TieMeasureFlow.ico +UninstallDisplayIcon={app}\TieMeasureFlow.exe +VersionInfoVersion={#Versione4} +VersionInfoCompany=Tielogic +VersionInfoProductName=TieMeasureFlow +Compression=lzma2/max +SolidCompression=yes +WizardStyle=modern +DisableProgramGroupPage=yes +; il programma in esecuzione tiene aperti i file: Inno chiede di chiuderlo (in silenzioso lo chiude) +CloseApplications=yes + +[Languages] +Name: "italiano"; MessagesFile: "compiler:Languages\Italian.isl" + +[Tasks] +Name: "desktopicon"; Description: "Crea un collegamento sul desktop"; GroupDescription: "Collegamenti:" + +[Dirs] +; scrivibili da tutti gli utenti: l'exe gira con l'utente che lo lancia, non con admin +Name: "{commonappdata}\Tielogic\licenze"; Permissions: users-modify +Name: "{commonappdata}\Tielogic\TieMeasureFlow"; Permissions: users-modify +Name: "{commonappdata}\Tielogic\TieMeasureFlow\uploads"; Permissions: users-modify +Name: "{commonappdata}\Tielogic\TieMeasureFlow\camera"; Permissions: users-modify +Name: "{commonappdata}\Tielogic\TieMeasureFlow\log"; Permissions: users-modify + +[Files] +Source: "{#Origine}\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs ignoreversion + +[Icons] +Name: "{group}\TieMeasureFlow"; Filename: "{app}\TieMeasureFlow.exe" +Name: "{group}\Impronta di questo computer"; Filename: "{app}\TieMeasureFlow.exe"; Parameters: "--impronta" +; l'exe non ha finestra: per fermarlo si chiude il processo +Name: "{group}\Arresta TieMeasureFlow"; Filename: "{sys}\taskkill.exe"; Parameters: "/IM TieMeasureFlow.exe /F"; Flags: runminimized +Name: "{group}\Cartella dati e configurazione"; Filename: "{commonappdata}\Tielogic\TieMeasureFlow" +Name: "{group}\Disinstalla TieMeasureFlow"; Filename: "{uninstallexe}" +Name: "{autodesktop}\TieMeasureFlow"; Filename: "{app}\TieMeasureFlow.exe"; Tasks: desktopicon + +[Run] +Filename: "{app}\TieMeasureFlow.exe"; Description: "Avvia TieMeasureFlow"; Flags: nowait postinstall skipifsilent diff --git a/packaging/windows/build.ps1 b/packaging/windows/build.ps1 new file mode 100644 index 0000000..14b3382 --- /dev/null +++ b/packaging/windows/build.ps1 @@ -0,0 +1,205 @@ +<# +Pacchetto Windows di TieMeasureFlow (TieMeasureFlow#76). + +Cosa fa: uv sync in un ambiente Python 3.13 separato (build\venv313), frontend (Tailwind + +traduzioni), compila avvio.py con Nuitka in modalita' --standalone (cartella, NON onefile: +l'onefile si decomprime in %TEMP% ad ogni avvio), aggiunge le migrazioni Alembic come soli +.pyc, le DLL di WeasyPrint (piano B) e il commit del motore di visione, poi Inno Setup +produce dist\TieMeasureFlow-setup-.exe. Sul PC del cliente non finisce nessun +.py; i .pyc ci sono solo in src\backend\migrations (Alembic le carica dal disco). + +Come si usa (dalla radice del repo, con il submodule vendor/visionsuite inizializzato): + powershell -ExecutionPolicy Bypass -File packaging\windows\build.ps1 [-Versione 3.0.0] +-Versione accetta anche un tag (v3.0.0, v3.0.0-rc1): si usano i numeri iniziali. +Senza -Versione si usa la variabile GITHUB_REF_NAME (tag nella CI) o 0.0.0. +-SenzaSetup si ferma dopo Nuitka (cartella build\nuitka\avvio.dist). + +Dipende da: uv, node/npx (Tailwind 3.4.19 da npm: serve Internet SOLO a chi costruisce), +Visual Studio con carico C++ (MSVC; Nuitka lo trova da solo), Inno Setup 6 +(`winget install --id JRSoftware.InnoSetup -e`; ISCC.exe nel PATH, in Program Files o in +%LOCALAPPDATA%\Programs). Nuitka e' nel gruppo uv "windows" (pyproject.toml). + +⚠️ La prima compilazione e' lunga (decine di minuti: numpy/opencv/scipy/plotly); le successive +riusano la cache di Nuitka (%LOCALAPPDATA%\Nuitka) e la cartella build\nuitka. +⚠️ I pacchetti di Core condividono il namespace visionsuite (extend_path): Nuitka non lo +segue da solo, quindi i sottopacchetti si elencano uno per uno dagli __path__ installati. +⚠️ numba e' escluso di proposito: le funzioni compilate da Nuitka non hanno bytecode e numba +non puo' farne il JIT. Core e pm2d hanno gia' il ripiego in Python puro (HAS_NUMBA=False): +funziona ma e' piu' lento nel matching. +⚠️ Il frontend Flask non e' un pacchetto (`from config import Config`, `blueprints`, `services`): +si compila mettendo flask_app e scripts su PYTHONPATH, come fa avvio.py in sviluppo. +#> +param( + [string]$Versione = $env:GITHUB_REF_NAME, + [switch]$SenzaSetup +) +$ErrorActionPreference = 'Stop' +$radice = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +Set-Location $radice + +# Versione numerica a 4 parti per i metadati dell'exe (Nuitka la vuole cosi') +if (-not $Versione) { $Versione = '0.0.0' } +if ($Versione -notmatch '^v?(\d+)\.(\d+)\.(\d+)') { throw "Versione non valida: '$Versione' (atteso es. v3.0.0)" } +$ver3 = "$($Matches[1]).$($Matches[2]).$($Matches[3])" +$ver4 = "$ver3.0" +Write-Host "== TieMeasureFlow $ver3 ==" + +# Controlli: se manca qualcosa il messaggio dice come installarlo +if (-not (Get-Command uv -ErrorAction SilentlyContinue)) { + throw "uv non trovato. Installarlo: https://docs.astral.sh/uv/getting-started/installation/" +} +if (-not (Get-Command npx -ErrorAction SilentlyContinue)) { + throw "node/npx non trovato (serve a compilare Tailwind). Installarlo da https://nodejs.org" +} +if (-not (Test-Path (Join-Path $radice 'vendor\visionsuite\packages\vs-license'))) { + throw "Submodule vendor/visionsuite mancante o vecchio: git submodule update --init --recursive" +} +$iscc = (Get-Command ISCC.exe -ErrorAction SilentlyContinue).Source +if (-not $iscc) { + foreach ($c in @("${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", "$env:ProgramFiles\Inno Setup 6\ISCC.exe", + "$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe")) { + if (Test-Path $c) { $iscc = $c; break } + } +} +if (-not $iscc -and -not $SenzaSetup) { throw "Inno Setup 6 non trovato. Installarlo: winget install --id JRSoftware.InnoSetup -e" } + +# Ambiente separato da quello di sviluppo (3.11): extra server + client + vision-worker + gruppo windows +$env:UV_PROJECT_ENVIRONMENT = Join-Path $radice 'build\venv313' +uv sync --python 3.13 --frozen --extra server --extra client --extra vision-worker --group windows +if ($LASTEXITCODE -ne 0) { throw "uv sync fallito" } +$py = Join-Path $env:UV_PROJECT_ENVIRONMENT 'Scripts\python.exe' +$vn = & $py -m nuitka --version # niente Select-Object -First: chiude la pipe e falsa l'exit code +if ($LASTEXITCODE -ne 0) { throw "Nuitka non disponibile (serve anche un compilatore C: Visual Studio con 'Sviluppo di applicazioni desktop con C++')" } + +# Frontend: tailwind.css non e' nel repo (stessa versione esatta di Dockerfile.frontend) e traduzioni .mo +$flask = Join-Path $radice 'src\frontend\flask_app' +Push-Location $flask +try { + npx --yes tailwindcss@3.4.19 -i static/css/input.css -o static/css/tailwind.css --minify + if ($LASTEXITCODE -ne 0) { throw "Tailwind fallito" } + & (Join-Path $env:UV_PROJECT_ENVIRONMENT 'Scripts\pybabel.exe') compile -d translations + if ($LASTEXITCODE -ne 0) { throw "pybabel compile fallito" } +} finally { Pop-Location } + +# Sottopacchetti del namespace visionsuite (core, camera, task, tools, license, ...) +$pacchetti = & $py -c "import visionsuite, pkgutil; print('\n'.join('visionsuite.' + m.name for m in pkgutil.iter_modules(visionsuite.__path__) if m.ispkg))" +if ($LASTEXITCODE -ne 0 -or -not $pacchetti) { throw "Elenco dei pacchetti visionsuite non riuscito" } +$includi = @($pacchetti -split "\r?\n" | Where-Object { $_ } | ForEach-Object { "--include-package=$_" }) +# Metadati delle distribuzioni di Core (importlib.metadata): visionsuite, vs-* +$distribuzioni = & $py -c "import importlib.metadata as m; print('\n'.join(sorted({d.metadata['Name'] for d in m.distributions() if d.metadata['Name'].lower() in ('visionsuite',) or d.metadata['Name'].lower().startswith('vs-')})))" +if ($LASTEXITCODE -ne 0) { throw "Elenco delle distribuzioni di Core non riuscito" } +$includi += @($distribuzioni -split "\r?\n" | Where-Object { $_ } | ForEach-Object { "--include-distribution-metadata=$_" }) +Write-Host "Pacchetti visionsuite: $($includi -join ' ')" + +# Flag Nuitka del piano PDF (WeasyPrint, kaleido, pyphen...): righe "--include-..." di pdf\LEGGIMI.md +$flagPdf = @() +$leggimi = Join-Path $PSScriptRoot 'pdf\LEGGIMI.md' +if (Test-Path $leggimi) { + $flagPdf = @(Get-Content $leggimi | ForEach-Object { + if ($_ -match '^\s*(?:[-*]\s*)?`?(--(?:include|nofollow|noinclude|enable|plugin)[^\s`]*)`?\s*$') { $Matches[1] } + }) + Write-Host "Flag Nuitka da pdf\LEGGIMI.md: $($flagPdf -join ' ')" +} + +# Nuitka cerca i moduli non-pacchetto (config, services, blueprints, app, camera_service) su PYTHONPATH +$env:PYTHONPATH = (@($radice, $flask, (Join-Path $radice 'scripts')) -join ';') + +$out = Join-Path $radice 'build\nuitka' +New-Item -ItemType Directory -Force $out | Out-Null +$nuitka = @( + '--standalone', + '--assume-yes-for-downloads', + '--msvc=latest', + '--windows-console-mode=disable', # nessuna finestra nera: gli errori escono in MessageBox e nel log + "--output-dir=$out", + '--output-filename=TieMeasureFlow.exe', + '--windows-icon-from-ico=packaging\windows\TieMeasureFlow.ico', + '--company-name=Tielogic', + '--product-name=TieMeasureFlow', + "--file-version=$ver4", + "--product-version=$ver4", + '--file-description=TieMeasureFlow', + '--copyright=Tielogic', + # codice del prodotto: backend, worker di visione, runner (src e' un pacchetto, flask_app no) + '--include-package=src.backend', + '--include-package=src.vision', + '--include-package=src.vision_worker', + # le migrazioni NON si compilano: Alembic le carica dal disco, vanno come .pyc (sotto) + '--nofollow-import-to=src.backend.migrations', + '--nofollow-import-to=src.backend.tests', + '--nofollow-import-to=src.vision.tests', + '--nofollow-import-to=src.vision_worker.tests', + # frontend Flask e servizio camera: moduli di primo livello trovati via PYTHONPATH + '--include-module=app', + '--include-module=config', + '--include-package=blueprints', + '--include-package=services', + '--include-module=camera_service', + '--nofollow-import-to=blueprints.tests', + '--nofollow-import-to=tests', + '--include-package=uvicorn', # loop e protocolli di uvicorn si caricano per nome + '--include-package=waitress', + '--include-package=alembic', + '--include-module=aiosqlite', + '--include-module=sqlalchemy.dialects.sqlite.aiosqlite', + '--include-package=harvesters', # camere GenTL (GigE): il producer .cti e' del vendor + '--include-package=genicam', + '--include-package-data=genicam', + '--include-package-data=babel', # locale-data di Babel (flask-babel) + '--nofollow-import-to=numba', # vedi nota in testa: il ripiego in Python puro + '--nofollow-import-to=llvmlite', + # dati letti dal disco con __file__: setup page, report, Flask (templates/static/translations alla radice) + '--include-data-dir=src\backend\templates=src\backend\templates', + '--include-data-files=src\backend\migrations\alembic.ini=src\backend\migrations\alembic.ini', + '--include-data-dir=src\frontend\flask_app\templates=templates', + '--include-data-dir=src\frontend\flask_app\static=static', + '--include-data-dir=src\frontend\flask_app\translations=translations', + '--include-data-files=pyproject.toml=pyproject.toml' # config.py ne legge la versione per il piede di pagina +) + $includi + $flagPdf + @('packaging\windows\avvio.py') + +$t0 = Get-Date +& $py -m nuitka @nuitka +if ($LASTEXITCODE -ne 0) { throw "Nuitka fallito (codice $LASTEXITCODE)" } +Write-Host ("Nuitka: {0:N0} minuti" -f ((Get-Date) - $t0).TotalMinutes) + +$sorgente = Join-Path $out 'avvio.dist' +if (-not (Test-Path (Join-Path $sorgente 'TieMeasureFlow.exe'))) { throw "exe non trovato in $sorgente" } + +# Migrazioni Alembic: env.py e versions\ come soli .pyc (compileall -b: .pyc accanto al sorgente, che si cancella) +$mig = Join-Path $sorgente 'src\backend\migrations' +New-Item -ItemType Directory -Force (Join-Path $mig 'versions') | Out-Null +Copy-Item 'src\backend\migrations\env.py' $mig -Force +Copy-Item 'src\backend\migrations\versions\*.py' (Join-Path $mig 'versions') -Force +& $py -m compileall -b -q $mig +if ($LASTEXITCODE -ne 0) { throw "compileall delle migrazioni fallito" } +Get-ChildItem $mig -Recurse -Filter *.py -File | Remove-Item -Force +Get-ChildItem $mig -Recurse -Directory -Filter __pycache__ | Remove-Item -Recurse -Force + +# Commit del motore di visione: avvio.py lo legge in VISION_ENGINE_VERSION +$motore = (git -C vendor/visionsuite rev-parse HEAD).Trim() +if ($LASTEXITCODE -ne 0 -or -not $motore) { throw "Commit di vendor/visionsuite non leggibile" } +[System.IO.File]::WriteAllText((Join-Path $sorgente 'vision-engine-version.txt'), $motore, (New-Object System.Text.UTF8Encoding $false)) + +# DLL di WeasyPrint (piano PDF): lo script le scarica con sha256 pinnato +$gtk = Join-Path $PSScriptRoot 'pdf\prendi-gtk.ps1' +if (Test-Path $gtk) { + powershell -NoProfile -ExecutionPolicy Bypass -File $gtk -Destinazione (Join-Path $sorgente 'gtk') + if ($LASTEXITCODE -ne 0) { throw "prendi-gtk.ps1 fallito" } +} else { + Write-Warning "packaging\windows\pdf\prendi-gtk.ps1 assente: il pacchetto non avra' le DLL di WeasyPrint (niente report PDF)" +} + +# Il pacchetto non deve contenere codice sorgente o bytecode, tranne i .pyc delle migrazioni +$sporchi = Get-ChildItem $sorgente -Recurse -Include *.py, *.pyc -File | + Where-Object { $_.FullName -notlike "$mig\*" -or $_.Extension -eq '.py' } +if ($sporchi) { throw "Il pacchetto contiene sorgenti/bytecode:`n$($sporchi.FullName -join "`n")" } + +if ($SenzaSetup) { Write-Host "Fermo qui (-SenzaSetup): $sorgente"; return } + +$dist = Join-Path $radice 'dist' +New-Item -ItemType Directory -Force $dist | Out-Null +& $iscc "/DVersione=$ver3" "/DVersione4=$ver4" "/DOrigine=$sorgente" "/DUscita=$dist" (Join-Path $radice 'packaging\windows\TieMeasureFlow.iss') +if ($LASTEXITCODE -ne 0) { throw "Inno Setup fallito" } + +$setup = Join-Path $dist "TieMeasureFlow-setup-$ver3.exe" +Write-Host ("Pronto: {0} ({1:N1} MB)" -f $setup, ((Get-Item $setup).Length / 1MB)) From 959e92552b0c9212bd8ee46fd43cf67564d8e5f6 Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Sat, 17 Oct 2026 09:17:18 +0200 Subject: [PATCH 4/9] Pacchetto Windows: test dell'avvio, workflow rilascio-windows, sezione README (TieMeasureFlow#76) Co-Authored-By: Claude Sonnet 5.5 --- .gitea/workflows/rilascio-windows.yml | 120 +++++++++++++++++ README.md | 48 +++++++ packaging/windows/avvio.py | 2 +- packaging/windows/tests/test_avvio.py | 186 ++++++++++++++++++++++++++ 4 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/rilascio-windows.yml create mode 100644 packaging/windows/tests/test_avvio.py diff --git a/.gitea/workflows/rilascio-windows.yml b/.gitea/workflows/rilascio-windows.yml new file mode 100644 index 0000000..685a2b1 --- /dev/null +++ b/.gitea/workflows/rilascio-windows.yml @@ -0,0 +1,120 @@ +# TieMeasureFlow#76: setup.exe di TieMeasureFlow per Windows, generato al tag v*. +# Cosa fa: submodule vendor/visionsuite (deploy key), build.ps1 (uv sync Python 3.13, Tailwind, +# Nuitka --standalone, migrazioni .pyc, DLL di WeasyPrint, Inno Setup), poi il setup come +# artifact e come asset della release Gitea del tag. +# Da cosa dipende: un runner Gitea con label `windows` che giri su un host Windows (non in +# container) con Visual Studio/Build Tools (MSVC), git e node (serve a actions/checkout e a +# Tailwind); uv e Inno Setup li installa il workflow se mancano. Secret SUBMODULI_SSH_KEY +# (deploy key di sola lettura di TieVision-Core, impostato sul repo il 17/10/2026). +# Il runner `windows` e' act_runner in modalita' host su daitarn (capacity 1). +# ⚠️ upload-artifact@v3: e' la versione che usa gia' vs-native.yml in TieVision-Core su questo +# Gitea; la v4 usa un protocollo che act_runner puo' non supportare. +# ⚠️ L'asset della release usa il token del job (secrets.GITHUB_TOKEN): se la chiamata risponde +# 403 serve un token personale in un secret (es. RILASCI_TOKEN) al posto di GITHUB_TOKEN. +# ⚠️ La build da freddo e' lunga (Nuitka compila backend, visione, plotly, opencv...): timeout 180 min. +name: rilascio-windows + +on: + push: + tags: ['v*'] + workflow_dispatch: {} + +jobs: + setup-windows: + runs-on: windows + timeout-minutes: 180 + defaults: + run: + shell: powershell + steps: + # Checkout senza ssh-key (come ci.yml): i submodule si inizializzano a parte, con la deploy key. + - uses: actions/checkout@v4 + + - name: Submodule privati (deploy key in sola lettura) + env: + SUBMODULI_SSH_KEY: ${{ secrets.SUBMODULI_SSH_KEY }} + run: | + $ErrorActionPreference = 'Stop' + # cartella temporanea del job, non ~/.ssh: su un runner host e' l'account dell'utente + $ssh = Join-Path $env:TEMP ("submoduli-" + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Force $ssh | Out-Null + $chiave = Join-Path $ssh 'chiave' + # LF e newline finale: OpenSSH rifiuta una chiave con CRLF + [System.IO.File]::WriteAllText($chiave, ($env:SUBMODULI_SSH_KEY.Trim() + "`n"), (New-Object System.Text.UTF8Encoding $false)) + # OpenSSH su Windows rifiuta una chiave leggibile da altri utenti; (F) e non (R): + # con la sola lettura Remove-Item finale non puo' cancellarla + icacls $chiave /inheritance:r | Out-Null + icacls $chiave /grant:r "$($env:USERNAME):(F)" | Out-Null + $known = Join-Path $ssh 'known_hosts' + [System.IO.File]::WriteAllText($known, "[git.tielogic.xyz]:222 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDvABW6Tvruqkf7KJhJZUiAX6NeCx0enwcNXzZbECQ8J`n", (New-Object System.Text.UTF8Encoding $false)) + $env:GIT_SSH_COMMAND = "ssh -i $($chiave -replace '\\','/') -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$($known -replace '\\','/')" + try { + git submodule update --init --recursive + if ($LASTEXITCODE -ne 0) { throw "git submodule update fallito" } + } finally { + # la deploy key non resta mai sul disco del runner + Remove-Item $ssh -Recurse -Force -ErrorAction SilentlyContinue + } + + - name: uv (se manca) + run: | + if (-not (Get-Command uv -ErrorAction SilentlyContinue)) { + irm https://astral.sh/uv/install.ps1 | iex + } + # il PATH del passo successivo + "$env:USERPROFILE\.local\bin" | Out-File -Append -Encoding utf8 $env:GITHUB_PATH + + - name: Node (se manca) + run: | + # serve a Tailwind (npx) in build.ps1 + if (-not (Get-Command npx -ErrorAction SilentlyContinue)) { + winget install --id OpenJS.NodeJS.LTS -e --silent --accept-package-agreements --accept-source-agreements + } + "$env:ProgramFiles +odejs" | Out-File -Append -Encoding utf8 $env:GITHUB_PATH + + - name: Inno Setup (se manca) + run: | + $trovato = (Get-Command ISCC.exe -ErrorAction SilentlyContinue) -or + (Test-Path "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe") -or + (Test-Path "$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe") + if (-not $trovato) { + winget install --id JRSoftware.InnoSetup -e --silent --accept-package-agreements --accept-source-agreements + } + + - name: Build (Nuitka + Inno Setup) + # GITHUB_REF_NAME e' il tag (v0.1.0); con workflow_dispatch e' il ramo: build.ps1 rifiuta + # un nome senza numeri, quindi in quel caso si passa 0.0.0 + run: | + $v = '${{ github.ref_name }}' + if ($v -notmatch '^v?\d+\.\d+\.\d+') { $v = '0.0.0' } + powershell -ExecutionPolicy Bypass -File packaging\windows\build.ps1 -Versione $v + if ($LASTEXITCODE -ne 0) { throw "build.ps1 fallito" } + + - name: Setup come artifact + uses: actions/upload-artifact@v3 + with: + name: TieMeasureFlow-setup + path: dist/TieMeasureFlow-setup-*.exe + if-no-files-found: error + + - name: Asset della release Gitea (solo ai tag) + if: startsWith(github.ref, 'refs/tags/') + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} + TAG: ${{ github.ref_name }} + run: | + $ErrorActionPreference = 'Stop' + $h = @{ Authorization = "token $env:TOKEN" } + # la release del tag: la crea il primo job che arriva, poi si riusa + try { + $rel = Invoke-RestMethod -Headers $h "$env:API/releases/tags/$env:TAG" + } catch { + $corpo = @{ tag_name = $env:TAG; name = "TieMeasureFlow $env:TAG" } | ConvertTo-Json + $rel = Invoke-RestMethod -Method Post -Headers $h -ContentType 'application/json' -Body $corpo "$env:API/releases" + } + $setup = Get-ChildItem dist\TieMeasureFlow-setup-*.exe | Select-Object -First 1 + # curl.exe (incluso in Windows 10+) fa il multipart senza codice a mano + curl.exe --fail -sS -X POST -H "Authorization: token $env:TOKEN" -F "attachment=@$($setup.FullName)" "$env:API/releases/$($rel.id)/assets?name=$($setup.Name)" + if ($LASTEXITCODE -ne 0) { throw "caricamento dell'asset fallito" } diff --git a/README.md b/README.md index 3d54059..b9b0810 100644 --- a/README.md +++ b/README.md @@ -532,6 +532,54 @@ Nella schermata Stazioni si compilano `camera_url` (per esempio `http://host.doc --- +## Pacchetto Windows + +Per i PC senza Docker (per esempio il PC della stazione in fiera) TieMeasureFlow si distribuisce come programma di installazione per Windows, `TieMeasureFlow-setup-.exe`. Funziona completamente offline, non richiede Docker né MySQL, e non contiene codice sorgente Python: il codice del prodotto è compilato con Nuitka. Il programma parte solo con una licenza valida, legata al computer. + +### Installazione + +1. Eseguire `TieMeasureFlow-setup-.exe` (serve un account amministratore; per una prova senza amministratore si può lanciare il setup con `/CURRENTUSER`). Per un'installazione silenziosa: `/VERYSILENT /SUPPRESSMSGBOXES /NORESTART`. +2. Dal menu Start aprire «Impronta di questo computer» e comunicare l'impronta a Tielogic. L'impronta viene salvata anche in `C:\ProgramData\Tielogic\licenze\impronta-tiemeasureflow.txt`. +3. Copiare il file di licenza ricevuto in `C:\ProgramData\Tielogic\licenze\tiemeasureflow.json`. La licenza non è inclusa nel setup e non va mai inserita nel repository. +4. Avviare «TieMeasureFlow» dal menu Start o dal desktop. Senza una licenza valida (mancante, scaduta, di un altro computer o di un altro prodotto) il programma mostra il motivo e l'impronta ed esce. + +### Primo avvio + +Il programma gira in un solo processo, senza finestra, e apre il browser. Ascolta solo sul computer locale (`127.0.0.1`); per raggiungere l'interfaccia da altri dispositivi della rete si avvia l'exe con `--host 0.0.0.0` (per esempio da un collegamento personalizzato). + +| Componente | Indirizzo | +|---|---| +| Interfaccia web (Flask) | http://127.0.0.1:8780/ | +| Backend API (FastAPI) | http://127.0.0.1:8000/ | +| Worker di visione | http://127.0.0.1:8100/ | +| Camera della stazione | http://127.0.0.1:8765/ (`/camera/sources`) | + +Al primo avvio vengono create la cartella dati `C:\ProgramData\Tielogic\TieMeasureFlow` e il file `config.env` con le chiavi segrete generate (`SERVER_SECRET_KEY`, `CLIENT_SECRET_KEY`, `SETUP_PASSWORD`, `DB_PASSWORD`); al riavvio vengono rilette, quindi le sessioni e le password restano valide. Il database è un file SQLite (`tmf.db`); le migrazioni si applicano da sole a ogni avvio. + +Se il database non contiene ancora utenti, il browser si apre direttamente sulla pagina di setup `http://127.0.0.1:8000/api/setup`. La pagina chiede la `SETUP_PASSWORD`, che si legge in `config.env`, e permette di inizializzare il database e creare il primo amministratore (vedi «Setup Iniziale»). Terminato il setup si accede all'interfaccia su `http://127.0.0.1:8780/`. + +Per le camere della stazione si compila nella schermata Stazioni `camera_url` = `http://127.0.0.1:8765` e `camera_source` (`camera:` oppure `camera:folder`). Con `camera:folder` il servizio restituisce a rotazione le immagini che si copiano in `C:\ProgramData\Tielogic\TieMeasureFlow\camera`. Per le camere GigE/GenTL il driver (producer `.cti`) è quello installato dal produttore della camera. + +Altre informazioni utili: + +- I log sono in `C:\ProgramData\Tielogic\TieMeasureFlow\log\tiemeasureflow.log`. +- Una seconda esecuzione mentre il programma è già attivo apre soltanto il browser. +- Per fermare il programma si usa «Arresta TieMeasureFlow» nel menu Start (chiude il processo). +- La disinstallazione non cancella licenze, database, file caricati e `config.env`: sono dati del cliente. +- I report PDF usano le librerie grafiche incluse nel pacchetto e un browser Chrome o Edge già installato sul computer per i grafici. + +### Come si costruisce + +Il setup lo produce la CI Gitea (`.gitea/workflows/rilascio-windows.yml`) quando si crea un tag `v*`, su un runner con etichetta `windows`; il file compare come allegato della release. In locale, dalla radice del repository con il sottomodulo `vendor/visionsuite` inizializzato: + +```powershell +powershell -ExecutionPolicy Bypass -File packaging\windows\build.ps1 -Versione 3.0.0 +``` + +Servono `uv`, Node.js, Visual Studio con il carico C++ e Inno Setup 6. Il risultato è `dist\TieMeasureFlow-setup-3.0.0.exe`. La prima compilazione richiede decine di minuti. I test dell'avvio si lanciano con l'ambiente Python 3.13 del pacchetto: `uv run --python 3.13 --extra server --extra client --extra vision-worker --group windows --with pytest pytest packaging/windows/tests -q`. + +--- + ## Documentazione Indice completo: [`docs/README.md`](docs/README.md). diff --git a/packaging/windows/avvio.py b/packaging/windows/avvio.py index 1b13c92..39ad758 100644 --- a/packaging/windows/avvio.py +++ b/packaging/windows/avvio.py @@ -435,7 +435,7 @@ def main(argv=None) -> int: # Si resta vivi finche' tutti i server girano; se uno muore si ferma tutto try: - while all(t.is_alive() for t in thread): + while thread and all(t.is_alive() for t in thread): time.sleep(1) except KeyboardInterrupt: pass diff --git a/packaging/windows/tests/test_avvio.py b/packaging/windows/tests/test_avvio.py new file mode 100644 index 0000000..3eec8cd --- /dev/null +++ b/packaging/windows/tests/test_avvio.py @@ -0,0 +1,186 @@ +"""Test di packaging/windows/avvio.py (TieMeasureFlow#76): licenza firmata con la chiave di prova +del contratto v1, nessun server vero (avvia_server e migrazioni sono sostituiti). + +Si lanciano con l'ambiente 3.13 del pacchetto (serve visionsuite.license): + UV_PROJECT_ENVIRONMENT=build/venv313 uv run --python 3.13 --frozen --extra server --extra client \ + --extra vision-worker --group windows --with pytest pytest packaging/windows/tests -q +""" +from __future__ import annotations + +import base64 +import functools +import importlib.util +import json +import os +import sqlite3 +from datetime import date, timedelta +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +RADICE = Path(__file__).resolve().parents[3] +# vettore di prova del contratto licenza v1 (chiave finta, solo test) +SEED_TEST = base64.b64decode("AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=") +PUBBLICA_TEST = base64.b64decode("A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=") +IMPRONTA = "ab" * 32 + + +def _firma(dati: dict) -> str: + # stessa canonicalizzazione del contratto (byte firmati) + byte = json.dumps(dati, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + return base64.b64encode(Ed25519PrivateKey.from_private_bytes(SEED_TEST).sign(byte)).decode() + + +@pytest.fixture +def avvio(monkeypatch, tmp_path): + # avvio.py non e' in un pacchetto: lo si carica dal percorso + spec = importlib.util.spec_from_file_location("avvio_tmf", RADICE / "packaging/windows/avvio.py") + m = importlib.util.module_from_spec(spec) + spec.loader.exec_module(m) + monkeypatch.setenv("PROGRAMDATA", str(tmp_path)) # ProgramData finta + monkeypatch.delenv("TIELOGIC_LICENZA", raising=False) + monkeypatch.setattr(os, "environ", os.environ.copy()) # le variabili impostate da avvio non escono dal test + monkeypatch.setattr(m.lic, "impronta", lambda: IMPRONTA) + # verifica reale (formato, firma, prodotto, macchina, scadenza) ma con la chiave di prova + verifica_reale = m.lic.verifica + monkeypatch.setattr(m.lic, "verifica", functools.partial( + verifica_reale, chiave_pubblica=PUBBLICA_TEST, impronta_attesa=IMPRONTA)) + m.chiamate = [] + monkeypatch.setattr(m, "avvia_server", lambda host, dati: m.chiamate.append((host, dati)) or []) + monkeypatch.setattr(m, "esegui_migrazioni", lambda: m.chiamate.append("migrazioni")) + m.aperti = [] + monkeypatch.setattr(m.webbrowser, "open", lambda url: m.aperti.append(url)) + monkeypatch.setattr(m, "_prepara_log", lambda dati=None: None) + monkeypatch.setattr(m, "_porta_aperta", lambda porta: False) + return m + + +def _scrivi_licenza(tmp_path: Path, **modifiche) -> Path: + dati = {"versione": 1, "prodotto": "tiemeasureflow", "cliente": "Prova Srl", "impronta": IMPRONTA, + "emessa": date.today().isoformat(), "scadenza": (date.today() + timedelta(days=30)).isoformat()} + dati.update(modifiche) + p = tmp_path / "Tielogic" / "licenze" / "tiemeasureflow.json" + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(json.dumps({"dati": dati, "firma": _firma(dati)}), encoding="utf-8") + return p + + +def test_licenza_mancante_non_avvia_e_scrive_impronta(avvio, capsys): + assert avvio.main(["--senza-finestre"]) == 1 + assert avvio.chiamate == [] and avvio.aperti == [] + assert avvio.percorso_impronta().read_text(encoding="utf-8").strip() == IMPRONTA + err = capsys.readouterr().err + assert "Licenza non trovata" in err and IMPRONTA in err and "tiemeasureflow.json" in err + + +@pytest.mark.parametrize("modifica,testo", [ + ({"scadenza": "2020-01-01"}, "scaduta"), + ({"impronta": "cd" * 32}, "altra macchina"), + ({"prodotto": "tievision-studio"}, "altro prodotto"), +]) +def test_licenza_non_valida_non_avvia(avvio, tmp_path, capsys, modifica, testo): + _scrivi_licenza(tmp_path, **modifica) + assert avvio.main(["--senza-finestre"]) == 1 + assert avvio.chiamate == [] + assert testo in capsys.readouterr().err + assert avvio.percorso_impronta().read_text(encoding="utf-8").strip() == IMPRONTA + + +def test_licenza_manomessa_non_avvia(avvio, tmp_path, capsys): + p = _scrivi_licenza(tmp_path) + doc = json.loads(p.read_text(encoding="utf-8")) + doc["dati"]["cliente"] = "Altro Cliente" # dati cambiati dopo la firma + p.write_text(json.dumps(doc), encoding="utf-8") + assert avvio.main(["--senza-finestre"]) == 1 + assert "Firma" in capsys.readouterr().err + + +def test_licenza_valida_prepara_dati_e_avvia(avvio, tmp_path): + _scrivi_licenza(tmp_path) + assert avvio.main(["--senza-finestre", "--senza-browser", "--host", "0.0.0.0"]) == 0 + dati = tmp_path / "Tielogic" / "TieMeasureFlow" + # prima le migrazioni, poi i server + assert avvio.chiamate == ["migrazioni", ("0.0.0.0", dati)] + assert (dati / "config.env").is_file() and (dati / "uploads").is_dir() and (dati / "camera").is_dir() + assert not avvio.percorso_impronta().exists() # con licenza valida non si scrive nulla + assert avvio.aperti == [] + + +def test_variabili_del_contratto(avvio, tmp_path): + _scrivi_licenza(tmp_path) + avvio.main(["--senza-finestre", "--senza-browser"]) + e = os.environ + dati = tmp_path / "Tielogic" / "TieMeasureFlow" + assert e["APP_ENV"] == "production" + assert e["DB_URL"] == f"sqlite+aiosqlite:///{(dati / 'tmf.db').as_posix()}" and "\\" not in e["DB_URL"] + assert e["UPLOAD_DIR"] == str(dati / "uploads") == e["VISION_RECIPES_ROOT"] + assert e["VISION_WORKER_URL"] == "http://127.0.0.1:8100" + assert e["API_SERVER_URL"] == "http://127.0.0.1:8000" + assert e["SESSION_COOKIE_SECURE"] == "0" + for k in ("SERVER_SECRET_KEY", "CLIENT_SECRET_KEY", "SETUP_PASSWORD", "DB_PASSWORD"): + assert len(e[k]) >= 32 + + +def test_config_env_generato_una_volta_e_riletto(avvio, tmp_path): + dati = tmp_path / "Tielogic" / "TieMeasureFlow" + primo = avvio.carica_segreti(dati) + testo = (dati / "config.env").read_text(encoding="utf-8") + assert set(primo) == {"SERVER_SECRET_KEY", "CLIENT_SECRET_KEY", "SETUP_PASSWORD", "DB_PASSWORD"} + assert len(set(primo.values())) == 4 # segreti distinti + secondo = avvio.carica_segreti(dati) + assert secondo == primo and (dati / "config.env").read_text(encoding="utf-8") == testo + + +def test_config_env_completa_i_segreti_mancanti(avvio, tmp_path): + dati = tmp_path / "Tielogic" / "TieMeasureFlow" + dati.mkdir(parents=True) + (dati / "config.env").write_text("# nota\nSETUP_PASSWORD=scelta-da-me\n", encoding="utf-8") + v = avvio.carica_segreti(dati) + assert v["SETUP_PASSWORD"] == "scelta-da-me" and len(v["SERVER_SECRET_KEY"]) >= 32 + assert avvio.carica_segreti(dati) == v + + +def test_seconda_istanza_apre_solo_il_browser(avvio, tmp_path, monkeypatch): + _scrivi_licenza(tmp_path) + monkeypatch.setattr(avvio, "_porta_aperta", lambda porta: True) + monkeypatch.setattr(avvio, "_e_tmf", lambda: True) + assert avvio.main(["--senza-finestre"]) == 0 + assert avvio.aperti == ["http://127.0.0.1:8780/"] and avvio.chiamate == [] + + +def test_porta_occupata_da_altro_programma(avvio, tmp_path, monkeypatch, capsys): + _scrivi_licenza(tmp_path) + monkeypatch.setattr(avvio, "_porta_aperta", lambda porta: True) + monkeypatch.setattr(avvio, "_e_tmf", lambda: False) + assert avvio.main(["--senza-finestre"]) == 2 + assert "8780" in capsys.readouterr().err and avvio.chiamate == [] + + +def test_migrazione_fallita_esce_con_3(avvio, tmp_path, monkeypatch, capsys): + _scrivi_licenza(tmp_path) + + def rotta(): + raise RuntimeError("db bloccato") + + monkeypatch.setattr(avvio, "esegui_migrazioni", rotta) + assert avvio.main(["--senza-finestre"]) == 3 + assert "db bloccato" in capsys.readouterr().err + + +def test_impronta_si_stampa_senza_licenza(avvio, capsys): + assert avvio.main(["--impronta", "--senza-finestre"]) == 0 + assert IMPRONTA in capsys.readouterr().out + + +def test_nessun_utente_rileva_il_primo_avvio(avvio, tmp_path): + db = tmp_path / "t.db" + assert avvio._nessun_utente(db) is False # file assente: nessuna conclusione + con = sqlite3.connect(db) + con.execute("CREATE TABLE users (id INTEGER)") + con.commit() + assert avvio._nessun_utente(db) is True + con.execute("INSERT INTO users VALUES (1)") + con.commit() + con.close() + assert avvio._nessun_utente(db) is False From 8d4c4020f62488fdaae2e91ba4014b1b65eff629 Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Sat, 17 Oct 2026 09:20:58 +0200 Subject: [PATCH 5/9] Pacchetto Windows: tzdata nel gruppo windows, flag Nuitka dal LEGGIMI del piano PDF, trappole in CLAUDE.md (TieMeasureFlow#76) Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 17 +++++++++++++++++ packaging/windows/avvio.py | 6 ++++++ packaging/windows/build.ps1 | 5 +++-- pyproject.toml | 1 + uv.lock | 11 +++++++++++ 5 files changed, 38 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5a9b0fd..8daba67 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -335,3 +335,20 @@ Variabili d'ambiente in `.env` (copiare da `.env.example`): - Font UI: Inter - Font numeri: JetBrains Mono - Dark mode: `darkMode: 'class'` in tailwind.config.js, toggle via `Alpine.store('theme')` con localStorage key `tmf-theme` + +## Pacchetto Windows (TieMeasureFlow#76) + +- File: `packaging/windows/avvio.py` (entry point: licenza, `config.env`, variabili d'ambiente, migrazioni, quattro server in thread), `build.ps1` (uv sync 3.13, Tailwind, Nuitka `--standalone`, migrazioni come `.pyc`, Inno Setup), `TieMeasureFlow.iss`, `TieMeasureFlow.ico`; CI `.gitea/workflows/rilascio-windows.yml` (tag `v*`, runner con etichetta `windows`); test in `packaging/windows/tests/` (licenza firmata con la chiave di prova del contratto, server e migrazioni sostituiti). Piani collegati: SQLite su file (#77) e PDF/DLL (#78). Modello: TieVision-Studio#1. +- Un solo processo `TieMeasureFlow.exe`: thread `web` (Flask + waitress, `--host`, 8780), `backend` (8000), `vision` (8100), `camera` (8765) con uvicorn. Dati in `%PROGRAMDATA%\Tielogic\TieMeasureFlow` (`tmf.db`, `uploads`, `camera`, `log`, `config.env`); licenza in `%PROGRAMDATA%\Tielogic\licenze\tiemeasureflow.json` (mai nel repo). +- ⚠️ Python del pacchetto: 3.13 in un ambiente separato (`build\venv313`, `UV_PROJECT_ENVIRONMENT`); il repo resta su `.python-version` 3.11 per lo sviluppo. `vs-license` e `vs-camera` hanno il marker `python_version>='3.13'` nel gruppo `windows`, altrimenti `uv lock` fallisce sul `requires-python >=3.11`. +- ⚠️ Le variabili d'ambiente si impostano PRIMA di importare backend e frontend (leggono la configurazione all'import): in `avvio.py` gli import pesanti stanno dentro le funzioni. `APP_ENV=production` rifiuta i segreti di default, quindi `config.env` genera anche `DB_PASSWORD` (inutilizzata con SQLite). +- ⚠️ Il frontend Flask non è un pacchetto (`from config import Config`, `blueprints`, `services`, `app`) e `scripts/camera_service.py` neppure: da sorgente `avvio.py` aggiunge `flask_app` e `scripts` al `sys.path`; per Nuitka `build.ps1` li mette su `PYTHONPATH` e li include con `--include-module/--include-package`. I dati di Flask (`templates`, `static`, `translations`) vanno nella radice della cartella standalone, `src\backend\templates` sotto `src\backend`. +- ⚠️ Il browser parla con Flask (8780); la pagina di setup `/api/setup` invece è del backend: si apre su `http://127.0.0.1:8000/api/setup` (in Docker la serve nginx). `avvio.py` la apre da sola se il database non ha utenti. +- ⚠️ Alembic carica `env.py` e `versions\` dal disco, quindi Nuitka non può compilarli: `build.ps1` li copia come soli `.pyc` (`compileall -b`) e `avvio.py` esegue `upgrade head` con `sourceless=true`. È l'unica eccezione al «nessun `.py`/`.pyc`»: il controllo finale di `build.ps1` ammette `.pyc` solo sotto `src\backend\migrations`. `env.py` chiama `fileConfig`, che azzera i gestori del log: `avvio.py` li ricrea dopo le migrazioni. +- ⚠️ `numba` è escluso dal pacchetto (`--nofollow-import-to=numba`): le funzioni compilate da Nuitka non hanno bytecode e numba non può farne il JIT. Core e pm2d hanno il ripiego in Python puro (`HAS_NUMBA=False`): funziona ma il matching è più lento. Da misurare sul TC bench. +- ⚠️ L'exe è senza console: `sys.stdout`/`sys.stderr` possono essere `None` (`avvio.py` li sostituisce con il device nullo), uvicorn gira con `log_config=None` e il log va su file (`...\log\tiemeasureflow.log`). I server uvicorn girano in thread (nessun signal handler) e waitress si crea con `create_server`. +- ⚠️ `visionsuite` è un namespace condiviso da più distribuzioni: `build.ps1` elenca i sottopacchetti dagli `__path__` e i metadati con `--include-distribution-metadata`. Un nuovo pacchetto di Core si prende da solo se è in `[tool.uv.sources]` e nel gruppo. +- ⚠️ `tailwind.css` non è nel repo: `build.ps1` lo genera con `npx tailwindcss@3.4.19` (stessa versione di `Dockerfile.frontend`); serve Internet solo a chi costruisce. Il setup finale non ha bisogno di Internet. +- ⚠️ Chrome o Edge servono a kaleido per i grafici dei report: `avvio.py` imposta `BROWSER_PATH` al primo trovato. Le DLL di WeasyPrint le scarica `packaging/windows/pdf/prendi-gtk.ps1` (piano #78) in `gtk\` accanto all'exe. +- ⚠️ `SETUP_PASSWORD` e le chiavi stanno in `config.env`, in una cartella scrivibile da tutti gli utenti locali (serve all'exe, che gira con l'utente che lo lancia): non è un luogo per segreti ad alto valore. +- ⚠️ CI di rilascio: il runner `windows` è su daitarn (act_runner, modalità host, capacity 1); `upload-artifact` è alla v3. Non committare `build/` e `dist/`. diff --git a/packaging/windows/avvio.py b/packaging/windows/avvio.py index 39ad758..1817ac0 100644 --- a/packaging/windows/avvio.py +++ b/packaging/windows/avvio.py @@ -206,6 +206,12 @@ def imposta_ambiente(dati: Path, host: str, valori: dict[str, str]) -> None: gtk = programma / "gtk" # DLL di WeasyPrint (piano B) if gtk.is_dir(): env["WEASYPRINT_DLL_DIRECTORIES"] = str(gtk) + if hasattr(os, "add_dll_directory"): + # cintura e bretelle: WeasyPrint salta add_dll_directory se trova sys.frozen + try: + os.add_dll_directory(str(gtk)) + except OSError: + pass browser = trova_browser() if browser: env["BROWSER_PATH"] = browser diff --git a/packaging/windows/build.ps1 b/packaging/windows/build.ps1 index 14b3382..8401cc7 100644 --- a/packaging/windows/build.ps1 +++ b/packaging/windows/build.ps1 @@ -91,12 +91,13 @@ if ($LASTEXITCODE -ne 0) { throw "Elenco delle distribuzioni di Core non riuscit $includi += @($distribuzioni -split "\r?\n" | Where-Object { $_ } | ForEach-Object { "--include-distribution-metadata=$_" }) Write-Host "Pacchetti visionsuite: $($includi -join ' ')" -# Flag Nuitka del piano PDF (WeasyPrint, kaleido, pyphen...): righe "--include-..." di pdf\LEGGIMI.md +# Flag Nuitka del piano PDF (WeasyPrint, kaleido, pyphen, plotly, tzdata...): le righe "--include-..." +# del LEGGIMI.md, con l'eventuale commento "# ..." in coda. I dati di src/backend/templates sono gia' sopra. $flagPdf = @() $leggimi = Join-Path $PSScriptRoot 'pdf\LEGGIMI.md' if (Test-Path $leggimi) { $flagPdf = @(Get-Content $leggimi | ForEach-Object { - if ($_ -match '^\s*(?:[-*]\s*)?`?(--(?:include|nofollow|noinclude|enable|plugin)[^\s`]*)`?\s*$') { $Matches[1] } + if ($_ -match '^\s*(--(?:include|nofollow|noinclude|enable)[^\s`#]*)\s*(?:#.*)?$' -and $Matches[1] -notlike '--include-data-dir=src/backend/templates*') { $Matches[1] } }) Write-Host "Flag Nuitka da pdf\LEGGIMI.md: $($flagPdf -join ' ')" } diff --git a/pyproject.toml b/pyproject.toml index c322a72..0101619 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,6 +85,7 @@ vision-worker = [ # i container. Si usa con --python 3.13 insieme agli extra server, client e vision-worker. [dependency-groups] windows = [ + "tzdata; sys_platform == 'win32'", # ZoneInfo("Europe/Rome") (backend e frontend): Windows non ha il database IANA "aiosqlite>=0.20.0", # database SQLite su file (DB_URL, piano A) "waitress>=3.0.0", # server WSGI del frontend Flask (gunicorn non gira su Windows) "vs-camera[genicam]; python_version>='3.13'", # scripts/camera_service.py: camere GigE/GenTL (producer .cti del vendor) diff --git a/uv.lock b/uv.lock index df7b206..cbf7009 100644 --- a/uv.lock +++ b/uv.lock @@ -2103,6 +2103,7 @@ windows = [ { name = "aiosqlite" }, { name = "nuitka" }, { name = "ordered-set" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, { name = "vs-camera", extra = ["genicam"], marker = "python_full_version >= '3.13'" }, { name = "vs-license", marker = "python_full_version >= '3.13'" }, { name = "waitress" }, @@ -2158,6 +2159,7 @@ windows = [ { name = "aiosqlite", specifier = ">=0.20.0" }, { name = "nuitka", specifier = ">=2.5" }, { name = "ordered-set", specifier = ">=4.1" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, { name = "vs-camera", extras = ["genicam"], marker = "python_full_version >= '3.13'", editable = "vendor/visionsuite/packages/vs-camera" }, { name = "vs-license", marker = "python_full_version >= '3.13'", editable = "vendor/visionsuite/packages/vs-license" }, { name = "waitress", specifier = ">=3.0.0" }, @@ -2209,6 +2211,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "tzdata" +version = "2026.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/68/f1b440335057bfce71b6e50a9d09445aa2ecbd08359a337976627b8409e7/tzdata-2026.5.tar.gz", hash = "sha256:8cc73c0a0bfca7dbfa59235d60b2eff82231dee33f53d206db1acd9173cfc0a7", size = 200404, upload-time = "2026-10-03T09:23:14.143Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996, upload-time = "2026-10-03T09:23:12.535Z" }, +] + [[package]] name = "urllib3" version = "2.6.3" From bfc7c391e890e7002d8d781308f3b83f20c25da9 Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Sat, 17 Oct 2026 09:29:45 +0200 Subject: [PATCH 6/9] build.ps1: niente metadati vs-* (Nuitka li rifiuta per i pacchetti del namespace visionsuite) (TieMeasureFlow#76) Co-Authored-By: Claude Sonnet 5.5 --- packaging/windows/build.ps1 | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packaging/windows/build.ps1 b/packaging/windows/build.ps1 index 8401cc7..7524d85 100644 --- a/packaging/windows/build.ps1 +++ b/packaging/windows/build.ps1 @@ -85,10 +85,8 @@ try { $pacchetti = & $py -c "import visionsuite, pkgutil; print('\n'.join('visionsuite.' + m.name for m in pkgutil.iter_modules(visionsuite.__path__) if m.ispkg))" if ($LASTEXITCODE -ne 0 -or -not $pacchetti) { throw "Elenco dei pacchetti visionsuite non riuscito" } $includi = @($pacchetti -split "\r?\n" | Where-Object { $_ } | ForEach-Object { "--include-package=$_" }) -# Metadati delle distribuzioni di Core (importlib.metadata): visionsuite, vs-* -$distribuzioni = & $py -c "import importlib.metadata as m; print('\n'.join(sorted({d.metadata['Name'] for d in m.distributions() if d.metadata['Name'].lower() in ('visionsuite',) or d.metadata['Name'].lower().startswith('vs-')})))" -if ($LASTEXITCODE -ne 0) { throw "Elenco delle distribuzioni di Core non riuscito" } -$includi += @($distribuzioni -split "\r?\n" | Where-Object { $_ } | ForEach-Object { "--include-distribution-metadata=$_" }) +# Nessun --include-distribution-metadata per vs-*: nulla li legge e Nuitka rifiuta i metadati di una +# distribuzione il cui pacchetto (visionsuite.camera, namespace) non e' di primo livello. Write-Host "Pacchetti visionsuite: $($includi -join ' ')" # Flag Nuitka del piano PDF (WeasyPrint, kaleido, pyphen, plotly, tzdata...): le righe "--include-..." From eea3e319fb406d93a5d879e488a9e36e52c361e8 Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Sat, 17 Oct 2026 10:55:48 +0200 Subject: [PATCH 7/9] Pacchetto Windows: STATION_CODE predefinito e chiavi extra da config.env, alembic.ini sourceless, fileConfig neutralizzato (TieMeasureFlow#76) Co-Authored-By: Claude Sonnet 5.5 --- packaging/windows/avvio.py | 20 +++++++++++++++++++- packaging/windows/build.ps1 | 8 ++++++++ packaging/windows/tests/test_avvio.py | 15 +++++++++++++-- 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packaging/windows/avvio.py b/packaging/windows/avvio.py index 1817ac0..b2263d5 100644 --- a/packaging/windows/avvio.py +++ b/packaging/windows/avvio.py @@ -59,6 +59,8 @@ COMPILATO = "__compiled__" in globals() RADICE_SORGENTI = Path(__file__).resolve().parents[2] # Segreti generati al primo avvio e riletti dopo (nome -> generatore) SEGRETI = ("SERVER_SECRET_KEY", "CLIENT_SECRET_KEY", "SETUP_PASSWORD", "DB_PASSWORD") +# STATION_CODE: senza una stazione l'interfaccia risponde 503; ST-DEFAULT e' quella creata dal seed della pagina di setup +PREDEFINITI = {"STATION_CODE": "ST-DEFAULT"} def _stream_sicuri() -> None: @@ -155,9 +157,13 @@ def carica_segreti(dati: Path) -> dict[str, str]: percorso = dati / "config.env" valori = _leggi_env(percorso) if percorso.exists() else {} nuovi = [k for k in SEGRETI if not valori.get(k)] - if nuovi: + # valori di partenza modificabili dall'utente in config.env (scritti una volta sola) + mancanti = [k for k in PREDEFINITI if k not in valori] + if nuovi or mancanti: for k in nuovi: valori[k] = secrets.token_urlsafe(32) + for k in mancanti: + valori[k] = PREDEFINITI[k] righe = ["# Generato da TieMeasureFlow al primo avvio. Non condividere questo file.", "# SETUP_PASSWORD serve per la pagina http://127.0.0.1:8000/api/setup (primo amministratore)."] righe += [f"{k}={v}" for k, v in valori.items()] @@ -199,6 +205,10 @@ def imposta_ambiente(dati: Path, host: str, valori: dict[str, str]) -> None: "SERVER_CORS_ORIGINS": f"http://127.0.0.1:{PORTA_WEB},http://localhost:{PORTA_WEB}", **{k: valori[k] for k in SEGRETI}, } + # le altre chiavi di config.env (STATION_CODE, STATION_SWITCH_ENABLED, ...) passano cosi' come sono, + # ma non sovrascrivono le variabili fisse del contratto + for k, v in valori.items(): + env.setdefault(k, v) programma = cartella_programma() versione = programma / "vision-engine-version.txt" # scritto da build.ps1 if versione.is_file(): @@ -296,8 +306,16 @@ def _apri_browser_quando_pronto(url: str, porte: tuple[int, ...]) -> None: # ---------------------------------------------------------------- migrazioni +def _neutralizza_fileconfig() -> None: + # env.py di Alembic chiama logging.config.fileConfig (anche la pagina di setup, con command.stamp): + # azzererebbe i gestori del log su file. Nel pacchetto il log lo decide avvio.py. + import logging.config + logging.config.fileConfig = lambda *a, **k: None + + def esegui_migrazioni() -> None: """alembic upgrade head. Compilato: env.py e versions\\ sono soli .pyc, quindi sourceless.""" + _neutralizza_fileconfig() from alembic import command from alembic.config import Config import src.backend as pacchetto diff --git a/packaging/windows/build.ps1 b/packaging/windows/build.ps1 index 7524d85..074039c 100644 --- a/packaging/windows/build.ps1 +++ b/packaging/windows/build.ps1 @@ -174,6 +174,14 @@ if ($LASTEXITCODE -ne 0) { throw "compileall delle migrazioni fallito" } Get-ChildItem $mig -Recurse -Filter *.py -File | Remove-Item -Force Get-ChildItem $mig -Recurse -Directory -Filter __pycache__ | Remove-Item -Recurse -Force +# alembic.ini del pacchetto: sourceless, perche' anche la pagina di setup (command.stamp) apre +# alembic.ini con un Config suo e non troverebbe env.py/versions (solo .pyc) +$ini = Join-Path $mig 'alembic.ini' +$testoIni = [System.IO.File]::ReadAllText($ini) +if ($testoIni -notmatch '(?m)^script_location') { throw "alembic.ini senza script_location" } +$testoIni = $testoIni -replace '(?m)^(script_location[^\r\n]*)', "`$1`nsourceless = true" +[System.IO.File]::WriteAllText($ini, $testoIni, (New-Object System.Text.UTF8Encoding $false)) + # Commit del motore di visione: avvio.py lo legge in VISION_ENGINE_VERSION $motore = (git -C vendor/visionsuite rev-parse HEAD).Trim() if ($LASTEXITCODE -ne 0 -or -not $motore) { throw "Commit di vendor/visionsuite non leggibile" } diff --git a/packaging/windows/tests/test_avvio.py b/packaging/windows/tests/test_avvio.py index 3eec8cd..04226e9 100644 --- a/packaging/windows/tests/test_avvio.py +++ b/packaging/windows/tests/test_avvio.py @@ -126,8 +126,9 @@ def test_config_env_generato_una_volta_e_riletto(avvio, tmp_path): dati = tmp_path / "Tielogic" / "TieMeasureFlow" primo = avvio.carica_segreti(dati) testo = (dati / "config.env").read_text(encoding="utf-8") - assert set(primo) == {"SERVER_SECRET_KEY", "CLIENT_SECRET_KEY", "SETUP_PASSWORD", "DB_PASSWORD"} - assert len(set(primo.values())) == 4 # segreti distinti + assert set(primo) == {"SERVER_SECRET_KEY", "CLIENT_SECRET_KEY", "SETUP_PASSWORD", "DB_PASSWORD", "STATION_CODE"} + assert len({primo[k] for k in avvio.SEGRETI}) == 4 # segreti distinti + assert primo["STATION_CODE"] == "ST-DEFAULT" secondo = avvio.carica_segreti(dati) assert secondo == primo and (dati / "config.env").read_text(encoding="utf-8") == testo @@ -184,3 +185,13 @@ def test_nessun_utente_rileva_il_primo_avvio(avvio, tmp_path): con.commit() con.close() assert avvio._nessun_utente(db) is False + + +def test_chiavi_extra_di_config_env_passano_ma_non_sovrascrivono_il_contratto(avvio, tmp_path): + _scrivi_licenza(tmp_path) + dati = tmp_path / "Tielogic" / "TieMeasureFlow" + dati.mkdir(parents=True) + (dati / "config.env").write_text("STATION_CODE=ST-FIERA\nSTATION_SWITCH_ENABLED=1\nAPP_ENV=dev\n", encoding="utf-8") + avvio.main(["--senza-finestre", "--senza-browser"]) + assert os.environ["STATION_CODE"] == "ST-FIERA" and os.environ["STATION_SWITCH_ENABLED"] == "1" + assert os.environ["APP_ENV"] == "production" # le variabili fisse vincono From 8b3e897668008565532b244acf29f2ada3744787 Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Sat, 17 Oct 2026 11:25:22 +0200 Subject: [PATCH 8/9] Workflow rilascio-windows: corregge il percorso di Node nel PATH (YAML valido) (TieMeasureFlow#76) Co-Authored-By: Claude Sonnet 5.5 --- .gitea/workflows/rilascio-windows.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitea/workflows/rilascio-windows.yml b/.gitea/workflows/rilascio-windows.yml index 685a2b1..783d398 100644 --- a/.gitea/workflows/rilascio-windows.yml +++ b/.gitea/workflows/rilascio-windows.yml @@ -70,8 +70,7 @@ jobs: if (-not (Get-Command npx -ErrorAction SilentlyContinue)) { winget install --id OpenJS.NodeJS.LTS -e --silent --accept-package-agreements --accept-source-agreements } - "$env:ProgramFiles -odejs" | Out-File -Append -Encoding utf8 $env:GITHUB_PATH + "$env:ProgramFiles\nodejs" | Out-File -Append -Encoding utf8 $env:GITHUB_PATH - name: Inno Setup (se manca) run: | From 3aac8cc490fe51379f70cee62fc88ff861bc553e Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Sat, 17 Oct 2026 11:28:12 +0200 Subject: [PATCH 9/9] CLAUDE.md: trappole del pacchetto Windows (stazione, alembic, init-db) (TieMeasureFlow#76) Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 8daba67..7e79fb4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -352,3 +352,7 @@ Variabili d'ambiente in `.env` (copiare da `.env.example`): - ⚠️ Chrome o Edge servono a kaleido per i grafici dei report: `avvio.py` imposta `BROWSER_PATH` al primo trovato. Le DLL di WeasyPrint le scarica `packaging/windows/pdf/prendi-gtk.ps1` (piano #78) in `gtk\` accanto all'exe. - ⚠️ `SETUP_PASSWORD` e le chiavi stanno in `config.env`, in una cartella scrivibile da tutti gli utenti locali (serve all'exe, che gira con l'utente che lo lancia): non è un luogo per segreti ad alto valore. - ⚠️ CI di rilascio: il runner `windows` è su daitarn (act_runner, modalità host, capacity 1); `upload-artifact` è alla v3. Non committare `build/` e `dist/`. +- ⚠️ `STATION_CODE`: senza una stazione l'interfaccia risponde 503 («Stazione non configurata»). `config.env` nasce con `STATION_CODE=ST-DEFAULT` (la stazione creata dal seed della pagina di setup); le chiavi extra di `config.env` (`STATION_CODE`, `STATION_SWITCH_ENABLED`, ...) passano all'ambiente ma non sovrascrivono le variabili fisse del contratto. +- ⚠️ Alembic anche fuori da `avvio.py`: `setup.py` (`_stamp_head`) apre `alembic.ini` con un `Config` suo, quindi `build.ps1` scrive `sourceless = true` nell'`alembic.ini` del pacchetto; e `env.py` chiama `logging.config.fileConfig`, che azzererebbe il log su file, quindi `avvio.py` lo sostituisce con un no-op. +- ⚠️ Bug noto, non del pacchetto: `POST /api/setup/init-db` risponde 500 (`asyncio.run() cannot be called from a running event loop`, perché `_stamp_head` esegue `env.py` dentro il loop). Nel pacchetto non serve: le migrazioni creano già le tabelle; la pagina di setup si usa per «Seed» o per creare l'amministratore. +- ⚠️ Le richieste di login all'API sono limitate (5 al minuto): negli script di prova distanziarle.