From a352a21a10d81345e5215470a7c95b63d6c0ff6d Mon Sep 17 00:00:00 2001 From: cproudlock Date: Tue, 4 Aug 2026 19:12:00 -0400 Subject: [PATCH] Declare packaging as a runtime dependency shopdb/plugins/loader.py imports packaging.specifiers and packaging.version at module scope, but packaging was never listed in requirements.in. It was present in every development and CI environment as a transitive dependency of pytest, so the full suite passed while a venv built from requirements.txt alone could not import shopdb at all. The Windows installer builds exactly such a venv, so stage 3 failed on a customer server with ModuleNotFoundError: No module named 'packaging', after the runtime and all wheels had installed successfully. Add packaging to requirements.in, recompile the hashed lockfile, and add the wheel to the offline wheelhouse with the matching bundle-lock entry. The recompile also picked up newer uv formatting: inline environment markers on cffi and greenlet and shorter "via" comments. The pinned distribution set and every existing hash are unchanged. tests/test_runtime_dependencies.py guards the general case by scanning shopdb/, plugins/ and scripts/ for unconditional third-party imports and asserting each maps to a distribution pinned in requirements.txt. Test dependencies are the blind spot for this class of failure, since they are present wherever the suite runs and absent wherever it does not. --- deploy/windows/installer/bundle-lock.json | 396 +++++++++++----------- requirements.in | 6 + requirements.txt | 77 ++--- tests/test_runtime_dependencies.py | 159 +++++++++ 4 files changed, 392 insertions(+), 246 deletions(-) create mode 100644 tests/test_runtime_dependencies.py 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.' + )