From c3b8d84c5e5078a1b101428ab51705351c351ca7 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Fri, 25 Sep 2026 15:46:12 -0500 Subject: [PATCH 1/6] PYTHON-6114 Restore mod_wsgi test infrastructure PYTHON-6104 removed the mod_wsgi tests from Evergreen. Restore the test infrastructure they need: refresh the Apache configs (drop the stale Apache 2.2 configs, add current apache24ubuntu and httpd24fedora configs), update the README, and declare the mod_wsgi dependency group the tests are installed from. --- .gitignore | 2 + pyproject.toml | 3 + test/mod_wsgi_test/README.rst | 77 +++-- test/mod_wsgi_test/apache22amazon.conf | 34 -- test/mod_wsgi_test/apache22ubuntu1204.conf | 29 -- test/mod_wsgi_test/apache24ubuntu.conf | 47 +++ test/mod_wsgi_test/apache24ubuntu161404.conf | 28 -- test/mod_wsgi_test/httpd24fedora.conf | 58 ++++ test/mod_wsgi_test/mod_wsgi_test.conf | 2 - .../mod_wsgi_test/mod_wsgi_test_embedded.conf | 2 - uv.lock | 325 ++++++++++-------- 11 files changed, 328 insertions(+), 279 deletions(-) delete mode 100644 test/mod_wsgi_test/apache22amazon.conf delete mode 100644 test/mod_wsgi_test/apache22ubuntu1204.conf create mode 100644 test/mod_wsgi_test/apache24ubuntu.conf delete mode 100644 test/mod_wsgi_test/apache24ubuntu161404.conf create mode 100644 test/mod_wsgi_test/httpd24fedora.conf diff --git a/.gitignore b/.gitignore index 8546aeabb7..222e030ad0 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,5 @@ xunit-results/ coverage.xml server.log .coverage +access_log +error_log diff --git a/pyproject.toml b/pyproject.toml index 2f7a68c837..8083b03b46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,6 +70,9 @@ unasync = [ ] pip = ["pip>=20.2"] gevent = ["gevent>=21.12"] +# mod_wsgi is built against the interpreter it is installed with and is used +# only by the mod_wsgi test suite. +mod_wsgi = ["mod_wsgi>=4.9.4"] coverage = [ "coverage[toml]>=5,<=7.15.0" ] diff --git a/test/mod_wsgi_test/README.rst b/test/mod_wsgi_test/README.rst index e96db9406c..3dddb3e5f7 100644 --- a/test/mod_wsgi_test/README.rst +++ b/test/mod_wsgi_test/README.rst @@ -13,44 +13,38 @@ mod_wsgi creates. Test Matrix ----------- -PyMongo should be tested with several versions of mod_wsgi and a selection -of Python versions. Each combination of mod_wsgi and Python version should -be tested with a standalone and a replica set. ``mod_wsgi_test.py`` -detects if the deployment is a replica set and connects to the whole set. +Continuous integration tests the oldest supported CPython against MongoDB +6.0, the oldest version that installs on the GitHub runners, with minimum +dependencies, and the newest supported CPython against the latest MongoDB, in +both daemon and embedded mode against a replica set. The Python and MongoDB +versions come from ``generate_config_utils.py`` in ``.evergreen/scripts``. +Other combinations can be tested manually. Setup ----- -Compile Python +Install Apache .............. -We need a Python interpreter built as a shared library. Download the -source tarball for each Python version tested, untar it, and run:: +On Ubuntu, install Apache and the headers used to build mod_wsgi:: - ./configure --prefix=/some/directory --enable-shared LDFLAGS="-Wl,--rpath=/some/directory/lib" - make - make install + sudo apt-get install -y apache2 apache2-dev -This results in an executable named "python" or "python3" and a shared -library named something like "libpython2.7.so.1.0" or "libpython3.3m.so.1.0". +On Fedora/RHEL, install httpd and its headers instead:: -Compile mod_wsgi -................ + sudo dnf install -y httpd httpd-devel + +The test harness detects ``apache2`` or ``httpd`` automatically and starts it +with the matching config file (``apache24ubuntu.conf`` or +``httpd24fedora.conf``). -Compile mod_wsgi for each combination for Python and mod_wsgi version in the -test matrix. For example, to compile mod_wsgi 3.4 for Python 2.7 on a -RedHat-like Linux:: +Install mod_wsgi +................ - sudo yum install -y httpd httpd-devel - wget https://modwsgi.googlecode.com/files/mod_wsgi-3.4.tar.gz - tar xzf mod_wsgi-3.4.tar.gz - cd mod_wsgi-3.4 - ./configure --with-python=/some/directory/bin/python LDFLAGS="-Wl,--rpath=/some/directory/lib" - make - make install +The project defines a ``mod_wsgi`` dependency group used for testing. pip +builds mod_wsgi against the interpreter it is installed with:: -To ease testing of several matrix combinations, copy the resulting -``mod_wsgi.so`` to a safe place. + uv sync --group mod_wsgi Start mongod ............ @@ -61,12 +55,21 @@ listening on port 27017. Configure Apache ................ -Set a MOD_WSGI_SO environment variable so our ``mod_wsgi_test.conf`` -can find and load mod_wsgi.so:: +The Apache configs expand environment variables, so export them before +starting Apache directly. All configs need ``MOD_WSGI_SO``, the path to +mod_wsgi.so, and include the mode config named by ``MOD_WSGI_CONF`` +(``mod_wsgi_test.conf`` for standalone or ``mod_wsgi_test_embedded.conf`` +for embedded mode) from the checkout at ``PROJECT_DIRECTORY``. The Fedora +config also needs ``MOD_WSGI_PYTHON_HOME``, the prefix of the interpreter +mod_wsgi was built with:: - export MOD_WSGI_SO=/path/to/mod_wsgi.so + export MOD_WSGI_SO=$(find .venv -name "mod_wsgi*.so") + export MOD_WSGI_CONF=mod_wsgi_test.conf + export PROJECT_DIRECTORY=$PWD + export MOD_WSGI_PYTHON_HOME=$(.venv/bin/python -c "import sys; print(sys.base_prefix)") -Start Apache with one of the config files in this directory. +``mod_wsgi_tester.py`` exports these variables itself, so ``just setup-tests +mod_wsgi `` (see `Automation`_) is the easier path. Run the test ------------ @@ -104,7 +107,13 @@ the workaround added in `PYTHON-569 Automation ---------- -At MongoDB, Inc. we use a continuous integration job that tests each -combination in the matrix. The job starts up Apache, starts a single server -or replica set, and runs ``test_client.py`` with the proper arguments. -See `run-mod-wsgi-tests.sh `_ +Continuous integration runs the test on pull requests that change +mod_wsgi-relevant files. The minimum dependency job runs in a Fedora +container against ``httpd`` and the latest dependency job runs on Ubuntu +against ``apache2`` (see the `test-mod-wsgi workflow +`_). +To run the same steps locally, use ``just setup-tests mod_wsgi ``, +``just run-tests``, and ``just teardown-tests``. On Linux they run against the +host's Apache, either ``apache2`` or ``httpd``; on other hosts they run in an +ubuntu container with Apache, a single-node replica set, and both test modes. +``just smoke-mod-wsgi`` runs both modes with one command. diff --git a/test/mod_wsgi_test/apache22amazon.conf b/test/mod_wsgi_test/apache22amazon.conf deleted file mode 100644 index 7755336b07..0000000000 --- a/test/mod_wsgi_test/apache22amazon.conf +++ /dev/null @@ -1,34 +0,0 @@ -# This is a minimal httpd.conf file written for Apache 2.2 on Amazon Linux - -# The modules directory is here by default. -# ServerRoot "/etc/httpd" -DocumentRoot ${PWD} -PidFile ${PWD}/apache2.pid - -User nobody -Group nobody - -# Bind to localhost only, don't require sudo. -Listen 127.0.0.1:8080 - -# Required modules. -LoadModule authz_host_module modules/mod_authz_host.so -# Needed so we can set a custom log location. -LoadModule log_config_module modules/mod_log_config.so - -ErrorLog ${PWD}/error_log -CustomLog ${PWD}/access_log combined - - - AllowOverride None - Order Deny,Allow - Deny from All - - - - AllowOverride None - Order Allow,Deny - Allow from All - - -Include ${PROJECT_DIRECTORY}/test/mod_wsgi_test/${MOD_WSGI_CONF} diff --git a/test/mod_wsgi_test/apache22ubuntu1204.conf b/test/mod_wsgi_test/apache22ubuntu1204.conf deleted file mode 100644 index 9fa4b2060b..0000000000 --- a/test/mod_wsgi_test/apache22ubuntu1204.conf +++ /dev/null @@ -1,29 +0,0 @@ -# This is a minimal httpd.conf file written for Apache 2.2 on Ubuntu 12.04 - -# The modules directory is here on Ubuntu. -ServerRoot "/usr/lib/apache2" -DocumentRoot ${PWD} -PidFile ${PWD}/apache2.pid - -# Bind to localhost only, don't require sudo. -Listen 127.0.0.1:8080 - -# Required modules. -LoadModule authz_host_module modules/mod_authz_host.so - -ErrorLog ${PWD}/error_log -CustomLog ${PWD}/access_log combined - - - AllowOverride None - Order Deny,Allow - Deny from All - - - - AllowOverride None - Order Allow,Deny - Allow from All - - -Include ${PROJECT_DIRECTORY}/test/mod_wsgi_test/${MOD_WSGI_CONF} diff --git a/test/mod_wsgi_test/apache24ubuntu.conf b/test/mod_wsgi_test/apache24ubuntu.conf new file mode 100644 index 0000000000..63161de86b --- /dev/null +++ b/test/mod_wsgi_test/apache24ubuntu.conf @@ -0,0 +1,47 @@ +# This is a minimal httpd.conf file written for Apache 2.4 on Ubuntu. + +# The modules directory is here on Ubuntu. +ServerRoot "/usr/lib/apache2" + +# mod_wsgi 6 destroys the interpreter as child processes exit. pymongo shuts +# its monitor threads down during interpreter finalization, so the default +# lifecycle (WSGIDestroyInterpreter On) is safe to use. +DocumentRoot ${PWD} +PidFile ${PWD}/apache2.pid + +# Bind to localhost only, don't require sudo. +Listen 127.0.0.1:8080 + +# Required modules. +LoadModule mpm_prefork_module modules/mod_mpm_prefork.so +LoadModule authz_core_module modules/mod_authz_core.so +# Loaded before the mod_wsgi directives in the included test config. +LoadModule wsgi_module ${MOD_WSGI_SO} + +# Bound the number of worker processes. mod_wsgi embedded mode loads a full +# Python interpreter and PyMongo in every worker, so the parallel test's +# concurrent requests would otherwise ramp prefork up to its default +# MaxRequestWorkers (256) and exhaust the container's memory. + + StartServers 2 + MinSpareServers 2 + MaxSpareServers 4 + ServerLimit 16 + MaxRequestWorkers 16 + MaxConnectionsPerChild 0 + + +ErrorLog ${PWD}/error_log +CustomLog ${PWD}/access_log combined + + + AllowOverride None + Require all denied + + + + AllowOverride None + Require all granted + + +Include ${PROJECT_DIRECTORY}/test/mod_wsgi_test/${MOD_WSGI_CONF} diff --git a/test/mod_wsgi_test/apache24ubuntu161404.conf b/test/mod_wsgi_test/apache24ubuntu161404.conf deleted file mode 100644 index eb5414f0f7..0000000000 --- a/test/mod_wsgi_test/apache24ubuntu161404.conf +++ /dev/null @@ -1,28 +0,0 @@ -# This is a minimal httpd.conf file written for Apache 2.4 on Ubuntu 14.04/16.04 - -# The modules directory is here on Ubuntu. -ServerRoot "/usr/lib/apache2" -DocumentRoot ${PWD} -PidFile ${PWD}/apache2.pid - -# Bind to localhost only, don't require sudo. -Listen 127.0.0.1:8080 - -# Required modules. -LoadModule mpm_prefork_module modules/mod_mpm_prefork.so -LoadModule authz_core_module modules/mod_authz_core.so - -ErrorLog ${PWD}/error_log -CustomLog ${PWD}/access_log combined - - - AllowOverride None - Require all denied - - - - AllowOverride None - Require all granted - - -Include ${PROJECT_DIRECTORY}/test/mod_wsgi_test/${MOD_WSGI_CONF} diff --git a/test/mod_wsgi_test/httpd24fedora.conf b/test/mod_wsgi_test/httpd24fedora.conf new file mode 100644 index 0000000000..6693b75447 --- /dev/null +++ b/test/mod_wsgi_test/httpd24fedora.conf @@ -0,0 +1,58 @@ +# This is a minimal httpd.conf file written for Apache 2.4 on Fedora/RHEL. + +# The modules directory is here on Fedora/RHEL. +ServerRoot "/etc/httpd" +DefaultRuntimeDir ${PWD} + +DocumentRoot ${PWD} +PidFile ${PWD}/httpd.pid + +# Bind to localhost only, don't require sudo. +Listen 127.0.0.1:8080 + +# Required modules. +LoadModule mpm_prefork_module modules/mod_mpm_prefork.so +LoadModule authz_core_module modules/mod_authz_core.so +LoadModule unixd_module modules/mod_unixd.so +LoadModule log_config_module modules/mod_log_config.so +# Loaded before any mod_wsgi directive below. +LoadModule wsgi_module ${MOD_WSGI_SO} + +# mod_wsgi is built against the interpreter used to install it. When Apache is +# started by a different executable than that interpreter, the embedded Python +# cannot locate its standard library, so point it at the installation. +WSGIPythonHome "${MOD_WSGI_PYTHON_HOME}" + +# Bound the number of worker processes. mod_wsgi embedded mode loads a full +# Python interpreter and PyMongo in every worker, so the parallel test's +# concurrent requests would otherwise ramp prefork up to its default +# MaxRequestWorkers (256) and exhaust the container's memory. + + StartServers 2 + MinSpareServers 2 + MaxSpareServers 4 + ServerLimit 16 + MaxRequestWorkers 16 + MaxConnectionsPerChild 0 + + +ServerName localhost + +ErrorLog ${PWD}/error_log +CustomLog ${PWD}/access_log combined + +# Apache must not run as root. The httpd package creates this user. +User apache +Group apache + + + AllowOverride None + Require all denied + + + + AllowOverride None + Require all granted + + +Include ${PROJECT_DIRECTORY}/test/mod_wsgi_test/${MOD_WSGI_CONF} diff --git a/test/mod_wsgi_test/mod_wsgi_test.conf b/test/mod_wsgi_test/mod_wsgi_test.conf index a5b09e437f..b4cbaa083e 100644 --- a/test/mod_wsgi_test/mod_wsgi_test.conf +++ b/test/mod_wsgi_test/mod_wsgi_test.conf @@ -14,8 +14,6 @@ # Minimal test of PyMongo in a WSGI application, see bug PYTHON-353 -LoadModule wsgi_module ${MOD_WSGI_SO} - # Avoid permissions issues WSGISocketPrefix /tmp/ diff --git a/test/mod_wsgi_test/mod_wsgi_test_embedded.conf b/test/mod_wsgi_test/mod_wsgi_test_embedded.conf index 306dab4ab6..970803e586 100644 --- a/test/mod_wsgi_test/mod_wsgi_test_embedded.conf +++ b/test/mod_wsgi_test/mod_wsgi_test_embedded.conf @@ -14,8 +14,6 @@ # Minimal test of PyMongo in an *Embedded mode* WSGI application. -LoadModule wsgi_module ${MOD_WSGI_SO} - # Avoid permissions issues WSGISocketPrefix /tmp/ diff --git a/uv.lock b/uv.lock index f2ae10151d..f7fe85a6ca 100644 --- a/uv.lock +++ b/uv.lock @@ -58,9 +58,9 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, - { name = "idna", marker = "python_full_version < '3.10'" }, - { name = "typing-extensions", marker = "python_full_version < '3.10'" }, + { name = "exceptiongroup" }, + { name = "idna" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } wheels = [ @@ -76,9 +76,9 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, - { name = "idna", marker = "python_full_version >= '3.10'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.15'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } wheels = [ @@ -358,9 +358,9 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "botocore", version = "1.42.97", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "jmespath", marker = "python_full_version < '3.10'" }, - { name = "s3transfer", version = "0.16.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "botocore", version = "1.42.97", source = { registry = "https://pypi.org/simple" } }, + { name = "jmespath" }, + { name = "s3transfer", version = "0.16.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/55/7d/5c6fa0bb9fd5caf865b9356411793900304328bcd0bc1eda96a32a1368a6/boto3-1.42.97.tar.gz", hash = "sha256:2833dbeda3670ea610ad48dff7d27cdc829dbbfcdfbc6b750b673948e949b6f0", size = 113217, upload-time = "2026-04-27T20:39:17.646Z" } wheels = [ @@ -376,9 +376,9 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "botocore", version = "1.43.94", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "jmespath", marker = "python_full_version >= '3.10'" }, - { name = "s3transfer", version = "0.19.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "botocore", version = "1.43.94", source = { registry = "https://pypi.org/simple" } }, + { name = "jmespath" }, + { name = "s3transfer", version = "0.19.2", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/d5/0f/a1d691e9c51ae5dce5a7394e550d7e36a995494e1c2795ce210d3520b257/boto3-1.43.94.tar.gz", hash = "sha256:aa832d88e85035cebb83f04700c65623328b88fe828ff7ed9539483bd9d608c5", size = 112663, upload-time = "2026-09-14T20:10:05.902Z" } wheels = [ @@ -394,9 +394,9 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "jmespath", marker = "python_full_version < '3.10'" }, - { name = "python-dateutil", marker = "python_full_version < '3.10'" }, - { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/c6/95/c37edb602948fad2253ffd1bb3dba5b938645bd1845ee4160350136a0f41/botocore-1.42.97.tar.gz", hash = "sha256:5c0bb00e32d16ff6d278cc8c9e10dc3672d9c1d569031635ac3c908a60de8310", size = 15269348, upload-time = "2026-04-27T20:39:05.625Z" } wheels = [ @@ -412,9 +412,9 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "jmespath", marker = "python_full_version >= '3.10'" }, - { name = "python-dateutil", marker = "python_full_version >= '3.10'" }, - { name = "urllib3", version = "2.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3", version = "2.7.0", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/fa/5b/5a7cc096db8ddb5f6c221112d5a4a46888d9e38c1f5d5a06ae7e3e0755f2/botocore-1.43.94.tar.gz", hash = "sha256:cb97cedec5fceee3261f7fff75d0b0aa2cf68b32eeb2e8c2649e4657419773a6", size = 16104907, upload-time = "2026-09-14T20:10:00.315Z" } wheels = [ @@ -439,7 +439,7 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "pycparser", version = "2.23", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and implementation_name != 'PyPy'" }, + { name = "pycparser", version = "2.23", source = { registry = "https://pypi.org/simple" }, marker = "implementation_name != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ @@ -537,7 +537,7 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "pycparser", version = "3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and implementation_name != 'PyPy'" }, + { name = "pycparser", version = "3.0", source = { registry = "https://pypi.org/simple" }, marker = "implementation_name != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } wheels = [ @@ -830,7 +830,7 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } wheels = [ @@ -976,7 +976,7 @@ wheels = [ [package.optional-dependencies] toml = [ - { name = "tomli", marker = "python_full_version < '3.10'" }, + { name = "tomli" }, ] [[package]] @@ -1083,7 +1083,7 @@ wheels = [ [package.optional-dependencies] toml = [ - { name = "tomli", marker = "python_full_version >= '3.10' and python_full_version <= '3.11'" }, + { name = "tomli", marker = "python_full_version <= '3.11'" }, ] [[package]] @@ -1353,8 +1353,8 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "cffi", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.9' and platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", marker = "python_full_version <= '3.9'" }, + { name = "cffi", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" } wheels = [ @@ -1418,9 +1418,9 @@ resolution-markers = [ "python_full_version > '3.9' and python_full_version < '3.10'", ] dependencies = [ - { name = "cffi", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.9' and python_full_version < '3.10' and platform_python_implementation != 'PyPy'" }, + { name = "cffi", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and platform_python_implementation != 'PyPy'" }, { name = "cffi", version = "2.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", marker = "python_full_version > '3.9' and python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } wheels = [ @@ -1520,7 +1520,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1554,10 +1554,10 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "cffi", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, - { name = "greenlet", version = "3.2.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and platform_python_implementation == 'CPython'" }, - { name = "zope-event", version = "6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "zope-interface", version = "8.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "cffi", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, + { name = "greenlet", version = "3.2.5", source = { registry = "https://pypi.org/simple" }, marker = "platform_python_implementation == 'CPython'" }, + { name = "zope-event", version = "6.0", source = { registry = "https://pypi.org/simple" } }, + { name = "zope-interface", version = "8.0.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/9f/5c/92002455a57cb3634383e2b822e3bccf409f43cde34528e46428971475cf/gevent-26.7.0.tar.gz", hash = "sha256:5b333a556e38a302b1b8c80525bef16d437e16f1e7767947789406841856a102", size = 6729213, upload-time = "2026-07-22T20:16:04.713Z" } wheels = [ @@ -1617,10 +1617,10 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "cffi", version = "2.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, - { name = "greenlet", version = "3.5.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and platform_python_implementation == 'CPython'" }, - { name = "zope-event", version = "6.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "zope-interface", version = "8.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "cffi", version = "2.1.1", source = { registry = "https://pypi.org/simple" }, marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, + { name = "greenlet", version = "3.5.6", source = { registry = "https://pypi.org/simple" }, marker = "platform_python_implementation == 'CPython'" }, + { name = "zope-event", version = "6.2", source = { registry = "https://pypi.org/simple" } }, + { name = "zope-interface", version = "8.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/b8/eb/5f2db8013f1a4a6df2c23201f384a066f13ff5764a9f62a608c8a50ac8cc/gevent-26.8.0.tar.gz", hash = "sha256:96039f41bbde6dcd72559e5ffbd408a04f46774b47d991d4cf032da8fa79e5a0", size = 6625998, upload-time = "2026-08-10T18:02:28.038Z" } wheels = [ @@ -1903,7 +1903,7 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "zipp", version = "3.23.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "zipp", version = "3.23.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } wheels = [ @@ -1919,7 +1919,7 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "zipp", version = "4.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "zipp", version = "4.1.0", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/6f/7e/1e7e8dc30634b93ebb3d58a3dea569ad146e656218d3960ab04f62047b29/importlib_metadata-9.0.1.tar.gz", hash = "sha256:ab830580bc0ef3db61ce8fae716389e5462b67e033018bab6d8f80ef17172f99", size = 59124, upload-time = "2026-08-28T15:30:34.646Z" } wheels = [ @@ -2225,6 +2225,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2f/c0/bc0c3e82bf91993bf2337b125c4d49c3cadae39f5a4c642baada958f976e/mockupdb-1.8.1-py2.py3-none-any.whl", hash = "sha256:8eb76212963bad5df7f3ea8f26af916087c2dcc69a0457c75328abad44956dbb", size = 27578, upload-time = "2021-10-15T19:02:55.489Z" }, ] +[[package]] +name = "mod-wsgi" +version = "5.0.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version <= '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/4d/275853efa4d2a4efe1614aa0df06295e71f2082cebccc4d40d0f0a551c24/mod_wsgi-5.0.2.tar.gz", hash = "sha256:6fe5557c3ef25c184c2c994905fe52be1c06ae5c46235ecafe558e84789e2159", size = 498627, upload-time = "2024-11-22T08:53:22.752Z" } + +[[package]] +name = "mod-wsgi" +version = "6.0.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/ce/fdbfe51f596e37c657176ea64d637737074214bfcd21c2177aa6b1f1da14/mod_wsgi-6.0.6.tar.gz", hash = "sha256:897f58ee961be7dcfe527074dd64d43d18ca108cf919d4c3bcf10fa6a4c12921", size = 607559, upload-time = "2026-08-19T09:51:18.52Z" } + [[package]] name = "mypy" version = "1.19.1" @@ -2463,6 +2483,10 @@ lint = [ mockupdb = [ { name = "mockupdb" }, ] +mod-wsgi = [ + { name = "mod-wsgi", version = "5.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "mod-wsgi", version = "6.0.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] perf = [ { name = "simplejson" }, ] @@ -2516,6 +2540,7 @@ dev = [] gevent = [{ name = "gevent", specifier = ">=21.12" }] lint = [{ name = "ruff", specifier = "==0.15.20" }] mockupdb = [{ name = "mockupdb", specifier = ">=1.8.1" }] +mod-wsgi = [{ name = "mod-wsgi", specifier = ">=4.9.4" }] perf = [{ name = "simplejson", specifier = ">=3.17.0" }] pip = [{ name = "pip", specifier = ">=20.2" }] typing = [ @@ -2572,8 +2597,8 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "cryptography", version = "47.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.9'" }, - { name = "typing-extensions", marker = "python_full_version <= '3.9'" }, + { name = "cryptography", version = "47.0.0", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1a/51/27a5ad5f939d08f690a326ef9582cda7140555180db71695f6fb747d6a36/pyopenssl-26.2.0.tar.gz", hash = "sha256:8c6fcecd1183a7fc897548dfe388b0cdb7f37e018200d8409cf33959dbe35387", size = 182195, upload-time = "2026-05-04T23:06:09.72Z" } wheels = [ @@ -2590,8 +2615,8 @@ resolution-markers = [ "python_full_version > '3.9' and python_full_version < '3.10'", ] dependencies = [ - { name = "cryptography", version = "50.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.9'" }, - { name = "typing-extensions", marker = "python_full_version > '3.9' and python_full_version < '3.13'" }, + { name = "cryptography", version = "50.0.1", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3f/e8/7325d258199b159eb2c03fe32107533e2832e70e63f4fb88a6aa00023201/pyopenssl-26.4.0.tar.gz", hash = "sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7", size = 182046, upload-time = "2026-08-01T19:50:50.512Z" } wheels = [ @@ -2620,13 +2645,13 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, - { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "packaging", marker = "python_full_version < '3.10'" }, - { name = "pluggy", marker = "python_full_version < '3.10'" }, - { name = "pygments", marker = "python_full_version < '3.10'" }, - { name = "tomli", marker = "python_full_version < '3.10'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } wheels = [ @@ -2642,13 +2667,13 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, - { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "packaging", marker = "python_full_version >= '3.10'" }, - { name = "pluggy", marker = "python_full_version >= '3.10'" }, - { name = "pygments", marker = "python_full_version >= '3.10'" }, - { name = "tomli", marker = "python_full_version == '3.10.*'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ @@ -2664,9 +2689,9 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "backports-asyncio-runner", marker = "python_full_version < '3.10'" }, - { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "typing-extensions", marker = "python_full_version < '3.10'" }, + { name = "backports-asyncio-runner" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } wheels = [ @@ -2682,9 +2707,9 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "backports-asyncio-runner", marker = "python_full_version == '3.10.*'" }, - { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } wheels = [ @@ -2734,10 +2759,10 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "certifi", marker = "python_full_version < '3.10'" }, - { name = "charset-normalizer", marker = "python_full_version < '3.10'" }, - { name = "idna", marker = "python_full_version < '3.10'" }, - { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } wheels = [ @@ -2753,10 +2778,10 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "certifi", marker = "python_full_version >= '3.10'" }, - { name = "charset-normalizer", marker = "python_full_version >= '3.10'" }, - { name = "idna", marker = "python_full_version >= '3.10'" }, - { name = "urllib3", version = "2.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3", version = "2.7.0", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ @@ -2777,7 +2802,7 @@ name = "roman-numerals-py" version = "4.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "roman-numerals", marker = "python_full_version >= '3.11'" }, + { name = "roman-numerals" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cb/b5/de96fca640f4f656eb79bbee0e79aeec52e3e0e359f8a3e6a0d366378b64/roman_numerals_py-4.1.0.tar.gz", hash = "sha256:f5d7b2b4ca52dd855ef7ab8eb3590f428c0b1ea480736ce32b01fef2a5f8daf9", size = 4274, upload-time = "2025-12-17T18:25:41.153Z" } wheels = [ @@ -2818,7 +2843,7 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "botocore", version = "1.42.97", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "botocore", version = "1.42.97", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/46/29/af14f4ef3c11a50435308660e2cc68761c9a7742475e0585cd4396b91777/s3transfer-0.16.1.tar.gz", hash = "sha256:8e424355754b9ccb32467bdc568edf55be82692ef2002d934b1311dbb3b9e524", size = 154801, upload-time = "2026-04-22T20:36:06.475Z" } wheels = [ @@ -2834,7 +2859,7 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "botocore", version = "1.43.94", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "botocore", version = "1.43.94", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/76/43/35e4d8aa320bffe8287fe8f65f578fa2d2db0a64212f0e710dce58267854/s3transfer-0.19.2.tar.gz", hash = "sha256:ba0309fd86be3c27dbf78cdd813c13c5e1df16e5874b99d2535ebbdfb9892993", size = 165592, upload-time = "2026-07-22T19:30:44.432Z" } wheels = [ @@ -3020,24 +3045,24 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "alabaster", version = "0.7.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "babel", marker = "python_full_version < '3.10'" }, - { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, - { name = "docutils", marker = "python_full_version < '3.10'" }, - { name = "imagesize", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "jinja2", marker = "python_full_version < '3.10'" }, - { name = "packaging", marker = "python_full_version < '3.10'" }, - { name = "pygments", marker = "python_full_version < '3.10'" }, - { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "snowballstemmer", marker = "python_full_version < '3.10'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.10'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.10'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.10'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.10'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.10'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.10'" }, - { name = "tomli", marker = "python_full_version < '3.10'" }, + { name = "alabaster", version = "0.7.16", source = { registry = "https://pypi.org/simple" } }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize", version = "1.5.0", source = { registry = "https://pypi.org/simple" } }, + { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" } }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" } }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, + { name = "tomli" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/be/50e50cb4f2eff47df05673d361095cafd95521d2a22521b920c67a372dcb/sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe", size = 8067911, upload-time = "2024-07-20T14:46:56.059Z" } wheels = [ @@ -3052,23 +3077,23 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, - { name = "babel", marker = "python_full_version == '3.10.*'" }, - { name = "colorama", marker = "python_full_version == '3.10.*' and sys_platform == 'win32'" }, - { name = "docutils", marker = "python_full_version == '3.10.*'" }, - { name = "imagesize", version = "2.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, - { name = "jinja2", marker = "python_full_version == '3.10.*'" }, - { name = "packaging", marker = "python_full_version == '3.10.*'" }, - { name = "pygments", marker = "python_full_version == '3.10.*'" }, - { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, - { name = "snowballstemmer", marker = "python_full_version == '3.10.*'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.10.*'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.10.*'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.10.*'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.10.*'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.10.*'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.10.*'" }, - { name = "tomli", marker = "python_full_version == '3.10.*'" }, + { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" } }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize", version = "2.0.1", source = { registry = "https://pypi.org/simple" } }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" } }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, + { name = "tomli" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } wheels = [ @@ -3083,23 +3108,23 @@ resolution-markers = [ "python_full_version >= '3.11'", ] dependencies = [ - { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "babel", marker = "python_full_version >= '3.11'" }, - { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, - { name = "docutils", marker = "python_full_version >= '3.11'" }, - { name = "imagesize", version = "2.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "jinja2", marker = "python_full_version >= '3.11'" }, - { name = "packaging", marker = "python_full_version >= '3.11'" }, - { name = "pygments", marker = "python_full_version >= '3.11'" }, - { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "roman-numerals-py", marker = "python_full_version >= '3.11'" }, - { name = "snowballstemmer", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.11'" }, + { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" } }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize", version = "2.0.1", source = { registry = "https://pypi.org/simple" } }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" } }, + { name = "roman-numerals-py" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/ad/4360e50ed56cb483667b8e6dadf2d3fda62359593faabbe749a27c4eaca6/sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348", size = 8321876, upload-time = "2025-03-02T22:31:59.658Z" } wheels = [ @@ -3116,16 +3141,16 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "colorama", marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "colorama" }, + { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.10.*'" }, { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, - { name = "starlette", version = "0.49.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "starlette", version = "0.49.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.10.*'" }, { name = "starlette", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, - { name = "uvicorn", version = "0.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "uvicorn", version = "0.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.10.*'" }, { name = "uvicorn", version = "0.53.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, - { name = "watchfiles", version = "1.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "watchfiles", version = "1.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.10.*'" }, { name = "watchfiles", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, - { name = "websockets", version = "15.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "websockets", version = "15.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.10.*'" }, { name = "websockets", version = "16.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a5/2c/155e1de2c1ba96a72e5dba152c509a8b41e047ee5c2def9e9f0d812f8be7/sphinx_autobuild-2024.10.3.tar.gz", hash = "sha256:248150f8f333e825107b6d4b86113ab28fa51750e5f9ae63b59dc339be951fb1", size = 14023, upload-time = "2024-10-02T23:15:30.172Z" } @@ -3141,12 +3166,12 @@ resolution-markers = [ "python_full_version >= '3.11'", ] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "starlette", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "uvicorn", version = "0.53.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "watchfiles", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "websockets", version = "17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "colorama" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" } }, + { name = "starlette", version = "1.6.0", source = { registry = "https://pypi.org/simple" } }, + { name = "uvicorn", version = "0.53.0", source = { registry = "https://pypi.org/simple" } }, + { name = "watchfiles", version = "1.2.0", source = { registry = "https://pypi.org/simple" } }, + { name = "websockets", version = "17.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/e0/3c/a59a3a453d4133777f7ed2e83c80b7dc817d43c74b74298ca0af869662ad/sphinx_autobuild-2025.8.25.tar.gz", hash = "sha256:9cf5aab32853c8c31af572e4fecdc09c997e2b8be5a07daf2a389e270e85b213", size = 15200, upload-time = "2025-08-25T18:44:55.436Z" } wheels = [ @@ -3280,8 +3305,8 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "anyio", version = "4.12.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "typing-extensions", marker = "python_full_version < '3.10'" }, + { name = "anyio", version = "4.12.1", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/de/1a/608df0b10b53b0beb96a37854ee05864d182ddd4b1156a22f1ad3860425a/starlette-0.49.3.tar.gz", hash = "sha256:1c14546f299b5901a1ea0e34410575bc33bbd741377a10484a54445588d00284", size = 2655031, upload-time = "2025-11-01T15:12:26.13Z" } wheels = [ @@ -3297,8 +3322,8 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "anyio", version = "4.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, + { name = "anyio", version = "4.15.1", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } wheels = [ @@ -3426,9 +3451,9 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "h11", marker = "python_full_version < '3.10'" }, - { name = "typing-extensions", marker = "python_full_version < '3.10'" }, + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" } }, + { name = "h11" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ae/4f/f9fdac7cf6dd79790eb165639b5c452ceeabc7bbabbba4569155470a287d/uvicorn-0.39.0.tar.gz", hash = "sha256:610512b19baa93423d2892d7823741f6d27717b642c8964000d7194dded19302", size = 82001, upload-time = "2025-12-21T13:05:17.973Z" } wheels = [ @@ -3444,9 +3469,9 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "click", version = "8.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "h11", marker = "python_full_version >= '3.10'" }, - { name = "typing-extensions", marker = "python_full_version == '3.10.*'" }, + { name = "click", version = "8.5.0", source = { registry = "https://pypi.org/simple" } }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5d/ad/04bbb797c84fc1f26cb171f7394716f4865ffb8d8c5e1eef42565c2dfa6b/uvicorn-0.53.0.tar.gz", hash = "sha256:a9356f0cb89b3b8621529c5d5eebd69bfe154f4c3f68b4cf2de47e45fa855c2e", size = 110881, upload-time = "2026-09-14T07:44:23.815Z" } wheels = [ @@ -3462,7 +3487,7 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "anyio", version = "4.12.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "anyio", version = "4.12.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } wheels = [ @@ -3585,7 +3610,7 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "anyio", version = "4.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "anyio", version = "4.15.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } wheels = [ @@ -4135,7 +4160,7 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "setuptools", version = "82.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "setuptools", version = "82.0.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/c2/d8/9c8b0c6bb1db09725395618f68d3b8a08089fca0aed28437500caaf713ee/zope_event-6.0.tar.gz", hash = "sha256:0ebac894fa7c5f8b7a89141c272133d8c1de6ddc75ea4b1f327f00d1f890df92", size = 18731, upload-time = "2025-09-12T07:10:13.551Z" } wheels = [ From 2e7818157ea63c0a13d4877f0c8972fac26e64d4 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Fri, 25 Sep 2026 15:46:21 -0500 Subject: [PATCH 2/6] PYTHON-6114 Add GitHub and local testing Move the mod_wsgi tests to a dedicated test-mod-wsgi.yml workflow: a min-deps job (Fedora container, httpd) and a latest job (Ubuntu, apache2), each running standalone and embedded rounds, triggered when mod_wsgi-relevant files change. The mod-wsgi-versions action computes the CPython and MongoDB versions from the Evergreen config lists. Add .evergreen/scripts/mod_wsgi.sh with dispatch.sh routing the just recipes, so just setup-tests mod_wsgi , just run-tests, and just teardown-tests run the tests against the host's Apache on Linux and inside an ubuntu container with its own replica set otherwise (just smoke-mod-wsgi runs both modes). mod_wsgi_tester.py drives Apache and verifies readiness; mod_wsgi_matrix.py defines the version matrix. Document the workflow and the local commands in CONTRIBUTING.md. --- .evergreen/scripts/dispatch.sh | 46 +++ .evergreen/scripts/mod_wsgi.sh | 240 ++++++++++++++++ .evergreen/scripts/mod_wsgi_matrix.py | 29 ++ .evergreen/scripts/mod_wsgi_tester.py | 288 +++++++++++++++++++ .github/actions/mod-wsgi-versions/action.yml | 23 ++ .github/workflows/test-mod-wsgi.yml | 180 ++++++++++++ CONTRIBUTING.md | 18 ++ justfile | 36 ++- 8 files changed, 855 insertions(+), 5 deletions(-) create mode 100755 .evergreen/scripts/dispatch.sh create mode 100755 .evergreen/scripts/mod_wsgi.sh create mode 100644 .evergreen/scripts/mod_wsgi_matrix.py create mode 100644 .evergreen/scripts/mod_wsgi_tester.py create mode 100644 .github/actions/mod-wsgi-versions/action.yml create mode 100644 .github/workflows/test-mod-wsgi.yml diff --git a/.evergreen/scripts/dispatch.sh b/.evergreen/scripts/dispatch.sh new file mode 100755 index 0000000000..372cf6535c --- /dev/null +++ b/.evergreen/scripts/dispatch.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# Route the just test recipes to the mod_wsgi tests or the Evergreen test +# runner. The mod_wsgi setup writes TEST_NAME to the test env file. +set -eu + +SCRIPT_DIR=$(dirname "${BASH_SOURCE:-$0}") +RECIPE="${1:-}" +if [ "$#" -gt 0 ]; then + shift +fi + +mod_wsgi_env() { + [ -f "$SCRIPT_DIR/test-env.sh" ] \ + && grep -q 'TEST_NAME="mod_wsgi"' "$SCRIPT_DIR/test-env.sh" \ + && bash "$SCRIPT_DIR/mod_wsgi.sh" active +} + +case "$RECIPE" in + setup-tests) + if [ "${1:-}" = "mod_wsgi" ]; then + shift + exec bash "$SCRIPT_DIR/mod_wsgi.sh" setup "${1:-}" + fi + exec bash "$SCRIPT_DIR/setup-tests.sh" "$@" + ;; + run-tests) + if mod_wsgi_env; then + if [ "$#" -gt 0 ]; then + echo "Ignoring arguments for the mod_wsgi tests: $*" >&2 + fi + exec bash "$SCRIPT_DIR/mod_wsgi.sh" test + fi + exec bash "$(dirname "$SCRIPT_DIR")/run-tests.sh" "$@" + ;; + teardown-tests) + if [ -f "$SCRIPT_DIR/test-env.sh" ] \ + && grep -q 'TEST_NAME="mod_wsgi"' "$SCRIPT_DIR/test-env.sh"; then + exec bash "$SCRIPT_DIR/mod_wsgi.sh" teardown + fi + exec bash "$SCRIPT_DIR/teardown-tests.sh" "$@" + ;; + *) + echo "Unknown recipe: $RECIPE" + exit 1 + ;; +esac diff --git a/.evergreen/scripts/mod_wsgi.sh b/.evergreen/scripts/mod_wsgi.sh new file mode 100755 index 0000000000..00f9e9cbde --- /dev/null +++ b/.evergreen/scripts/mod_wsgi.sh @@ -0,0 +1,240 @@ +#!/bin/bash +# Set up, run, or tear down the mod_wsgi tests. On Linux the test runs against +# the host's Apache; on other hosts it runs in an ubuntu:24.04 container with +# its own MongoDB replica set. +# +# Usage: mod_wsgi.sh setup | test | teardown | active +set -euo pipefail + +SCRIPT_DIR=$(dirname "${BASH_SOURCE:-$0}") +ROOT=$(dirname "$(dirname "$SCRIPT_DIR")") +CMD="${1:-}" +MODE="${2:-}" +CONTAINER=pymongo-mod-wsgi +HOME_SRC=/home/smoke/src +# The venv, MongoDB install, and the recorded version selection live outside +# the source copy, so refreshing the checkout never reinstalls them. +VENV=/home/smoke/venv +VERSIONS=/home/smoke/work/versions + +cd "$ROOT" + +case "$CMD" in + setup) + if [ "$MODE" != "standalone" ] && [ "$MODE" != "embedded" ]; then + echo "Usage: mod_wsgi.sh setup " + exit 1 + fi + # The harness recipes (run-tests, teardown-tests) dispatch on this. + echo "export TEST_NAME=\"mod_wsgi\"" > .evergreen/scripts/test-env.sh + echo "export SUB_TEST_NAME=\"$MODE\"" >> .evergreen/scripts/test-env.sh + # A failed setup leaves a partially started session behind. While the + # marker still routes to this session, tear it down; then remove the + # marker, so later just recipes do not route to the half-set-up state. + # The teardown is what stops the server or container setup may have + # started, and the next setup attempt stops any leftovers as well. + cleanup_marker() { + status=$? + if [ "$status" -ne 0 ]; then + bash "$SCRIPT_DIR/mod_wsgi.sh" teardown || true + rm -f .evergreen/scripts/test-env.sh + fi + } + trap cleanup_marker EXIT + ;; + test|teardown|active) ;; + *) + echo "Usage: mod_wsgi.sh setup | test | teardown | active" + exit 1 + ;; +esac + +if [ "$CMD" = "active" ]; then + # Exit 0 when a mod_wsgi session is active in this environment, so the + # dispatch script can route to us without a stale marker. Uses the system + # python and the tester's own STATE_FILE constant, so a missing or + # mismatched uv cannot break the dispatch and the path cannot drift. + if [ "$(uname -s)" = "Linux" ] && [ -z "${MOD_WSGI_DOCKER:-}" ]; then + if python3 -c " +import sys +sys.path.insert(0, '.evergreen/scripts') +from mod_wsgi_tester import STATE_FILE +sys.exit(0 if STATE_FILE.exists() else 1) +"; then + exit 0 + fi + exit 1 + fi + # The docker branch below handles the container case. +fi + +if [ "$(uname -s)" = "Linux" ] && [ -z "${MOD_WSGI_DOCKER:-}" ]; then + # Native: Apache runs on the host; MongoDB is already running, started by + # "just run-server" or the CI workflow. Set MOD_WSGI_DOCKER to force the + # container path on Linux. + if [ "$CMD" != "teardown" ]; then + # Other recipes' exact uv syncs — including run-tests' resync — prune the + # mod_wsgi group; restore it before setup or test when missing. Checking + # first keeps the sync from reconciling dependencies that a caller + # resolved differently, like the min-deps job's lowest-direct install. + if ! uv run --no-sync python -c "import mod_wsgi" 2>/dev/null; then + uv sync --group mod_wsgi --quiet + fi + fi + if [ "$CMD" = "setup" ]; then + uv run --no-sync python .evergreen/scripts/mod_wsgi_tester.py setup "$MODE" + else + uv run --no-sync python .evergreen/scripts/mod_wsgi_tester.py "$CMD" + fi + # Clear the dispatch marker so later just recipes use the harness. + if [ "$CMD" = "teardown" ]; then + rm -f .evergreen/scripts/test-env.sh + fi + exit 0 +fi + +# Docker path for hosts that cannot run Apache and mongod natively. +container_exists() { docker container inspect "$CONTAINER" >/dev/null 2>&1; } +container_running() { + [ "$(docker container inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null)" = "true" ] +} + +if [ "$CMD" = "active" ]; then + if container_exists; then + exit 0 + fi + exit 1 +fi + +# Copy the checkout into the container's home directory, so root-owned test +# artifacts never pollute it. +sync_source() { + docker exec "$CONTAINER" rm -rf "$HOME_SRC" + docker exec -u smoke "$CONTAINER" mkdir -p "$HOME_SRC" + tar -C "$ROOT" -cf - --exclude=.git --exclude=.venv \ + --exclude=access_log --exclude=error_log . | docker exec -i -u smoke "$CONTAINER" tar -C "$HOME_SRC" -xf - +} + +# Start a single-node replica set, if it is not already running. +ensure_mongod() { + docker exec --user smoke -w "$HOME_SRC" "$CONTAINER" bash -c 'set -euo pipefail + export PATH="$HOME/.local/bin:$PATH" + mkdir -p "$HOME/work/db" + if ! pgrep -x mongod >/dev/null; then + "$HOME/work/bin/mongod" --replSet rs0 --bind_ip 127.0.0.1 --port 27017 --dbpath "$HOME/work/db" --fork --logpath "$HOME/work/mongod.log" + fi + ' +} + +# Create the container and install the environment (idempotent). +bootstrap() { + if ! container_exists; then + docker run --name "$CONTAINER" -d ubuntu:24.04 sleep infinity + docker exec "$CONTAINER" apt-get update -qq + docker exec "$CONTAINER" apt-get install -y -qq apache2 apache2-dev build-essential curl jq git tar gzip ca-certificates + docker exec "$CONTAINER" useradd -m smoke + fi + if ! container_running; then + docker start "$CONTAINER" + fi + # Always refresh the checkout copy, so reused containers test the current + # source. The venv and versions file live outside it and survive the + # refresh. + sync_source + # Re-evaluate the newest Python and MongoDB on every bootstrap, so a reused + # container tracks matrix and server releases, but only reinstall when the + # recorded selection differs, since the downloads are slow. + docker exec --user smoke -w "$HOME_SRC" \ + -e VERSIONS="$VERSIONS" -e VENV="$VENV" "$CONTAINER" bash -c 'set -euo pipefail + export PATH="$HOME/.local/bin:$PATH" + export UV_PYTHON_INSTALL_DIR="$HOME/uv-python" + export UV_PROJECT_ENVIRONMENT="$VENV" + if ! command -v uv >/dev/null; then + curl -LsSf https://astral.sh/uv/install.sh | sh >/dev/null + fi + # Mirror the CI jobs and test the newest supported CPython. + LATEST_PYTHON=$(uv run --no-project --with "shrub.py>=3.10.0" python .evergreen/scripts/mod_wsgi_matrix.py | jq -r ".[-1].\"python-version\"") + case "$(uname -m)" in + aarch64|arm64) MARCH=aarch64 ;; + x86_64|amd64) MARCH=x86_64 ;; + *) echo "Unsupported architecture: $(uname -m)"; exit 1 ;; + esac + # MongoDB builds are per-distro; the container is ubuntu:24.04. + MONGODB_URL=$(curl -fsSL https://downloads.mongodb.org/current.json | jq -r " + .versions[] | select(.current and .production_release) | .downloads[] | + select(.target==\"ubuntu2404\" and .arch==\"$MARCH\") | .archive.url" | grep -v enterprise | head -1) + DESIRED="python=$LATEST_PYTHON mongodb=$MONGODB_URL" + if [ "$(cat "$VERSIONS" 2>/dev/null || true)" != "$DESIRED" ]; then + uv python install "$LATEST_PYTHON" >/dev/null + mkdir -p "$HOME/work/bin" + curl -fsSL "$MONGODB_URL" -o "$HOME/work/mongo.tgz" + MEMBER=$(tar -tzf "$HOME/work/mongo.tgz" | grep "bin/mongod$") + # Strip the two leading path components, so ensure_mongod finds the + # binary at "$HOME/work/bin/mongod". + tar -xz -C "$HOME/work/bin" --strip-components=2 -f "$HOME/work/mongo.tgz" "$MEMBER" + # A new interpreter needs a fresh venv; the sync below repopulates it. + rm -rf "$VENV" + printf "%s\n" "$DESIRED" > "$VERSIONS" + fi + # Rebuild the in-place C extensions on every bootstrap; the source + # refresh may carry artifacts built by a different interpreter. + export PYMONGO_C_EXT_MUST_BUILD=1 + uv sync --group mod_wsgi --refresh-package pymongo + ' + ensure_mongod + docker exec --user smoke -w "$HOME_SRC" "$CONTAINER" bash -c 'set -euo pipefail + export PATH="$HOME/.local/bin:$PATH" + export UV_PROJECT_ENVIRONMENT="'"$VENV"'" + if ! uv run --no-sync python -c "from pymongo import MongoClient; MongoClient().admin.command(\"hello\")" 2>/dev/null; then + uv run --no-sync python -c " +from pymongo import MongoClient +client = MongoClient(\"127.0.0.1:27017\", directConnection=True) +client.admin.command(\"replSetInitiate\", {\"_id\": \"rs0\", \"members\": [{\"_id\": 0, \"host\": \"127.0.0.1:27017\"}]}) +" + fi + ' +} + +# Bring a running session to readiness for a test run without refreshing the +# checkout copy: Apache may be live in it, and sync_source would unlink its +# pid file and error log. Only a missing, stopped, or never-bootstrapped +# container needs the full bootstrap, since then nothing is live to disturb. +ensure_ready() { + if container_exists && container_running \ + && docker exec "$CONTAINER" test -e "$VERSIONS"; then + ensure_mongod + else + bootstrap + fi +} + +case "$CMD" in + setup) + bootstrap + docker exec --user smoke -w "$HOME_SRC" "$CONTAINER" bash -c 'set -euo pipefail + export PATH="$HOME/.local/bin:$PATH" + export UV_PYTHON_INSTALL_DIR="$HOME/uv-python" + export UV_PROJECT_ENVIRONMENT="'"$VENV"'" + uv run --no-sync python .evergreen/scripts/mod_wsgi_tester.py setup '"$MODE"' + ' + ;; + test) + ensure_ready + docker exec --user smoke -w "$HOME_SRC" "$CONTAINER" bash -c 'set -euo pipefail + export PATH="$HOME/.local/bin:$PATH" + export UV_PROJECT_ENVIRONMENT="'"$VENV"'" + uv run --no-sync python .evergreen/scripts/mod_wsgi_tester.py test + ' + ;; + teardown) + if container_running; then + docker exec --user smoke -w "$HOME_SRC" "$CONTAINER" bash -c 'set -euo pipefail + export PATH="$HOME/.local/bin:$PATH" + export UV_PROJECT_ENVIRONMENT="'"$VENV"'" + uv run --no-sync python .evergreen/scripts/mod_wsgi_tester.py teardown + ' || true + docker stop -t 1 "$CONTAINER" + fi + rm -f .evergreen/scripts/test-env.sh + ;; +esac diff --git a/.evergreen/scripts/mod_wsgi_matrix.py b/.evergreen/scripts/mod_wsgi_matrix.py new file mode 100644 index 0000000000..2b8bb6611b --- /dev/null +++ b/.evergreen/scripts/mod_wsgi_matrix.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import json +import sys + +from generate_config_utils import ALL_VERSIONS, CPYTHONS + +# MongoDB 6.0 with the oldest supported CPython and minimum dependencies, and +# the latest MongoDB with the newest supported CPython. MongoDB 6.0 is the +# oldest version that installs on the GitHub runners. +VERSIONS = [ + { + "python-version": CPYTHONS[0], + "mongodb-version": "6.0", + }, + { + "python-version": CPYTHONS[-1], + "mongodb-version": ALL_VERSIONS[-1], + }, +] + +if __name__ == "__main__": + if len(sys.argv) == 3 and sys.argv[1] == "--index": + # Emit one version pair in GITHUB_OUTPUT format. + entry = VERSIONS[int(sys.argv[2])] + print(f"python-version={entry['python-version']}") + print(f"mongodb-version={entry['mongodb-version']}") + else: + print(json.dumps(VERSIONS)) diff --git a/.evergreen/scripts/mod_wsgi_tester.py b/.evergreen/scripts/mod_wsgi_tester.py new file mode 100644 index 0000000000..8402bdf497 --- /dev/null +++ b/.evergreen/scripts/mod_wsgi_tester.py @@ -0,0 +1,288 @@ +"""Setup, run, and tear down the mod_wsgi tests. + +Usage: + python .evergreen/scripts/mod_wsgi_tester.py setup + python .evergreen/scripts/mod_wsgi_tester.py test + python .evergreen/scripts/mod_wsgi_tester.py teardown + +The commands run as separate workflow steps, so state is passed between them +in a 0600 file in a user-owned cache directory. +""" + +from __future__ import annotations + +import json +import logging +import os +import signal +import socket +import subprocess +import sys +import time +import urllib.request +from pathlib import Path + +logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") +LOGGER = logging.getLogger("mod_wsgi_tester") + +ROOT = Path(__file__).parents[2] +# State lives in a user-owned cache directory rather than shared /tmp: another +# local user must not be able to read or plant it, since teardown feeds its +# values into the commands that stop Apache. +STATE_DIR = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "pymongo-mod-wsgi" +STATE_DIR.mkdir(mode=0o700, parents=True, exist_ok=True) +STATE_FILE = STATE_DIR / "state.json" +PORT = 8080 + + +def run_command( + cmd: str | list[str], env: dict[str, str] | None = None, check: bool = True +) -> None: + LOGGER.info(f"Running command: {cmd}") + try: + result = subprocess.run( # noqa: S603 + cmd, + shell=isinstance(cmd, str), + env={**os.environ, **(env or {})}, + cwd=str(ROOT), + capture_output=True, + text=True, + check=False, + ) + except FileNotFoundError: + # A missing binary is a failed command like any other: raised when + # required, logged and swallowed for best-effort calls. The shell + # path used to behave the same way via the exit code, and hosts like + # the Fedora job's container do not ship pkill. + if check: + raise + LOGGER.warning(f"Command not found: {cmd if isinstance(cmd, str) else cmd[0]}") + return + if result.stdout: + LOGGER.info(result.stdout) + if result.returncode != 0: + LOGGER.error(result.stderr) + if check: + raise RuntimeError(f"Command failed with code {result.returncode}: {cmd}") + + +def make_request(url: str, timeout: int = 10) -> None: + for _ in range(timeout): + try: + # Per-request timeout, so an Apache that accepts the connection + # but never answers fails after roughly timeout seconds instead + # of hanging forever. + urllib.request.urlopen(url, timeout=1) # noqa: S310 + return + except OSError: + # URLError (and its HTTPError subclass) plus a socket timeout. + pass + time.sleep(1) + raise TimeoutError(f"Failed to access {url}") + + +def find_mod_wsgi_so() -> str: + # The .so is built against the installing interpreter and lives in the + # installed package. + import mod_wsgi + + so_files = list((Path(mod_wsgi.__file__).parent / "server").glob("mod_wsgi*.so")) + if len(so_files) != 1: + raise ValueError(f"Expected one mod_wsgi.so in {mod_wsgi.__file__}, found {so_files}") + return str(so_files[0]) + + +def find_apache() -> tuple[str, str]: + """Return the Apache binary and the config file matching its distribution.""" + from shutil import which + + apache = which("apache2") + if not apache: + # apache2 lives in sbin, which is not always on PATH for non-root users. + for candidate in ("/usr/lib/apache2/mpm-prefork/apache2", "/usr/sbin/apache2"): + if Path(candidate).exists(): + apache = candidate + break + if apache: + return apache, "apache24ubuntu.conf" + apache = which("httpd") + if not apache: + for candidate in ("/usr/sbin/httpd", "/usr/local/apache2/bin/httpd"): + if Path(candidate).exists(): + apache = candidate + break + if not apache: + raise ValueError("Could not find apache2 or httpd") + return apache, "httpd24fedora.conf" + + +def port_is_open() -> bool: + try: + socket.create_connection(("127.0.0.1", PORT), timeout=1).close() + return True + except OSError: + return False + + +def pid_file(apache_config: str) -> Path: + # The test configs write the master process's pid to the checkout root. + return ROOT / ("httpd.pid" if "httpd" in apache_config else "apache2.pid") + + +def pid_is_our_apache(pid: int, apache_config: str) -> bool: + # A stale pid file may name a pid the OS has reused for an unrelated + # process, so only signal an Apache whose command line carries this + # checkout's config argument. + try: + cmdline = (Path("/proc") / str(pid) / "cmdline").read_bytes() + except OSError: + return False + argv = cmdline.decode("utf-8", "replace").split("\0") + return f"{ROOT}/test/mod_wsgi_test/{apache_config}" in argv + + +def stop_apache(apache: str, apache_config: str, env: dict[str, str] | None = None) -> None: + # Idempotent: safe to call when Apache is not running. + # Prefer the pid file: -k stop re-parses the config, which fails when the + # mod_wsgi .so or the config's env vars are unavailable. Fall back to + # pkill for a server whose pid file was removed under it. The commands run + # as argument lists, so state-derived values never reach a shell. + pid = pid_file(apache_config) + if pid.exists(): + try: + victim = int(pid.read_text().strip()) + except ValueError: + victim = 0 + if victim and pid_is_our_apache(victim, apache_config): + try: + os.kill(victim, signal.SIGTERM) + except (ProcessLookupError, PermissionError): + LOGGER.warning("Could not stop Apache via the pid file") + else: + # Stale, reused, or unreadable pid: remove the file and stop + # through the config-validated path instead. + LOGGER.warning(f"Removing stale pid file {pid}") + pid.unlink() + if not pid.exists(): + run_command( + [apache, "-k", "stop", "-f", f"{ROOT}/test/mod_wsgi_test/{apache_config}"], + env=env, + check=False, + ) + if port_is_open(): + run_command(["pkill", "-f", f"test/mod_wsgi_test/{apache_config}"], check=False) + + # Stopping returns before the port is released, which the next mode needs + # to rebind. + deadline = time.time() + 10 + while time.time() < deadline: + if not port_is_open(): + return + time.sleep(0.5) + raise ValueError(f"Apache did not release port {PORT}") + + +def write_state(state: dict) -> None: + # The cache directory is user-only (0700) and the file is created 0600, so + # no other local user can read or plant this state. + fd = os.open(STATE_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: + json.dump(state, f) + + +def read_state() -> dict: + # Lost or corrupt state falls back to discovery in teardown. + try: + return json.loads(STATE_FILE.read_text()) + except (OSError, ValueError): + return {} + + +def setup(sub_test_name: str) -> None: + if sub_test_name == "embedded": + conf = "mod_wsgi_test_embedded.conf" + elif sub_test_name == "standalone": + conf = "mod_wsgi_test.conf" + else: + raise ValueError("mod_wsgi sub test must be either 'standalone' or 'embedded'") + apache, apache_config = find_apache() + # On Fedora/RHEL, httpd embeds the interpreter mod_wsgi was built with and + # needs to be told where the Python installation lives. + env = { + "MOD_WSGI_CONF": conf, + "MOD_WSGI_SO": find_mod_wsgi_so(), + "MOD_WSGI_PYTHON_HOME": sys.base_prefix, + "PROJECT_DIRECTORY": str(ROOT), + } + # Stop any server left behind by a previous failed run so the port is free. + stop_apache(apache, apache_config, env) + run_command( + [apache, "-k", "start", "-f", f"{ROOT}/test/mod_wsgi_test/{apache_config}"], + env=env, + ) + write_state({"apache": apache, "config": apache_config, "env": env}) + + try: + for interp in ("interpreter1", "interpreter2"): + make_request(f"http://localhost:{PORT}/{interp}{ROOT}", 10) + except Exception: + # The Apache error log holds the actual failure. Stop the server so a + # failed setup does not leave it bound to the port. + stop_apache(apache, apache_config, env) + error_log = Path("error_log") + if error_log.exists(): + LOGGER.error(error_log.read_text()) + raise + + +def test() -> None: + sys.path.insert(0, str(ROOT)) + from test.mod_wsgi_test.test_client import main, parse_args + + uri1 = f"http://localhost:{PORT}/interpreter1{ROOT}" + uri2 = f"http://localhost:{PORT}/interpreter2{ROOT}" + try: + main(*parse_args(f"-n 25000 -t 100 parallel {uri1} {uri2}".split())) + main(*parse_args(f"-n 25000 serial {uri1} {uri2}".split())) + except BaseException: + # The test client raises KeyboardInterrupt in the failing path. + error_log = Path("error_log") + if error_log.exists(): + LOGGER.error(error_log.read_text()) + raise + + +def teardown() -> None: + state = read_state() + apache = state.get("apache") + apache_config = state.get("config") + env = state.get("env") + if not apache or not apache_config: + # State lost; fall back to discovery to stop a leftover server. The + # pid file in stop_apache usually makes this unnecessary. + try: + apache, apache_config = find_apache() + except ValueError: + return + env = { + "MOD_WSGI_CONF": "mod_wsgi_test.conf", + "MOD_WSGI_PYTHON_HOME": sys.base_prefix, + "PROJECT_DIRECTORY": str(ROOT), + } + try: + env["MOD_WSGI_SO"] = find_mod_wsgi_so() + except (ImportError, ValueError): + pass + stop_apache(apache, apache_config, env) + + +if __name__ == "__main__": + command = sys.argv[1] if len(sys.argv) > 1 else "" + if command == "setup": + setup(sys.argv[2]) + elif command == "test": + test() + elif command == "teardown": + teardown() + else: + raise SystemExit(f"Usage: {sys.argv[0]} setup |test|teardown") diff --git a/.github/actions/mod-wsgi-versions/action.yml b/.github/actions/mod-wsgi-versions/action.yml new file mode 100644 index 0000000000..63994a3c14 --- /dev/null +++ b/.github/actions/mod-wsgi-versions/action.yml @@ -0,0 +1,23 @@ +name: Compute Mod WSGI versions +description: Compute the CPython and MongoDB versions for a Mod WSGI job. +inputs: + index: + description: Row of the version matrix to use. + required: true +outputs: + python-version: + description: The CPython version to test with. + value: ${{ steps.versions.outputs.python-version }} + mongodb-version: + description: The MongoDB version to test against. + value: ${{ steps.versions.outputs.mongodb-version }} +runs: + using: composite + steps: + - id: versions + # The versions come from the Evergreen config's version lists. + shell: bash + env: + INDEX: ${{ inputs.index }} + run: | + uv run --no-project --with 'shrub.py>=3.10.0' python .evergreen/scripts/mod_wsgi_matrix.py --index "$INDEX" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/test-mod-wsgi.yml b/.github/workflows/test-mod-wsgi.yml new file mode 100644 index 0000000000..e0a9f3b57d --- /dev/null +++ b/.github/workflows/test-mod-wsgi.yml @@ -0,0 +1,180 @@ +name: Mod WSGI Tests + +on: + push: + branches: ["main", "v**"] + paths: + - ".evergreen/scripts/mod_wsgi_*" + - ".evergreen/scripts/mod_wsgi.sh" + - ".evergreen/scripts/dispatch.sh" + - ".evergreen/scripts/generate_config_utils.py" + - ".evergreen/scripts/setup-system.sh" + - ".github/workflows/test-mod-wsgi.yml" + - ".github/actions/mod-wsgi-*/**" + - "test/mod_wsgi_test/**" + - "pymongo/**" + - "bson/**" + - "justfile" + - "pyproject.toml" + - "uv.lock" + pull_request: + paths: + - ".evergreen/scripts/mod_wsgi_*" + - ".evergreen/scripts/mod_wsgi.sh" + - ".evergreen/scripts/dispatch.sh" + - ".evergreen/scripts/generate_config_utils.py" + - ".evergreen/scripts/setup-system.sh" + - ".github/workflows/test-mod-wsgi.yml" + - ".github/actions/mod-wsgi-*/**" + - "test/mod_wsgi_test/**" + - "pymongo/**" + - "bson/**" + - "justfile" + - "pyproject.toml" + - "uv.lock" + workflow_dispatch: + +concurrency: + group: mod-wsgi-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash -eux {0} + +permissions: + contents: read + +jobs: + mod_wsgi_min_deps: + # Exercise the minimum dependency versions against Fedora's httpd, so the + # mod_wsgi tests cover httpd in addition to Ubuntu's apache2. + runs-on: ubuntu-latest + name: Mod WSGI Min Deps + container: + # fedora:latest, pinned by digest; bump periodically. + image: fedora@sha256:43b29f65a41eb9c35e1cd5323e3bdf3b655c2357a9f4f1ff2f9c2798e5045d80 + env: + # Install the interpreter where the Apache user can read it. + UV_PYTHON_INSTALL_DIR: /opt/uv-python + # The test rounds run against the lowest direct dependency versions + # installed below; a resync would reconcile them back to the lockfile. + JUST_NO_RESYNC: "1" + steps: + # actions/checkout needs git, which fedora:latest does not include. + - name: Install git + run: dnf install -y git + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + - name: Install just + run: | + uv tool install rust-just + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + - name: Set up the system + run: bash .evergreen/scripts/setup-system.sh + - name: Install Apache + # jq and curl are needed to pick the MongoDB download. + run: dnf install -y httpd httpd-devel jq curl + - id: versions + uses: $/.github/actions/mod-wsgi-versions + with: + index: 0 + - name: Install Python + env: + PYTHON_VERSION: ${{ steps.versions.outputs.python-version }} + run: | + uv python install "$PYTHON_VERSION" + echo "UV_PYTHON=$PYTHON_VERSION" >> "$GITHUB_ENV" + - name: Install dependencies + run: | + export PYMONGO_C_EXT_MUST_BUILD=1 + just install + uv sync --group mod_wsgi --refresh-package pymongo + uv run --no-sync python tools/fail_if_no_c.py + # Re-resolve at the lowest direct versions, including the mod_wsgi + # group: uv sync above installed the locked version, but this job + # must exercise the group's mod_wsgi>=4.9.4 lower bound. + uv pip install -e ".[test]" --group mod_wsgi --resolution=lowest-direct --force-reinstall + - name: Start MongoDB + # Fedora is not a supported MongoDB platform, so install the RHEL build + # directly instead of using the drivers-evergreen-tools action, which + # relies on actions/setup-python and does not run on Fedora. + env: + MONGODB_VERSION: ${{ steps.versions.outputs.mongodb-version }} + run: | + ARCH=$(uname -m) + URL=$(curl -fsSL https://downloads.mongodb.org/current.json | jq -r --arg v "$MONGODB_VERSION." --arg a "$ARCH" ' + .versions[] | select(.current and .production_release) | select(.version | startswith($v)) | + .downloads[] | select(.target=="rhel90" and .arch==$a) | .archive.url' | grep -v enterprise | head -1) + curl -fsSL "$URL" -o /tmp/mongo.tgz + tar -xz -C /tmp --strip-components=1 -f /tmp/mongo.tgz + mkdir -p /tmp/db + /tmp/bin/mongod --replSet rs0 --bind_ip 127.0.0.1 --port 27017 --dbpath /tmp/db --fork --logpath /tmp/mongod.log + # --no-sync: a sync would reconcile the lowest-direct dependency + # versions installed above back to the lockfile. + uv run --no-sync python -c " + from pymongo import MongoClient + client = MongoClient('127.0.0.1:27017', directConnection=True) + client.admin.command('replSetInitiate', {'_id': 'rs0', 'members': [{'_id': 0, 'host': '127.0.0.1:27017'}]}) + MongoClient().admin.command('hello') + " + - name: Run standalone tests + run: | + just setup-tests mod_wsgi standalone + just run-tests + - name: Teardown standalone + if: always() + run: just teardown-tests + - name: Run embedded tests + run: | + just setup-tests mod_wsgi embedded + just run-tests + - name: Teardown embedded + if: always() + run: just teardown-tests + + mod_wsgi_latest: + runs-on: ubuntu-22.04 + name: Mod WSGI Latest + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + - id: versions + uses: $/.github/actions/mod-wsgi-versions + with: + index: 1 + - name: Install Python tooling + uses: mongodb-labs/drivers-github-tools/python/setup@f137fdd28483af14ebf466ebc5aa789fbf867218 # v3.0.5 + with: + python-version: ${{ steps.versions.outputs.python-version }} + - name: Install Apache + run: sudo apt-get install -y apache2 apache2-dev + - name: Install dependencies + run: | + export PYMONGO_C_EXT_MUST_BUILD=1 + just install + uv sync --group mod_wsgi --refresh-package pymongo + uv run --no-sync python tools/fail_if_no_c.py + - id: setup-mongodb + uses: mongodb-labs/drivers-evergreen-tools@49da51cebc0060b4d699d57bdef30ee7e071de44 + with: + version: ${{ steps.versions.outputs.mongodb-version }} + topology: "replica_set" + - name: Run standalone tests + run: | + just setup-tests mod_wsgi standalone + just run-tests + - name: Teardown standalone + if: always() + run: just teardown-tests + - name: Run embedded tests + run: | + just setup-tests mod_wsgi embedded + just run-tests + - name: Teardown embedded + if: always() + run: just teardown-tests diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 42a8606cd9..61e369767a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -353,6 +353,24 @@ You will need to set up access to the `drivers-test-secrets-role`, see the [Wiki - Run `just setup-tests aws_lambda`. - Run `just run-tests`. +### mod_wsgi tests + +Continuous integration runs the tests on pull requests that change +mod_wsgi-relevant files, in the `test-mod-wsgi.yml` workflow. + +To run the tests by hand, install Apache and mod_wsgi (`sudo apt-get install -y apache2 +apache2-dev` and `uv sync --group mod_wsgi` on Ubuntu), then: + +- On Linux, run `TOPOLOGY=replica_set just run-server`. +- Run `just setup-tests mod_wsgi `. +- Run `just run-tests`. +- Run `just teardown-tests`. + +The `mode` can be `standalone` or `embedded`. On non-Linux hosts the same +commands run inside an ubuntu container, which bootstraps its own replica +set (so the `run-server` step does not apply), or use `just smoke-mod-wsgi` +to run both modes. + ### OCSP tests - Export the orchestration file, e.g. `export ORCHESTRATION_FILE=rsa-basic-tls-ocsp-disableStapling.json`. diff --git a/justfile b/justfile index df678cdbac..9b37109c74 100644 --- a/justfile +++ b/justfile @@ -12,9 +12,12 @@ mypy_args := "--install-types --non-interactive" default: @just --list +# Restore the default development environment. Set JUST_NO_RESYNC to skip the +# sync when a caller resolves dependencies differently, such as the +# minimum-dependencies job in test-mod-wsgi.yml. [private] resync: - @uv sync --quiet + @if [ -z "${JUST_NO_RESYNC:-}" ]; then uv sync --quiet; fi # Set up the development environment install: @@ -85,17 +88,17 @@ test-numpy *args="": && resync # Run tests via the Evergreen test runner script [group('test')] run-tests *args: && resync - bash ./.evergreen/run-tests.sh {{args}} + bash .evergreen/scripts/dispatch.sh run-tests {{args}} # Set up the test environment (auth, TLS, etc.) [group('test')] setup-tests *args="": - bash .evergreen/scripts/setup-tests.sh {{args}} + bash .evergreen/scripts/dispatch.sh setup-tests {{args}} # Tear down resources created by setup-tests [group('test')] -teardown-tests: - bash .evergreen/scripts/teardown-tests.sh +teardown-tests *args="": + bash .evergreen/scripts/dispatch.sh teardown-tests {{args}} [group('test')] integration-tests: @@ -124,6 +127,29 @@ coverage-xml: uv tool run --with "coverage[toml]" coverage xml @echo "Coverage report generated in coverage.xml" +# Run the mod_wsgi tests (natively on Linux, else in an ubuntu container) +[group('test')] +smoke-mod-wsgi: + #!/usr/bin/env bash + set -euo pipefail + STATUS=0 + # Tear down on an early exit (a failed setup or teardown), so a partially + # started Apache or container and the dispatch marker never outlive the + # recipe; the per-mode teardown below clears the guard. + CLEANED=1 + cleanup() { + [ "$CLEANED" -eq 1 ] || just teardown-tests || true + } + trap cleanup EXIT + for mode in standalone embedded; do + CLEANED=0 + just setup-tests mod_wsgi $mode + just run-tests || STATUS=$? + just teardown-tests + CLEANED=1 + [ "$STATUS" -eq 0 ] || exit "$STATUS" + done + # Start a MongoDB server via drivers-evergreen-tools [group('server')] run-server *args="": From bb2eae55a9cbcb6394c418abdd2c45801428fc2b Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Mon, 28 Sep 2026 05:14:01 -0500 Subject: [PATCH 3/6] PYTHON-6114 Address Copilot review findings --- .evergreen/scripts/mod_wsgi.sh | 17 ++++++++++++++--- .github/workflows/test-mod-wsgi.yml | 4 ++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.evergreen/scripts/mod_wsgi.sh b/.evergreen/scripts/mod_wsgi.sh index 00f9e9cbde..8975ca82ad 100755 --- a/.evergreen/scripts/mod_wsgi.sh +++ b/.evergreen/scripts/mod_wsgi.sh @@ -130,9 +130,20 @@ ensure_mongod() { bootstrap() { if ! container_exists; then docker run --name "$CONTAINER" -d ubuntu:24.04 sleep infinity - docker exec "$CONTAINER" apt-get update -qq - docker exec "$CONTAINER" apt-get install -y -qq apache2 apache2-dev build-essential curl jq git tar gzip ca-certificates - docker exec "$CONTAINER" useradd -m smoke + # A transient failure during initialization (for example in apt-get) + # would leave the half-created container behind, and later bootstraps + # would skip initialization and fail when sync_source expects the + # missing smoke user. Remove the container on failure, so the next + # attempt initializes a fresh one. + initialize() { + docker exec "$CONTAINER" apt-get update -qq + docker exec "$CONTAINER" apt-get install -y -qq apache2 apache2-dev build-essential curl jq git tar gzip ca-certificates + docker exec "$CONTAINER" useradd -m smoke + } + if ! initialize; then + docker rm -f "$CONTAINER" >/dev/null 2>&1 || true + exit 1 + fi fi if ! container_running; then docker start "$CONTAINER" diff --git a/.github/workflows/test-mod-wsgi.yml b/.github/workflows/test-mod-wsgi.yml index e0a9f3b57d..9b3e981135 100644 --- a/.github/workflows/test-mod-wsgi.yml +++ b/.github/workflows/test-mod-wsgi.yml @@ -78,7 +78,7 @@ jobs: # jq and curl are needed to pick the MongoDB download. run: dnf install -y httpd httpd-devel jq curl - id: versions - uses: $/.github/actions/mod-wsgi-versions + uses: ./.github/actions/mod-wsgi-versions with: index: 0 - name: Install Python @@ -144,7 +144,7 @@ jobs: persist-credentials: false - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - id: versions - uses: $/.github/actions/mod-wsgi-versions + uses: ./.github/actions/mod-wsgi-versions with: index: 1 - name: Install Python tooling From 60b277e79119e94823fe7822dd99b411fdb5e356 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Mon, 28 Sep 2026 05:17:14 -0500 Subject: [PATCH 4/6] PYTHON-6114 Restore workflow action path syntax --- .github/workflows/test-mod-wsgi.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-mod-wsgi.yml b/.github/workflows/test-mod-wsgi.yml index 9b3e981135..e0a9f3b57d 100644 --- a/.github/workflows/test-mod-wsgi.yml +++ b/.github/workflows/test-mod-wsgi.yml @@ -78,7 +78,7 @@ jobs: # jq and curl are needed to pick the MongoDB download. run: dnf install -y httpd httpd-devel jq curl - id: versions - uses: ./.github/actions/mod-wsgi-versions + uses: $/.github/actions/mod-wsgi-versions with: index: 0 - name: Install Python @@ -144,7 +144,7 @@ jobs: persist-credentials: false - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - id: versions - uses: ./.github/actions/mod-wsgi-versions + uses: $/.github/actions/mod-wsgi-versions with: index: 1 - name: Install Python tooling From 9210315ee61c295ca34240e3c443fcbb58afa56a Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Mon, 28 Sep 2026 05:33:49 -0500 Subject: [PATCH 5/6] PYTHON-6114 Address latest Copilot review findings --- .evergreen/scripts/mod_wsgi.sh | 13 ++++++++++++- .evergreen/scripts/mod_wsgi_tester.py | 4 +++- .github/workflows/test-mod-wsgi.yml | 4 ---- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/.evergreen/scripts/mod_wsgi.sh b/.evergreen/scripts/mod_wsgi.sh index 8975ca82ad..61aed24b73 100755 --- a/.evergreen/scripts/mod_wsgi.sh +++ b/.evergreen/scripts/mod_wsgi.sh @@ -196,7 +196,18 @@ bootstrap() { docker exec --user smoke -w "$HOME_SRC" "$CONTAINER" bash -c 'set -euo pipefail export PATH="$HOME/.local/bin:$PATH" export UV_PROJECT_ENVIRONMENT="'"$VENV"'" - if ! uv run --no-sync python -c "from pymongo import MongoClient; MongoClient().admin.command(\"hello\")" 2>/dev/null; then + # Probe replSetGetStatus over a direct connection: it returns immediately + # for an uninitialized replica set, while the default hello-based server + # selection would block for the full server selection timeout first. + if ! uv run --no-sync python -c " +from pymongo import MongoClient +client = MongoClient(\"127.0.0.1:27017\", directConnection=True) +try: + client.admin.command(\"replSetGetStatus\") +except Exception: + # NotYetInitialized (or a transient failure): initiation is still needed. + raise SystemExit(1) +" 2>/dev/null; then uv run --no-sync python -c " from pymongo import MongoClient client = MongoClient(\"127.0.0.1:27017\", directConnection=True) diff --git a/.evergreen/scripts/mod_wsgi_tester.py b/.evergreen/scripts/mod_wsgi_tester.py index 8402bdf497..6247ba029b 100644 --- a/.evergreen/scripts/mod_wsgi_tester.py +++ b/.evergreen/scripts/mod_wsgi_tester.py @@ -25,7 +25,9 @@ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") LOGGER = logging.getLogger("mod_wsgi_tester") -ROOT = Path(__file__).parents[2] +# Resolve __file__, so ROOT is correct when the script is invoked with a +# relative path, as mod_wsgi.sh does. +ROOT = Path(__file__).resolve().parents[2] # State lives in a user-owned cache directory rather than shared /tmp: another # local user must not be able to read or plant it, since teardown feeds its # values into the commands that stop Apache. diff --git a/.github/workflows/test-mod-wsgi.yml b/.github/workflows/test-mod-wsgi.yml index e0a9f3b57d..4985161986 100644 --- a/.github/workflows/test-mod-wsgi.yml +++ b/.github/workflows/test-mod-wsgi.yml @@ -8,7 +8,6 @@ on: - ".evergreen/scripts/mod_wsgi.sh" - ".evergreen/scripts/dispatch.sh" - ".evergreen/scripts/generate_config_utils.py" - - ".evergreen/scripts/setup-system.sh" - ".github/workflows/test-mod-wsgi.yml" - ".github/actions/mod-wsgi-*/**" - "test/mod_wsgi_test/**" @@ -23,7 +22,6 @@ on: - ".evergreen/scripts/mod_wsgi.sh" - ".evergreen/scripts/dispatch.sh" - ".evergreen/scripts/generate_config_utils.py" - - ".evergreen/scripts/setup-system.sh" - ".github/workflows/test-mod-wsgi.yml" - ".github/actions/mod-wsgi-*/**" - "test/mod_wsgi_test/**" @@ -72,8 +70,6 @@ jobs: run: | uv tool install rust-just echo "$HOME/.local/bin" >> "$GITHUB_PATH" - - name: Set up the system - run: bash .evergreen/scripts/setup-system.sh - name: Install Apache # jq and curl are needed to pick the MongoDB download. run: dnf install -y httpd httpd-devel jq curl From 68e1092fb4a47467a503588ab551b0ca19b20057 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Mon, 28 Sep 2026 05:43:28 -0500 Subject: [PATCH 6/6] PYTHON-6114 Validate fallback Apache pids before signaling --- .evergreen/scripts/mod_wsgi_tester.py | 30 +++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/.evergreen/scripts/mod_wsgi_tester.py b/.evergreen/scripts/mod_wsgi_tester.py index 6247ba029b..c326f6b476 100644 --- a/.evergreen/scripts/mod_wsgi_tester.py +++ b/.evergreen/scripts/mod_wsgi_tester.py @@ -146,9 +146,12 @@ def pid_is_our_apache(pid: int, apache_config: str) -> bool: def stop_apache(apache: str, apache_config: str, env: dict[str, str] | None = None) -> None: # Idempotent: safe to call when Apache is not running. # Prefer the pid file: -k stop re-parses the config, which fails when the - # mod_wsgi .so or the config's env vars are unavailable. Fall back to - # pkill for a server whose pid file was removed under it. The commands run - # as argument lists, so state-derived values never reach a shell. + # mod_wsgi .so or the config's env vars are unavailable. Fall back to a + # validated pgrep for a server whose pid file was removed under it. The + # commands run as argument lists, so state-derived values never reach a + # shell. + from shutil import which + pid = pid_file(apache_config) if pid.exists(): try: @@ -172,7 +175,26 @@ def stop_apache(apache: str, apache_config: str, env: dict[str, str] | None = No check=False, ) if port_is_open(): - run_command(["pkill", "-f", f"test/mod_wsgi_test/{apache_config}"], check=False) + # Last resort: find pids whose full command line mentions this + # checkout's absolute config path, then signal only the ones whose + # command line carries it as an exact argument, so a server from + # another checkout (or an unrelated process) is never killed. + config_path = f"{ROOT}/test/mod_wsgi_test/{apache_config}" + pgrep = which("pgrep") + if pgrep: + result = subprocess.run( # noqa: S603 + [pgrep, "-f", config_path], capture_output=True, text=True, check=False + ) + for line in result.stdout.split(): + try: + victim = int(line) + except ValueError: + continue + if pid_is_our_apache(victim, apache_config): + try: + os.kill(victim, signal.SIGTERM) + except (ProcessLookupError, PermissionError): + LOGGER.warning(f"Could not stop Apache pid {victim}") # Stopping returns before the port is released, which the next mode needs # to rebind.