Files
requirements/openstack_requirements/tests/test_check.py
Stephen Finucane 89bfb62554 project-requirements-change: Improve debug output
For the default path, this changes output from:

  Processing pyproject.toml (requirements)
  Processing requirements.txt (requirements)
  Processing test-requirements.txt (requirements)
  Processing doc/requirements.txt (requirements)
  Processing pyproject.toml (extras)
  Processing .[osprofiler]
  Processing .[zvm]
  Processing .[vmware]
  Validating pyproject.toml
  Validating requirements.txt
  Validating test-requirements.txt
  Validating doc/requirements.txt
  Validating osprofiler
  Validating zvm
  Validating vmware
  Updated requirements match openstack/requirements

to:

  Processing pyproject.toml (requirements)
  Processing requirements.txt (requirements)
  Processing test-requirements.txt (requirements)
  Processing doc/requirements.txt (requirements)
  Processing pyproject.toml (extras)
    Processing .[osprofiler]
    Processing .[zvm]
    Processing .[vmware]
  Validating pyproject.toml (dependencies)
  Validating requirements.txt (dependencies)
  Validating test-requirements.txt (dependencies)
  Validating doc/requirements.txt (dependencies)
  Validating pyproject.toml (.[osprofiler] extra)
  Validating pyproject.toml (.[zvm] extra)
  Validating pyproject.toml (.[vmware] extra)
  Updated requirements match openstack/requirements.

While for the error path, this changes the error output from:

  ERROR: Requirement for package oslo.vmware excludes a version not excluded in the global list.
    Local settings : {'!=0.0.1'}
    Global settings: set()
    Unexpected     : {'!=0.0.1'}
  *** Incompatible requirement found!
  *** See https://docs.openstack.org/requirements/latest/

to:

  ERROR: Requirement for package oslo.vmware excludes a version not excluded in the global list.
    Local settings : ['!=0.0.1']
    Global settings: []
    Unexpected     : ['!=0.0.1']
  *** Incompatible requirement found!
  *** See https://docs.openstack.org/requirements/latest/

Both of which look better and easier to grok, IMO.

Change-Id: I82fe39797ac857957c6b07cc90fc78bcae58628a
Signed-off-by: Stephen Finucane <stephenfin@redhat.com>
2025-11-19 18:48:03 +00:00

601 lines
20 KiB
Python

# Licensed 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.
import textwrap
from openstack_requirements import check
from openstack_requirements import requirement
import fixtures
import testtools
class TestRequirementsList(testtools.TestCase):
def setUp(self):
super().setUp()
self._stdout_fixture = fixtures.StringStream('stdout')
self.stdout = self.useFixture(self._stdout_fixture).stream
self.useFixture(fixtures.MonkeyPatch('sys.stdout', self.stdout))
def test_extras__setup_cfg(self):
project_data = {
'root': '/fake/root',
'requirements': {
'requirements.txt': ['requests>=2.0.0'],
},
'extras': {
'setup.cfg': {
'test': ['pytest>=6.0.0', 'flake8>=3.8.0'],
'dev': ['black>=24.0.0', 'mypy>=0.900'],
}
},
}
req_list = check.RequirementsList('test-project', project_data)
req_list.process(strict=False)
self.assertIn('setup.cfg (.[test] extra)', req_list.reqs_by_file)
self.assertIn('setup.cfg (.[dev] extra)', req_list.reqs_by_file)
test_reqs = req_list.reqs_by_file['setup.cfg (.[test] extra)']
dev_reqs = req_list.reqs_by_file['setup.cfg (.[dev] extra)']
self.assertEqual(len(test_reqs), 2)
self.assertIn('pytest', test_reqs)
self.assertIn('flake8', test_reqs)
self.assertEqual(len(dev_reqs), 2)
self.assertIn('black', dev_reqs)
self.assertIn('mypy', dev_reqs)
class TestIsReqInGlobalReqs(testtools.TestCase):
def setUp(self):
super().setUp()
self._stdout_fixture = fixtures.StringStream('stdout')
self.stdout = self.useFixture(self._stdout_fixture).stream
self.backports = list()
self.useFixture(fixtures.MonkeyPatch('sys.stdout', self.stdout))
self.global_reqs = check.get_global_reqs(
textwrap.dedent("""
name>=1.2,!=1.4
withmarker>=1.5;python_version=='3.5'
withmarker>=1.2,!=1.4;python_version=='2.7'
""")
)
def test_match(self):
"""Test a basic package."""
req = requirement.parse('name>=1.2,!=1.4')['name'][0][0]
self.assertTrue(
check._is_requirement_in_global_reqs(
req,
self.global_reqs['name'],
self.backports,
)
)
def test_match_with_markers(self):
"""Test a package specified with python 3 markers."""
req = requirement.parse(
textwrap.dedent("""
withmarker>=1.5;python_version=='3.5'
""")
)['withmarker'][0][0]
self.assertTrue(
check._is_requirement_in_global_reqs(
req,
self.global_reqs['withmarker'],
self.backports,
)
)
def test_match_with_local_markers(self):
"""Test a package specified with python 3 markers."""
req = requirement.parse(
textwrap.dedent("""
name;python_version=='3.5'
""")
)['name'][0][0]
self.assertTrue(
check._is_requirement_in_global_reqs(
req,
self.global_reqs['name'],
self.backports,
allow_3_only=True,
)
)
def test_match_without_python3_markers(self):
"""Test a package specified without python 3 markers.
Python 3 packages are a thing. On those, it's totally unnecessary to
specify e.g. a "python_version>'3" marker for packages.
"""
req = requirement.parse(
textwrap.dedent("""
withmarker>=1.5
""")
)['withmarker'][0][0]
self.assertTrue(
check._is_requirement_in_global_reqs(
req,
self.global_reqs['withmarker'],
self.backports,
allow_3_only=True,
)
)
def test_backport(self):
"""Test a stdlib backport pacakge.
The python_version marker should be ignored for stdlib backport-type
packages.
"""
req = requirement.parse("name;python_version<'3.9'")['name'][0][0]
self.assertTrue(
check._is_requirement_in_global_reqs(
req,
self.global_reqs['name'],
['name'],
)
)
def test_name_mismatch(self):
"""Test a mismatch in package names.
Obviously a package with a different name is not the same thing.
"""
req = requirement.parse('wrongname>=1.2,!=1.4')['wrongname'][0][0]
self.assertFalse(
check._is_requirement_in_global_reqs(
req,
self.global_reqs['name'],
self.backports,
)
)
def test_marker_mismatch(self):
"""Test a mismatch in markers.
This should be a failure since the only marker we allow to be different
is the python_version marker.
"""
req = requirement.parse("name; sys_platform == 'win32'")['name'][0][0]
self.assertFalse(
check._is_requirement_in_global_reqs(
req,
self.global_reqs['name'],
self.backports,
)
)
def test_min_mismatch(self):
"""Test a mismatch in minimum version.
We actually allow this since we only enforce a common upper constraint.
Packages can specify whatever minimum they like so long as it doesn't
exceed the upper-constraint value.
"""
req = requirement.parse('name>=1.3,!=1.4')['name'][0][0]
self.assertTrue(
check._is_requirement_in_global_reqs(
req,
self.global_reqs['name'],
self.backports,
)
)
def test_extra_exclusion(self):
"""Test that we validate exclusions.
A package can't exclude a version unless that is also excluded in
global requirements.
"""
req = requirement.parse('name>=1.2,!=1.4,!=1.5')['name'][0][0]
self.assertFalse(
check._is_requirement_in_global_reqs(
req,
self.global_reqs['name'],
self.backports,
)
)
def test_missing_exclusion(self):
"""Test that we ignore missing exclusions.
A package can specify fewer exclusions than global requirements.
"""
req = requirement.parse('name>=1.2')['name'][0][0]
self.assertTrue(
check._is_requirement_in_global_reqs(
req,
self.global_reqs['name'],
self.backports,
)
)
class TestGetExclusions(testtools.TestCase):
def test_none(self):
req = list(check.get_global_reqs('name>=1.2')['name'])[0]
self.assertEqual(
set(),
check._get_exclusions(req),
)
def test_one(self):
req = list(check.get_global_reqs('name>=1.2,!=1.4')['name'])[0]
self.assertEqual(
set(['!=1.4']),
check._get_exclusions(req),
)
def test_cap(self):
req = list(check.get_global_reqs('name>=1.2,!=1.4,<2.0')['name'])[0]
self.assertEqual(
set(['!=1.4', '<2.0']),
check._get_exclusions(req),
)
class TestValidateOne(testtools.TestCase):
def setUp(self):
super().setUp()
self._stdout_fixture = fixtures.StringStream('stdout')
self.stdout = self.useFixture(self._stdout_fixture).stream
self.useFixture(fixtures.MonkeyPatch('sys.stdout', self.stdout))
self.backports = dict()
def test_unchanged(self):
# If the line matches the value in the branch list everything
# is OK.
reqs = [r for r, line in requirement.parse('name>=1.2,!=1.4')['name']]
global_reqs = check.get_global_reqs('name>=1.2,!=1.4')
self.assertFalse(
check._validate_one(
'name',
reqs=reqs,
denylist=requirement.parse(''),
backports=self.backports,
global_reqs=global_reqs,
)
)
def test_denylisted(self):
# If the package is denylisted, everything is OK.
reqs = [r for r, line in requirement.parse('name>=1.2,!=1.4')['name']]
global_reqs = check.get_global_reqs('name>=1.2,!=1.4')
self.assertFalse(
check._validate_one(
'name',
reqs=reqs,
denylist=requirement.parse('name'),
backports=self.backports,
global_reqs=global_reqs,
)
)
def test_denylisted_mismatch(self):
# If the package is denylisted, it doesn't matter if the
# version matches.
reqs = [r for r, line in requirement.parse('name>=1.5')['name']]
global_reqs = check.get_global_reqs('name>=1.2,!=1.4')
self.assertFalse(
check._validate_one(
'name',
reqs=reqs,
denylist=requirement.parse('name'),
backports=self.backports,
global_reqs=global_reqs,
)
)
def test_not_in_global_list(self):
# If the package is not in the global list, that is an error.
reqs = [r for r, line in requirement.parse('name>=1.2,!=1.4')['name']]
global_reqs = check.get_global_reqs('')
self.assertTrue(
check._validate_one(
'name',
reqs=reqs,
denylist=requirement.parse(''),
backports=self.backports,
global_reqs=global_reqs,
)
)
def test_new_item_matches_global_list(self):
# If the new item matches the global list exactly that is OK.
reqs = [r for r, line in requirement.parse('name>=1.2,!=1.4')['name']]
global_reqs = check.get_global_reqs('name>=1.2,!=1.4')
self.assertFalse(
check._validate_one(
'name',
reqs=reqs,
denylist=requirement.parse(''),
backports=self.backports,
global_reqs=global_reqs,
)
)
def test_new_item_lower_min(self):
# If the new item has a lower minimum value than the global
# list, that is OK.
reqs = [r for r, line in requirement.parse('name>=1.1,!=1.4')['name']]
global_reqs = check.get_global_reqs('name>=1.2,!=1.4')
self.assertFalse(
check._validate_one(
'name',
reqs=reqs,
denylist=requirement.parse(''),
backports=self.backports,
global_reqs=global_reqs,
)
)
def test_new_item_extra_exclusion(self):
# If the new item includes an exclusion that is not present in
# the global list that is not OK.
reqs = [
r for r, line in requirement.parse('name>=1.2,!=1.4,!=1.5')['name']
]
global_reqs = check.get_global_reqs('name>=1.2,!=1.4')
self.assertTrue(
check._validate_one(
'name',
reqs=reqs,
denylist=requirement.parse(''),
backports=self.backports,
global_reqs=global_reqs,
)
)
def test_new_item_missing_exclusion(self):
# If the new item does not include an exclusion that is
# present in the global list that is OK.
reqs = [r for r, line in requirement.parse('name>=1.2')['name']]
global_reqs = check.get_global_reqs('name>=1.2,!=1.4')
self.assertFalse(
check._validate_one(
'name',
reqs=reqs,
denylist=requirement.parse(''),
backports=self.backports,
global_reqs=global_reqs,
)
)
def test_new_item_matches_global_list_with_extra(self):
# If the global list has multiple entries for an item with
# different "extra" specifiers, the values must all be in the
# requirements file.
r_content = textwrap.dedent("""
name>=1.5;python_version=='3.5'
name>=1.2,!=1.4;python_version=='2.6'
""")
reqs = [r for r, line in requirement.parse(r_content)['name']]
global_reqs = check.get_global_reqs(
textwrap.dedent("""
name>=1.5;python_version=='3.5'
name>=1.2,!=1.4;python_version=='2.6'
""")
)
self.assertFalse(
check._validate_one(
'name',
reqs=reqs,
denylist=requirement.parse(''),
backports=self.backports,
global_reqs=global_reqs,
)
)
def test_new_item_missing_extra_line(self):
# If the global list has multiple entries for an item with
# different "extra" specifiers, the values must all be in the
# requirements file.
r_content = textwrap.dedent("""
name>=1.2,!=1.4;python_version=='2.6'
""")
reqs = [r for r, line in requirement.parse(r_content)['name']]
global_reqs = check.get_global_reqs(
textwrap.dedent("""
name>=1.5;python_version=='3.5'
name>=1.2,!=1.4;python_version=='2.6'
""")
)
self.assertTrue(
check._validate_one(
'name',
reqs=reqs,
denylist=requirement.parse(''),
backports=self.backports,
global_reqs=global_reqs,
)
)
def test_new_item_mismatches_global_list_with_extra(self):
# If the global list has multiple entries for an item with
# different "extra" specifiers, the values must all be in the
# requirements file.
r_content = textwrap.dedent("""
name>=1.5;python_version=='3.6'
name>=1.2,!=1.4;python_version=='2.6'
""")
reqs = [r for r, line in requirement.parse(r_content)['name']]
global_reqs = check.get_global_reqs(
textwrap.dedent("""
name>=1.5;python_version=='3.5'
name>=1.2,!=1.4;python_version=='2.6'
""")
)
self.assertTrue(
check._validate_one(
'name',
reqs=reqs,
denylist=requirement.parse(''),
backports=self.backports,
global_reqs=global_reqs,
)
)
def test_new_item_matches_py3_allowed_no_version(self):
# If the global list has multiple entries for an item but the branch
# allows python 3 only, then only the py3 entries need to match.
# Requirements without a python_version marker should always be used.
r_content = textwrap.dedent("""
name>=1.5;python_version=='3.5'
other-name
""")
reqs = [r for r, line in requirement.parse(r_content)['name']]
global_reqs = check.get_global_reqs(
textwrap.dedent("""
name>=1.5;python_version=='3.5'
name>=1.2,!=1.4;python_version=='2.6'
other-name
""")
)
self.assertFalse(
check._validate_one(
'name',
reqs=reqs,
denylist=requirement.parse(''),
backports=self.backports,
global_reqs=global_reqs,
allow_3_only=True,
)
)
def test_new_item_matches_py3_allowed(self):
# If the global list has multiple entries for an item but the branch
# allows python 3 only, then only the py3 entries need to match.
# Requirements without a python_version marker should always be used.
r_content = textwrap.dedent("""
name>=1.5
other-name
""")
reqs = [r for r, line in requirement.parse(r_content)['name']]
global_reqs = check.get_global_reqs(
textwrap.dedent("""
name>=1.5;python_version>='3.5'
name>=1.2,!=1.4;python_version=='2.6'
other-name
""")
)
self.assertFalse(
check._validate_one(
'name',
reqs=reqs,
denylist=requirement.parse(''),
backports=self.backports,
global_reqs=global_reqs,
allow_3_only=True,
)
)
def test_new_item_matches_py3_allowed_with_py2(self):
# If the global list has multiple entries for an item but the branch
# allows python 3 only, then only the py3 entries need to match.
# It should continue to pass with py2 entries though.
r_content = textwrap.dedent("""
name>=1.5;python_version=='3.5'
name>=1.2,!=1.4;python_version=='2.6'
""")
reqs = [r for r, line in requirement.parse(r_content)['name']]
global_reqs = check.get_global_reqs(
textwrap.dedent("""
name>=1.5;python_version=='3.5'
name>=1.2,!=1.4;python_version=='2.6'
""")
)
self.assertFalse(
check._validate_one(
'name',
reqs=reqs,
denylist=requirement.parse(''),
backports=self.backports,
global_reqs=global_reqs,
allow_3_only=True,
)
)
def test_new_item_matches_py3_allowed_no_py2(self):
# If the global list has multiple entries for an item but the branch
# allows python 3 only, then only the py3 entries need to match.
r_content = textwrap.dedent("""
name>=1.5;python_version=='3.5'
""")
reqs = [r for r, line in requirement.parse(r_content)['name']]
global_reqs = check.get_global_reqs(
textwrap.dedent("""
name>=1.5;python_version=='3.5'
name>=1.2,!=1.4;python_version=='2.6'
""")
)
self.assertFalse(
check._validate_one(
'name',
reqs=reqs,
denylist=requirement.parse(''),
backports=self.backports,
global_reqs=global_reqs,
allow_3_only=True,
)
)
class TestBackportPythonMarkers(testtools.TestCase):
def setUp(self):
super().setUp()
self._stdout_fixture = fixtures.StringStream('stdout')
self.stdout = self.useFixture(self._stdout_fixture).stream
self.useFixture(fixtures.MonkeyPatch('sys.stdout', self.stdout))
self.req = requirement.parse(
textwrap.dedent("""
name>=1.5;python_version=='3.11'
""")
)['name'][0][0]
self.global_reqs = check.get_global_reqs(
textwrap.dedent("""
name>=1.5;python_version=='3.10'
""")
)
def test_notmatching_no_backport(self):
backports = requirement.parse("")
self.assertFalse(
check._is_requirement_in_global_reqs(
self.req,
self.global_reqs["name"],
list(backports.keys()),
allow_3_only=True,
)
)
def test_notmatching_with_backport(self):
b_content = textwrap.dedent("""
name
""")
backports = requirement.parse(b_content)
self.assertTrue(
check._is_requirement_in_global_reqs(
self.req,
self.global_reqs["name"],
list(backports.keys()),
allow_3_only=True,
)
)