blob: 972177ba7c902de6bf79c3c35fdef8c83656b289 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Contracts for production and development dependency separation."""
from __future__ import annotations
import re
import tomllib
from pathlib import Path
import generate_requirements
from test.deployment import check_runtime_dependencies
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
PYPROJECT_PATH = REPOSITORY_ROOT / "pyproject.toml"
RUNTIME_REQUIREMENTS_PATH = REPOSITORY_ROOT / "requirements.txt"
DEV_REQUIREMENTS_PATH = REPOSITORY_ROOT / "requirements-dev.txt"
DEVELOPMENT_ONLY = {
*check_runtime_dependencies.DEVELOPMENT_ONLY_DISTRIBUTIONS,
"pandas-stubs",
"pytest-mock",
"pytest-xdist",
"toml",
"types-jsonschema",
}
REQUIRED_CI_TOOLS = {
"bandit",
"mypy",
"pytest",
"pytest-asyncio",
"pytest-cov",
"ruff",
}
SECURE_RUNTIME_FLOORS = {
"aiohttp>=3.14.3",
"aiomysql>=0.3.0",
"click>=8.3.3",
"cryptography>=50.0.0",
"filelock>=3.20.3,<4.0.0",
"orjson>=3.11.6",
"pyarrow>=23.0.1",
"Pygments>=2.20.0",
"PyJWT>=2.13.0",
"python-dotenv>=1.2.2",
"python-multipart>=0.0.31",
"requests>=2.33.0",
"sqlparse>=0.5.4",
"starlette>=1.3.1",
"urllib3>=2.7.0,<3.0.0",
}
def _dependency_name(requirement: str) -> str:
return re.split(r"[<>=!~;\[]", requirement, maxsplit=1)[0].strip().lower()
def _manifest_requirements(path: Path) -> list[str]:
return [
line
for raw_line in path.read_text(encoding="utf-8").splitlines()
if (line := raw_line.strip())
and not line.startswith("#")
and not line.startswith("-r ")
]
def _pyproject() -> dict:
return tomllib.loads(PYPROJECT_PATH.read_text(encoding="utf-8"))
def test_runtime_metadata_excludes_development_and_build_tools() -> None:
pyproject = _pyproject()
runtime = {
_dependency_name(requirement)
for requirement in pyproject["project"]["dependencies"]
}
assert runtime.isdisjoint(DEVELOPMENT_ONLY)
assert pyproject["build-system"]["requires"] == ["hatchling"]
def test_runtime_metadata_pins_known_vulnerability_floors() -> None:
runtime = set(_pyproject()["project"]["dependencies"])
assert SECURE_RUNTIME_FLOORS <= runtime
assert not any(
_dependency_name(requirement) in {"fastapi", "python-jose"}
for requirement in runtime
)
def test_ci_development_group_owns_test_and_quality_dependencies() -> None:
pyproject = _pyproject()
development = {
_dependency_name(requirement)
for requirement in pyproject["dependency-groups"]["dev"]
}
assert REQUIRED_CI_TOOLS <= development
assert development.isdisjoint(
{
_dependency_name(requirement)
for requirement in pyproject["project"]["dependencies"]
}
)
def test_generated_manifests_match_their_independent_dependency_layers() -> None:
pyproject = _pyproject()
assert (
_manifest_requirements(RUNTIME_REQUIREMENTS_PATH)
== pyproject["project"]["dependencies"]
)
assert (
_manifest_requirements(DEV_REQUIREMENTS_PATH)
== pyproject["dependency-groups"]["dev"]
)
assert "-r requirements.txt" in DEV_REQUIREMENTS_PATH.read_text(encoding="utf-8")
assert generate_requirements.verify_consistency(
PYPROJECT_PATH,
RUNTIME_REQUIREMENTS_PATH,
DEV_REQUIREMENTS_PATH,
)
def test_requirement_generator_is_reproducible(tmp_path: Path) -> None:
runtime_path = tmp_path / "requirements.txt"
dev_path = tmp_path / "requirements-dev.txt"
generate_requirements.generate_requirements(PYPROJECT_PATH, runtime_path)
generate_requirements.generate_requirements_dev(PYPROJECT_PATH, dev_path)
assert runtime_path.read_bytes() == RUNTIME_REQUIREMENTS_PATH.read_bytes()
assert dev_path.read_bytes() == DEV_REQUIREMENTS_PATH.read_bytes()
def test_consistency_rejects_cross_layer_package_with_different_specifiers(
tmp_path: Path,
capsys,
) -> None:
project_path = tmp_path / "pyproject.toml"
runtime_path = tmp_path / "requirements.txt"
dev_path = tmp_path / "requirements-dev.txt"
project_path.write_text(
'[project]\ndependencies = ["shared>=1"]\n'
'[dependency-groups]\ndev = ["shared>=2"]\n',
encoding="utf-8",
)
runtime_path.write_text("shared>=1\n", encoding="utf-8")
dev_path.write_text("-r requirements.txt\nshared>=2\n", encoding="utf-8")
assert not generate_requirements.verify_consistency(
project_path,
runtime_path,
dev_path,
)
assert "runtime/development overlap: ['shared']" in capsys.readouterr().out
def test_runtime_checker_reports_only_installed_development_packages(
monkeypatch,
) -> None:
def fake_version(distribution_name: str) -> str:
if distribution_name == "pytest":
return "9.0.0"
raise check_runtime_dependencies.PackageNotFoundError
monkeypatch.setattr(check_runtime_dependencies, "version", fake_version)
assert check_runtime_dependencies.installed_development_packages() == {
"pytest": "9.0.0"
}