diff --git a/deploy/windows/installer/bundle-lock.json b/deploy/windows/installer/bundle-lock.json index ced25fe..34bdb31 100644 --- a/deploy/windows/installer/bundle-lock.json +++ b/deploy/windows/installer/bundle-lock.json @@ -1,10 +1,19 @@ { "schema": 1, - "generated": "2026-08-04T16:16:57Z", + "generated": "2026-08-04T23:11:22Z", "pythontag": "cp314", "platform": "win_amd64", "note": "Exact third-party payload of the installer bundle. Regenerate with refresh-bundle-lock.ps1 and COMMIT the change as a reviewed dependency bump.", "payloads": { + "httpplatformhandler": { + "files": { + "httpPlatformHandler_amd64.msi": { + "sha256": "90f8d4905a0ab4f2c95223b3c79e2807a0b74507747d240e43c4302e8db4b5ef", + "size": 557056 + } + }, + "required": true + }, "python": { "files": { "python-3.14.6-amd64.exe": { @@ -14,197 +23,6 @@ }, "required": true }, - "wheels": { - "files": { - "flask_jwt_extended-4.7.3-py2.py3-none-any.whl": { - "sha256": "905ac807b52b5409bc9244dbcca434968c13ca6f9d91bffe7d4cb71e0e6231cb", - "size": 22698 - }, - "requests-2.33.1-py3-none-any.whl": { - "sha256": "4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", - "size": 64947 - }, - "waitress-3.0.2-py3-none-any.whl": { - "sha256": "c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e", - "size": 56232 - }, - "tabulate-0.10.0-py3-none-any.whl": { - "sha256": "f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", - "size": 39814 - }, - "click-8.3.3-py3-none-any.whl": { - "sha256": "a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", - "size": 110502 - }, - "alembic-1.18.4-py3-none-any.whl": { - "sha256": "a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", - "size": 263893 - }, - "werkzeug-3.1.8-py3-none-any.whl": { - "sha256": "63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", - "size": 226459 - }, - "marshmallow-4.3.0-py3-none-any.whl": { - "sha256": "46c4fe6984707e3cbd485dfebbf0a59874f58d695aad05c1668d15e8c6e13b46", - "size": 49148 - }, - "flask_cors-6.0.2-py3-none-any.whl": { - "sha256": "e57544d415dfd7da89a9564e1e3a9e515042df76e12130641ca6f3f2f03b699a", - "size": 13257 - }, - "itsdangerous-2.2.0-py3-none-any.whl": { - "sha256": "c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", - "size": 16234 - }, - "blinker-1.9.0-py3-none-any.whl": { - "sha256": "ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", - "size": 8458 - }, - "cryptography-50.0.0-cp311-abi3-win_amd64.whl": { - "sha256": "bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", - "size": 3840395 - }, - "dnspython-2.8.0-py3-none-any.whl": { - "sha256": "01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", - "size": 331094 - }, - "idna-3.13-py3-none-any.whl": { - "sha256": "892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", - "size": 68629 - }, - "flask-3.1.3-py3-none-any.whl": { - "sha256": "f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", - "size": 103424 - }, - "cachelib-0.13.0-py3-none-any.whl": { - "sha256": "8c8019e53b6302967d4e8329a504acf75e7bc46130291d30188a6e4e58162516", - "size": 20914 - }, - "sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl": { - "sha256": "77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5", - "size": 2144204 - }, - "cffi-2.1.0-cp314-cp314-win_amd64.whl": { - "sha256": "1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", - "size": 187937 - }, - "Flask_Migrate-4.1.0-py3-none-any.whl": { - "sha256": "24d8051af161782e0743af1b04a152d007bad9772b2bca67b7ec1e8ceeb3910d", - "size": 21237 - }, - "pyjwt-2.12.1-py3-none-any.whl": { - "sha256": "28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", - "size": 29726 - }, - "python_dotenv-1.2.2-py3-none-any.whl": { - "sha256": "1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", - "size": 22101 - }, - "marshmallow_sqlalchemy-1.5.0-py3-none-any.whl": { - "sha256": "3865232672f3dd38c4d5e4e85fdedce76904200742c3594948a2d11d0af93258", - "size": 16582 - }, - "pycparser-3.0-py3-none-any.whl": { - "sha256": "b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", - "size": 48172 - }, - "email_validator-2.3.0-py3-none-any.whl": { - "sha256": "80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", - "size": 35604 - }, - "typing_extensions-4.15.0-py3-none-any.whl": { - "sha256": "f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", - "size": 44614 - }, - "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl": { - "sha256": "92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", - "size": 159634 - }, - "flask_marshmallow-1.5.0-py3-none-any.whl": { - "sha256": "99951c77e5654111ed733811c6dc9310bfb4c3688c78a9e76f80b5ae0b2279a6", - "size": 12161 - }, - "urllib3-2.7.0-py3-none-any.whl": { - "sha256": "9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", - "size": 131087 - }, - "flask_caching-2.4.0-py3-none-any.whl": { - "sha256": "d15b8135f055c4f28f6f7dbcf8d36a3de4af1224def975ae6e0b43cbfa684486", - "size": 28727 - }, - "mako-1.3.12-py3-none-any.whl": { - "sha256": "8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", - "size": 78521 - }, - "markupsafe-3.0.3-cp314-cp314-win_amd64.whl": { - "sha256": "bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", - "size": 15341 - }, - "certifi-2026.4.22-py3-none-any.whl": { - "sha256": "3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", - "size": 135707 - }, - "jinja2-3.1.6-py3-none-any.whl": { - "sha256": "85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", - "size": 134899 - }, - "flask_sqlalchemy-3.1.1-py3-none-any.whl": { - "sha256": "4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0", - "size": 25125 - }, - "greenlet-3.5.0-cp314-cp314-win_amd64.whl": { - "sha256": "3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033", - "size": 239835 - }, - "pymysql-1.1.3-py3-none-any.whl": { - "sha256": "8164ba62c552f6105f3b11753352d0f16b90d1703ba67d81923d5a8a5d1c5289", - "size": 45356 - }, - "tzdata-2026.3-py2.py3-none-any.whl": { - "sha256": "dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", - "size": 348168 - }, - "mysql_connector_python-9.7.0-cp314-cp314-win_amd64.whl": { - "sha256": "5a5abbc152bc28cb2e64a04605ecd9941eff6b0dc5f9528cb84adb873e9a1e49", - "size": 18197576 - }, - "colorama-0.4.6-py2.py3-none-any.whl": { - "sha256": "4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", - "size": 25335 - } - }, - "required": true - }, - "mysql": { - "files": { - "mysql-8.4.6-winx64.msi": { - "sha256": "868885b2e221409f0170729bb30b2b2ce83440a1a4c735d19b3c225b51045762", - "size": 135081984 - } - }, - "required": false - }, - "mysqlclient": { - "files": { - "mysqldump.exe": { - "sha256": "329410b7a8ae3d68d38e6f5b781610770854d3f11b975060e231dae62c20dc46", - "size": 7142528 - }, - "libssl-3-x64.dll": { - "sha256": "e24fcd6c8ec31a14c97f520b5b960ed360c55ddf0935040eff297e511697633e", - "size": 788616 - }, - "mysql.exe": { - "sha256": "bd6880253c1853fc17b1e19129d6e5d9f9c86a2dccf2c7c9e6b878a10baa9770", - "size": 7177336 - }, - "libcrypto-3-x64.dll": { - "sha256": "e84ec61fc1b07a3c899ae39c1dc3f38591cdd310c043a388e7ab40a6828aa297", - "size": 5187216 - } - }, - "required": false - }, "vcredist": { "files": { "VC_redist.x64.exe": { @@ -214,11 +32,176 @@ }, "required": false }, - "httpplatformhandler": { + "mysql": { "files": { - "httpPlatformHandler_amd64.msi": { - "sha256": "90f8d4905a0ab4f2c95223b3c79e2807a0b74507747d240e43c4302e8db4b5ef", - "size": 557056 + "mysql-8.4.6-winx64.msi": { + "sha256": "868885b2e221409f0170729bb30b2b2ce83440a1a4c735d19b3c225b51045762", + "size": 135081984 + } + }, + "required": false + }, + "wheels": { + "files": { + "email_validator-2.3.0-py3-none-any.whl": { + "sha256": "80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", + "size": 35604 + }, + "cachelib-0.13.0-py3-none-any.whl": { + "sha256": "8c8019e53b6302967d4e8329a504acf75e7bc46130291d30188a6e4e58162516", + "size": 20914 + }, + "flask_caching-2.4.0-py3-none-any.whl": { + "sha256": "d15b8135f055c4f28f6f7dbcf8d36a3de4af1224def975ae6e0b43cbfa684486", + "size": 28727 + }, + "typing_extensions-4.15.0-py3-none-any.whl": { + "sha256": "f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", + "size": 44614 + }, + "greenlet-3.5.0-cp314-cp314-win_amd64.whl": { + "sha256": "3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033", + "size": 239835 + }, + "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl": { + "sha256": "92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", + "size": 159634 + }, + "colorama-0.4.6-py2.py3-none-any.whl": { + "sha256": "4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", + "size": 25335 + }, + "Flask_Migrate-4.1.0-py3-none-any.whl": { + "sha256": "24d8051af161782e0743af1b04a152d007bad9772b2bca67b7ec1e8ceeb3910d", + "size": 21237 + }, + "markupsafe-3.0.3-cp314-cp314-win_amd64.whl": { + "sha256": "bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", + "size": 15341 + }, + "flask_marshmallow-1.5.0-py3-none-any.whl": { + "sha256": "99951c77e5654111ed733811c6dc9310bfb4c3688c78a9e76f80b5ae0b2279a6", + "size": 12161 + }, + "tzdata-2026.3-py2.py3-none-any.whl": { + "sha256": "dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", + "size": 348168 + }, + "python_dotenv-1.2.2-py3-none-any.whl": { + "sha256": "1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", + "size": 22101 + }, + "dnspython-2.8.0-py3-none-any.whl": { + "sha256": "01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", + "size": 331094 + }, + "pymysql-1.1.3-py3-none-any.whl": { + "sha256": "8164ba62c552f6105f3b11753352d0f16b90d1703ba67d81923d5a8a5d1c5289", + "size": 45356 + }, + "packaging-26.3-py3-none-any.whl": { + "sha256": "d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", + "size": 129956 + }, + "flask_jwt_extended-4.7.3-py2.py3-none-any.whl": { + "sha256": "905ac807b52b5409bc9244dbcca434968c13ca6f9d91bffe7d4cb71e0e6231cb", + "size": 22698 + }, + "urllib3-2.7.0-py3-none-any.whl": { + "sha256": "9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", + "size": 131087 + }, + "marshmallow-4.3.0-py3-none-any.whl": { + "sha256": "46c4fe6984707e3cbd485dfebbf0a59874f58d695aad05c1668d15e8c6e13b46", + "size": 49148 + }, + "waitress-3.0.2-py3-none-any.whl": { + "sha256": "c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e", + "size": 56232 + }, + "idna-3.13-py3-none-any.whl": { + "sha256": "892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", + "size": 68629 + }, + "flask-3.1.3-py3-none-any.whl": { + "sha256": "f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", + "size": 103424 + }, + "werkzeug-3.1.8-py3-none-any.whl": { + "sha256": "63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", + "size": 226459 + }, + "flask_sqlalchemy-3.1.1-py3-none-any.whl": { + "sha256": "4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0", + "size": 25125 + }, + "marshmallow_sqlalchemy-1.5.0-py3-none-any.whl": { + "sha256": "3865232672f3dd38c4d5e4e85fdedce76904200742c3594948a2d11d0af93258", + "size": 16582 + }, + "alembic-1.18.4-py3-none-any.whl": { + "sha256": "a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", + "size": 263893 + }, + "tabulate-0.10.0-py3-none-any.whl": { + "sha256": "f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", + "size": 39814 + }, + "cffi-2.1.0-cp314-cp314-win_amd64.whl": { + "sha256": "1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", + "size": 187937 + }, + "jinja2-3.1.6-py3-none-any.whl": { + "sha256": "85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", + "size": 134899 + }, + "cryptography-50.0.0-cp311-abi3-win_amd64.whl": { + "sha256": "bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", + "size": 3840395 + }, + "certifi-2026.4.22-py3-none-any.whl": { + "sha256": "3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", + "size": 135707 + }, + "sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl": { + "sha256": "77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5", + "size": 2144204 + }, + "blinker-1.9.0-py3-none-any.whl": { + "sha256": "ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", + "size": 8458 + }, + "pycparser-3.0-py3-none-any.whl": { + "sha256": "b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", + "size": 48172 + }, + "flask_cors-6.0.2-py3-none-any.whl": { + "sha256": "e57544d415dfd7da89a9564e1e3a9e515042df76e12130641ca6f3f2f03b699a", + "size": 13257 + }, + "requests-2.33.1-py3-none-any.whl": { + "sha256": "4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", + "size": 64947 + }, + "pyjwt-2.12.1-py3-none-any.whl": { + "sha256": "28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", + "size": 29726 + }, + "click-8.3.3-py3-none-any.whl": { + "sha256": "a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", + "size": 110502 + }, + "mysql_connector_python-9.7.0-cp314-cp314-win_amd64.whl": { + "sha256": "5a5abbc152bc28cb2e64a04605ecd9941eff6b0dc5f9528cb84adb873e9a1e49", + "size": 18197576 + }, + "mako-1.3.12-py3-none-any.whl": { + "sha256": "8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", + "size": 78521 + }, + "itsdangerous-2.2.0-py3-none-any.whl": { + "sha256": "c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", + "size": 16234 } }, "required": true @@ -231,6 +214,27 @@ } }, "required": false + }, + "mysqlclient": { + "files": { + "libcrypto-3-x64.dll": { + "sha256": "e84ec61fc1b07a3c899ae39c1dc3f38591cdd310c043a388e7ab40a6828aa297", + "size": 5187216 + }, + "mysqldump.exe": { + "sha256": "329410b7a8ae3d68d38e6f5b781610770854d3f11b975060e231dae62c20dc46", + "size": 7142528 + }, + "mysql.exe": { + "sha256": "bd6880253c1853fc17b1e19129d6e5d9f9c86a2dccf2c7c9e6b878a10baa9770", + "size": 7177336 + }, + "libssl-3-x64.dll": { + "sha256": "e24fcd6c8ec31a14c97f520b5b960ed360c55ddf0935040eff297e511697633e", + "size": 788616 + } + }, + "required": false } } } diff --git a/requirements.in b/requirements.in index 2cb58a0..0244f9f 100644 --- a/requirements.in +++ b/requirements.in @@ -18,6 +18,12 @@ click>=8.1 python-dotenv>=1.0 tabulate>=0.9 +# Plugin contract version checking (shopdb/plugins/loader.py). +# Declared explicitly because it reaches dev and CI only as a pytest +# dependency: every test passes while a production venv, which has no pytest, +# cannot import shopdb at all. +packaging>=23.0 + # HTTP/API clients requests>=2.31 diff --git a/requirements.txt b/requirements.txt index 2c89e3b..26a86ac 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,31 +15,29 @@ # # Regenerate with the command above. Do NOT hand-edit: waitress and tzdata were # hand-added here once and vanished on the next compile. +# +# Only DIRECT requirements belong in requirements.in. A runtime import that is +# satisfied in dev only because some test dependency happens to pull it in will +# pass every test and then fail on a production venv: `packaging` reached this +# project through pytest, and the Windows install could not import shopdb at +# all until it was declared. tests/test_runtime_dependencies.py guards that. alembic==1.18.4 \ --hash=sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a \ --hash=sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc - # via - # -r requirements.in - # flask-migrate + # via flask-migrate blinker==1.9.0 \ --hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \ --hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc - # via - # -r requirements.in - # flask + # via flask cachelib==0.13.0 \ --hash=sha256:209d8996e3c57595bee274ff97116d1d73c4980b2fd9a34c7846cd07fd2e1a48 \ --hash=sha256:8c8019e53b6302967d4e8329a504acf75e7bc46130291d30188a6e4e58162516 - # via - # -r requirements.in - # flask-caching + # via flask-caching certifi==2026.4.22 \ --hash=sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a \ --hash=sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580 - # via - # -r requirements.in - # requests -cffi==2.1.0 \ + # via requests +cffi==2.1.0 ; platform_python_implementation != 'PyPy' \ --hash=sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc \ --hash=sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd \ --hash=sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d \ @@ -140,9 +138,7 @@ cffi==2.1.0 \ --hash=sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc \ --hash=sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da \ --hash=sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f - # via - # -r requirements.in - # cryptography + # via cryptography charset-normalizer==3.4.7 \ --hash=sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc \ --hash=sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c \ @@ -273,9 +269,7 @@ charset-normalizer==3.4.7 \ --hash=sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6 \ --hash=sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79 \ --hash=sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464 - # via - # -r requirements.in - # requests + # via requests click==8.3.3 \ --hash=sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2 \ --hash=sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613 @@ -337,9 +331,7 @@ cryptography==50.0.0 \ dnspython==2.8.0 \ --hash=sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af \ --hash=sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f - # via - # -r requirements.in - # email-validator + # via email-validator email-validator==2.3.0 \ --hash=sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4 \ --hash=sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426 @@ -381,7 +373,7 @@ flask-sqlalchemy==3.1.1 \ # via # -r requirements.in # flask-migrate -greenlet==3.5.0 \ +greenlet==3.5.0 ; platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64' \ --hash=sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846 \ --hash=sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4 \ --hash=sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662 \ @@ -441,34 +433,25 @@ greenlet==3.5.0 \ --hash=sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8 \ --hash=sha256:f8c30c2225f40dd76c50790f0eb3b5c7c18431efb299e2782083e1981feed243 \ --hash=sha256:fa94cb2288681e3a11645958f1871d48ee9211bd2f66628fdace505927d6e564 - # via - # -r requirements.in - # sqlalchemy + # via sqlalchemy idna==3.13 \ --hash=sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242 \ --hash=sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3 # via - # -r requirements.in # email-validator # requests itsdangerous==2.2.0 \ --hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \ --hash=sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173 - # via - # -r requirements.in - # flask + # via flask jinja2==3.1.6 \ --hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \ --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 - # via - # -r requirements.in - # flask + # via flask mako==1.3.12 \ --hash=sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9 \ --hash=sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a - # via - # -r requirements.in - # alembic + # via alembic markupsafe==3.0.3 \ --hash=sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f \ --hash=sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a \ @@ -560,7 +543,6 @@ markupsafe==3.0.3 \ --hash=sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a \ --hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50 # via - # -r requirements.in # flask # jinja2 # mako @@ -569,7 +551,6 @@ marshmallow==4.3.0 \ --hash=sha256:46c4fe6984707e3cbd485dfebbf0a59874f58d695aad05c1668d15e8c6e13b46 \ --hash=sha256:fb43c53b3fe240b8f6af37223d6ef1636f927ad9bea8ab323afad95dff090880 # via - # -r requirements.in # flask-marshmallow # marshmallow-sqlalchemy marshmallow-sqlalchemy==1.5.0 \ @@ -604,18 +585,18 @@ mysql-connector-python==9.7.0 \ --hash=sha256:ea4e05ab864ab8b0b5066d885d89ca51e5ba1320dba520c20a6f9388a8eca022 \ --hash=sha256:ee90c5f44f706f012be17f03f6ad158ff96e7f2dcc077896fe4537d3d28b3cf4 # via -r requirements.in -pycparser==3.0 \ +packaging==26.3 \ + --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c + # via -r requirements.in +pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy' \ --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 - # via - # -r requirements.in - # cffi + # via cffi pyjwt==2.12.1 \ --hash=sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c \ --hash=sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b - # via - # -r requirements.in - # flask-jwt-extended + # via flask-jwt-extended pymysql==1.1.3 \ --hash=sha256:8164ba62c552f6105f3b11753352d0f16b90d1703ba67d81923d5a8a5d1c5289 \ --hash=sha256:e70ebf2047a4edf6138cf79c68ad418ef620af65900aa585c5e8bfc95044d43a @@ -693,7 +674,6 @@ sqlalchemy==2.0.49 \ --hash=sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0 \ --hash=sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982 # via - # -r requirements.in # alembic # flask-sqlalchemy # marshmallow-sqlalchemy @@ -705,7 +685,6 @@ typing-extensions==4.15.0 \ --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 # via - # -r requirements.in # alembic # sqlalchemy tzdata==2026.3 \ @@ -715,9 +694,7 @@ tzdata==2026.3 \ urllib3==2.7.0 \ --hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \ --hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897 - # via - # -r requirements.in - # requests + # via requests waitress==3.0.2 \ --hash=sha256:682aaaf2af0c44ada4abfb70ded36393f0e307f4ab9456a215ce0020baefc31f \ --hash=sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e diff --git a/tests/test_runtime_dependencies.py b/tests/test_runtime_dependencies.py new file mode 100644 index 0000000..b381f6b --- /dev/null +++ b/tests/test_runtime_dependencies.py @@ -0,0 +1,159 @@ +"""Every third-party module the app imports at runtime must be a DECLARED dependency. + +The bug this exists to prevent: `shopdb/plugins/loader.py` imports `packaging`, +which was never listed in requirements.in. Locally and in CI that import +resolved anyway, because pytest depends on packaging -- so all 1159 tests +passed. The Windows installer builds its venv from requirements.txt alone, with +no test dependencies, so `import shopdb` raised ModuleNotFoundError and the +whole application failed to start on a customer server. + +Test dependencies are exactly the blind spot: they are present everywhere the +test suite runs and absent everywhere it does not. + +The check is static (AST) rather than a real import, so it covers plugins that +are not installed at this site and code paths no test exercises. +""" + +import ast +import sys +from pathlib import Path + +import pytest + +try: + from importlib.metadata import packages_distributions +except ImportError: # pragma: no cover - Python < 3.10 + packages_distributions = None + +REPO = Path(__file__).resolve().parent.parent +SCANNED = ('shopdb', 'plugins', 'scripts') + +# First-party top-level names. Imports of these resolve from the source tree. +FIRST_PARTY = {'shopdb', 'plugins', 'scripts', 'migrations', 'tests', 'wsgi', 'conftest'} + +# Modules deliberately imported without being declared. Each needs a reason. +ALLOWED_UNDECLARED = { + # Provided by the interpreter's own bundled installer tooling, and only + # touched by developer scripts that never run on a deployed site. + 'pip', + 'setuptools', + 'pkg_resources', +} + + +def _normalise(name): + """PEP 503 normalisation, so Flask_Migrate and flask-migrate compare equal.""" + return name.lower().replace('_', '-').replace('.', '-') + + +def _declared_distributions(): + """Distribution names pinned in requirements.txt.""" + text = (REPO / 'requirements.txt').read_text(encoding='utf-8') + declared = set() + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith('#') or line.startswith('--'): + continue + # "name==1.2.3 ; marker \" -> "name" + head = line.split('==', 1)[0].split(';', 1)[0].strip() + if head: + declared.add(_normalise(head)) + return declared + + +def _iter_source_files(): + for top in SCANNED: + root = REPO / top + if not root.is_dir(): + continue + for path in root.rglob('*.py'): + parts = set(path.parts) + if {'node_modules', 'venv', '.venv', 'build'} & parts: + continue + yield path + + +def _imported_top_level_modules(path): + """Top-level module names imported unconditionally by `path`. + + Imports nested inside a `try:` are skipped: that is how this codebase spells + an optional dependency, and a missing one is handled rather than fatal. + Imports inside functions are still counted -- a deferred import of an + undeclared package fails just as hard, only later. + """ + try: + tree = ast.parse(path.read_text(encoding='utf-8'), filename=str(path)) + except SyntaxError: + pytest.fail('could not parse {}'.format(path.relative_to(REPO))) + + guarded = set() + for node in ast.walk(tree): + if isinstance(node, ast.Try): + for child in ast.walk(node): + guarded.add(id(child)) + + found = set() + for node in ast.walk(tree): + if id(node) in guarded: + continue + if isinstance(node, ast.Import): + for alias in node.names: + found.add(alias.name.split('.')[0]) + elif isinstance(node, ast.ImportFrom): + # level > 0 is a relative (first-party) import. + if node.level == 0 and node.module: + found.add(node.module.split('.')[0]) + return found + + +@pytest.mark.skipif(packages_distributions is None, reason='needs Python 3.10+') +def test_runtime_imports_are_declared_dependencies(): + declared = _declared_distributions() + assert 'flask' in declared, 'requirements.txt did not parse as expected' + + module_to_dists = packages_distributions() + stdlib = getattr(sys, 'stdlib_module_names', frozenset()) + + offenders = {} + for path in _iter_source_files(): + for module in _imported_top_level_modules(path): + if module in FIRST_PARTY or module in stdlib or module in ALLOWED_UNDECLARED: + continue + if module.startswith('_'): + continue + dists = module_to_dists.get(module) + if not dists: + # Not installed in this environment, so its distribution cannot + # be identified. Absence here is not evidence of a problem. + continue + if not any(_normalise(d) in declared for d in dists): + rel = str(path.relative_to(REPO)) + offenders.setdefault( + '{} (from {})'.format(module, '/'.join(sorted(dists))), [] + ).append(rel) + + if offenders: + lines = [] + for module, files in sorted(offenders.items()): + shown = sorted(files)[:3] + more = '' if len(files) <= 3 else ' (+{} more)'.format(len(files) - 3) + lines.append(' {}\n {}{}'.format(module, ', '.join(shown), more)) + pytest.fail( + 'Imported at runtime but not declared in requirements.txt.\n' + 'These resolve here only because a test dependency supplies them; a\n' + 'production venv has no test dependencies and will fail to import.\n' + 'Add them to requirements.in and recompile.\n\n' + '\n'.join(lines) + ) + + +def test_packaging_is_declared(): + """Regression: the specific import that broke the Windows install. + + Kept separate from the sweep above because that one can only flag a module + it can attribute to an installed distribution -- an environment without + packaging would skip it silently, which is precisely the shipping case. + """ + assert 'packaging' in _declared_distributions(), ( + 'shopdb/plugins/loader.py imports packaging at module scope. Without it ' + 'declared, `import shopdb` fails on any venv built from requirements.txt.' + )