commit edbbc5865a1b5fb5048cd59ecc1d570280db5ad5
parent 8fa480f29a0843345e6f117c1a2be6ff4d6b1795
Author: Andrew Halberstadt <ahal@mozilla.com>
Date: Fri, 14 Nov 2025 14:23:33 +0000
Bug 1999798 - Add tests and docs for new 'requirements-txt' specifier, r=ahochheiden
Differential Revision: https://phabricator.services.mozilla.com/D272489
Diffstat:
4 files changed, 198 insertions(+), 1 deletion(-)
diff --git a/python/docs/index.rst b/python/docs/index.rst
@@ -51,7 +51,17 @@ To add a ``pip install``-d package dependency, add it to your site's
...
pypi:new-package==<version>
- ...
+
+If you'd like to lock dependencies and validate hashes, you can alternatively specify a path
+to a ``requirements.txt`` file:
+
+.. code:: text
+
+ ...
+ requirements-txt:path/to/requirements.txt
+
+The ``requirements.txt`` file can be generated using any tool you like, but it must include
+hashes for all listed packages.
.. note::
diff --git a/python/mach/mach/test/test_site.py b/python/mach/mach/test/test_site.py
@@ -3,15 +3,22 @@
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
import os
+import subprocess
+import sys
from unittest import mock
import pytest
from buildconfig import topsrcdir
from mozunit import main
+from mach.requirements import MachEnvRequirements, RequirementsTxtSpecifier
from mach.site import (
PIP_NETWORK_INSTALL_RESTRICTED_VIRTUALENVS,
+ ExternalPythonSite,
+ MozSiteMetadata,
+ PythonVirtualenv,
SitePackagesSource,
+ _create_venv_with_pthfile,
resolve_requirements,
)
@@ -52,5 +59,108 @@ def test_all_restricted_sites_dont_have_pypi_requirements():
)
+@pytest.fixture
+def parse_requirements_txt(tmp_path):
+ """Fixture to test requirements-txt parsing."""
+
+ def inner(site_content, requirements_txt_content=None):
+ # Create site packages file
+ site_file = tmp_path / "test-site.txt"
+ site_file.write_text(site_content)
+
+ # Create requirements.txt if provided
+ if requirements_txt_content is not None:
+ req_file = tmp_path / "requirements.txt"
+ req_file.write_text(requirements_txt_content)
+
+ # Parse the site file
+ requirements = MachEnvRequirements.from_requirements_definition(
+ str(tmp_path),
+ is_thunderbird=False,
+ only_strict_requirements=False,
+ requirements_definition=str(site_file),
+ )
+ return requirements
+
+ return inner
+
+
+def test_requirements_txt_parsed(parse_requirements_txt):
+ requirements = parse_requirements_txt(
+ "requires-python:>=3.9\nrequirements-txt:requirements.txt\n",
+ "package1==1.0.0 --hash=sha256:abc123\npackage2==2.0.0 --hash=sha256:def456\n",
+ )
+
+ assert len(requirements.requirements_txt_files) == 1
+ assert "requirements.txt" in requirements.requirements_txt_files[0].path
+
+
+def test_requirements_txt_missing_file(parse_requirements_txt):
+ with pytest.raises(Exception, match="requirements.txt file not found"):
+ parse_requirements_txt(
+ "requires-python:>=3.8\nrequirements-txt:nonexistent.txt\n"
+ )
+
+
+@pytest.fixture
+def run_create_venv_with_pthfile(tmp_path):
+
+ def inner(requirements_content):
+ req_file = tmp_path / "requirements.txt"
+ req_file.write_text(requirements_content)
+
+ requirements = MachEnvRequirements()
+ requirements.requirements_txt_files.append(
+ RequirementsTxtSpecifier(str(req_file))
+ )
+
+ venv_root = tmp_path / "venv"
+ external_python = ExternalPythonSite(sys.executable)
+ metadata = MozSiteMetadata(
+ sys.hexversion,
+ "test",
+ SitePackagesSource.VENV,
+ external_python,
+ str(venv_root),
+ )
+
+ venv = PythonVirtualenv(str(venv_root))
+ _create_venv_with_pthfile(venv, [], True, requirements, metadata)
+
+ return venv
+
+ return inner
+
+
+def test_requirements_txt_install_requires_hashes(
+ monkeypatch, run_create_venv_with_pthfile
+):
+ monkeypatch.delenv("MACH_SHOW_PIP_OUTPUT", raising=False)
+
+ try:
+ run_create_venv_with_pthfile("certifi==2021.5.30\n")
+ pytest.fail("Expected CalledProcessError to be raised due to missing hashes")
+ except subprocess.CalledProcessError as e:
+ error_output = e.stderr if e.stderr else ""
+ assert (
+ "hash" in error_output.lower()
+ ), f"Expected hash error in stderr, got: {error_output}"
+
+
+def test_requirements_txt_installs_with_hashes(run_create_venv_with_pthfile):
+ requirements_content = (
+ "certifi==2021.5.30 "
+ "--hash=sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8\n"
+ )
+
+ venv = run_create_venv_with_pthfile(requirements_content)
+
+ site_packages = venv.resolve_sysconfig_packages_path("purelib")
+ assert os.path.exists(site_packages), f"site-packages not found: {site_packages}"
+
+ certifi_path = os.path.join(site_packages, "certifi")
+ assert os.path.exists(certifi_path), f"certifi package not found in {site_packages}"
+
+
if __name__ == "__main__":
main()
diff --git a/python/mozbuild/mozbuild/test/python.toml b/python/mozbuild/mozbuild/test/python.toml
@@ -113,6 +113,8 @@ subsuite = "mozbuild"
["test_rewrite_mozbuild.py"]
+["test_site_dependency_extractor.py"]
+
["test_telemetry.py"]
["test_telemetry_settings.py"]
diff --git a/python/mozbuild/mozbuild/test/test_site_dependency_extractor.py b/python/mozbuild/mozbuild/test/test_site_dependency_extractor.py
@@ -0,0 +1,75 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import pytest
+from mozunit import main
+
+from mozbuild.lockfiles.site_dependency_extractor import (
+ DependencyParseError,
+ SiteDependencyExtractor,
+)
+
+
+def test_requirements_txt_parsing(tmp_path):
+ sites_dir = tmp_path / "sites"
+ sites_dir.mkdir()
+
+ requirements_txt = tmp_path / "requirements.txt"
+ requirements_txt.write_text(
+ """# Comment line
+certifi==2021.5.30 --hash=sha256:abc123
+
+# Another comment
+urllib3==1.26.5 --hash=sha256:def456
+requests==2.26.0 --hash=sha256:ghi789
+
+# Empty line follows
+
+-r other-requirements.txt
+--index-url https://pypi.org/simple
+"""
+ )
+
+ site_file = sites_dir / "test-site.txt"
+ site_file.write_text(
+ """requires-python:>=3.8
+requirements-txt:requirements.txt
+"""
+ )
+
+ extractor = SiteDependencyExtractor("test-site", sites_dir, tmp_path)
+ requires_python, dependencies = extractor.parse()
+
+ assert requires_python == ">=3.8"
+ assert len(dependencies) == 3
+
+ dep_names = {dep.name for dep in dependencies}
+ assert "certifi" in dep_names
+ assert "urllib3" in dep_names
+ assert "requests" in dep_names
+
+ dep_dict = {dep.name: dep.version for dep in dependencies}
+ assert dep_dict["certifi"] == "==2021.5.30 --hash=sha256"
+ assert dep_dict["urllib3"] == "==1.26.5 --hash=sha256"
+ assert dep_dict["requests"] == "==2.26.0 --hash=sha256"
+
+
+def test_requirements_txt_missing_file(tmp_path):
+ sites_dir = tmp_path / "sites"
+ sites_dir.mkdir()
+
+ site_file = sites_dir / "test-site.txt"
+ site_file.write_text(
+ """requires-python:>=3.8
+requirements-txt:nonexistent.txt
+"""
+ )
+
+ extractor = SiteDependencyExtractor("test-site", sites_dir, tmp_path)
+ with pytest.raises(DependencyParseError, match="requirements.txt file not found"):
+ extractor.parse()
+
+
+if __name__ == "__main__":
+ main()