dependabot-python 0.368.0 → 0.369.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/helpers/build +4 -0
  3. data/helpers/requirements.txt +1 -0
  4. data/helpers/test/fixtures/no_dependencies.toml +3 -0
  5. data/helpers/test/fixtures/pep621_arbitrary_equality.toml +7 -0
  6. data/helpers/test/fixtures/pep621_dependencies.toml +21 -0
  7. data/helpers/test/fixtures/pep621_empty_deps.toml +8 -0
  8. data/helpers/test/fixtures/pep621_extras.toml +8 -0
  9. data/helpers/test/fixtures/pep621_markers.toml +7 -0
  10. data/helpers/test/fixtures/pep621_multiple_extras.toml +7 -0
  11. data/helpers/test/fixtures/pep621_no_version.toml +8 -0
  12. data/helpers/test/fixtures/pep621_only_build_system.toml +3 -0
  13. data/helpers/test/fixtures/pep735_cycle.toml +13 -0
  14. data/helpers/test/fixtures/pep735_dependency_groups.toml +18 -0
  15. data/helpers/test/fixtures/requirements/constraints.txt +1 -0
  16. data/helpers/test/fixtures/requirements/markers.txt +1 -0
  17. data/helpers/test/fixtures/requirements/requirements-dev.txt +2 -0
  18. data/helpers/test/fixtures/requirements/requirements.txt +5 -0
  19. data/helpers/test/fixtures/requirements/with_constraints.txt +2 -0
  20. data/helpers/test/fixtures/requirements_empty/.gitkeep +0 -0
  21. data/helpers/test/fixtures/setup_cfg/setup.cfg +16 -0
  22. data/helpers/test/fixtures/setup_py/setup.py +20 -0
  23. data/helpers/test/fixtures/setup_py_comments/setup.py +9 -0
  24. data/helpers/test/test_hasher.py +114 -0
  25. data/helpers/test/test_parse_requirements.py +103 -0
  26. data/helpers/test/test_parse_setup.py +127 -0
  27. data/helpers/test/test_parser.py +184 -0
  28. data/helpers/test/test_run.py +49 -0
  29. data/lib/dependabot/python/file_updater/poetry_file_updater.rb +16 -11
  30. data/lib/dependabot/python/file_updater/pyproject_preparer.rb +109 -0
  31. data/lib/dependabot/python/update_checker/latest_version_finder.rb +4 -2
  32. metadata +29 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5b03d84191a73f9cd803b3db1fd32bc70638a4f607c2e9424a3900f143119f5f
4
- data.tar.gz: 6d04a235c38eb76a1a86eb2f60e2f34c80c82ff5f342d997ff3c8a25011e42f3
3
+ metadata.gz: 64cdd01e07bc2f6472a1027884006af4c7df3648168f04135c7b91cdf5530f14
4
+ data.tar.gz: b5610dc0420858f42cb23a8462abcd7ebd4b9b1aab8be3ca28233a99c239982c
5
5
  SHA512:
6
- metadata.gz: 793a849e50f2162b65f1da65521dfb7450081b84507fbb4134145ab5c72b63a916a59c68602bf41dea530d373cb0d2dd5e9a38fee9b6a5347b17848a8891054f
7
- data.tar.gz: db96c2fd7be7c4b6ac473f29dc429f3c4163fb2dd38d4b1d48c19ea62727022ff6822166dd10e9c4a680eae24bc5f16f447bf9f8ec55b7909b8ae663698b29c6
6
+ metadata.gz: f4cb556708bba8d54dc3d04495d021e4808cf5651184ed9fe2d1b65d6b1114c688f92d9be8ab8fae6e96d818643ce4c4824db5971dc2fabf55370b6ffff26f39
7
+ data.tar.gz: 64514c6fad289e34af6bdbaeecc25f8ff815ba193d77088a86fa3399c9379ab1cad740ff0f18ae0ac176e85375714e15f36ceae638b96d6e2714f425ce86cd96
data/helpers/build CHANGED
@@ -17,6 +17,10 @@ cp -r \
17
17
  "$helpers_dir/requirements.txt" \
18
18
  "$install_dir"
19
19
 
20
+ if [ -d "$helpers_dir/test" ]; then
21
+ cp -r "$helpers_dir/test" "$install_dir"
22
+ fi
23
+
20
24
  cd "$install_dir"
21
25
  PYENV_VERSION=$1 pyenv exec pip3 --disable-pip-version-check install --use-pep517 -r "requirements.txt"
22
26
 
@@ -5,6 +5,7 @@ hashin==1.0.5
5
5
  pipenv==2024.4.1
6
6
  plette==2.1.0
7
7
  poetry==2.2.1
8
+ pytest==8.3.5
8
9
  # TODO: Replace 3p package `tomli` with 3.11's new stdlib `tomllib` once we drop support for Python 3.10.
9
10
  tomli==2.2.1
10
11
 
@@ -0,0 +1,3 @@
1
+ [project]
2
+ name = "myapp"
3
+ version = "1.0.0"
@@ -0,0 +1,7 @@
1
+ [project]
2
+ name = "myapp"
3
+ version = "1.0.0"
4
+
5
+ dependencies = [
6
+ "numpy===1.24.0rc1",
7
+ ]
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "myapp"
3
+ version = "1.0.0"
4
+ requires-python = ">=3.10"
5
+
6
+ dependencies = [
7
+ "requests>=2.13.0,<3.0",
8
+ "urllib3==1.26.0",
9
+ ]
10
+
11
+ [project.optional-dependencies]
12
+ socks = [
13
+ "PySocks >= 1.5.6, != 1.5.7, < 2",
14
+ ]
15
+ tests = [
16
+ "pytest >= 7.0, < 8",
17
+ ]
18
+
19
+ [build-system]
20
+ requires = ["setuptools>=68.0"]
21
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,8 @@
1
+ [project]
2
+ name = "myapp"
3
+ version = "1.0.0"
4
+
5
+ dependencies = []
6
+
7
+ [project.optional-dependencies]
8
+ dev = []
@@ -0,0 +1,8 @@
1
+ [project]
2
+ name = "myapp"
3
+ version = "1.0.0"
4
+ requires-python = ">=3.10"
5
+
6
+ dependencies = [
7
+ "cachecontrol[filecache]>=0.14.0",
8
+ ]
@@ -0,0 +1,7 @@
1
+ [project]
2
+ name = "myapp"
3
+ version = "1.0.0"
4
+
5
+ dependencies = [
6
+ "requests>=2.13.0; python_version >= '3.8'",
7
+ ]
@@ -0,0 +1,7 @@
1
+ [project]
2
+ name = "myapp"
3
+ version = "1.0.0"
4
+
5
+ dependencies = [
6
+ "boto3[crt,s3]>=1.28.0",
7
+ ]
@@ -0,0 +1,8 @@
1
+ [project]
2
+ name = "myapp"
3
+ version = "1.0.0"
4
+
5
+ dependencies = [
6
+ "requests",
7
+ "flask",
8
+ ]
@@ -0,0 +1,3 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,13 @@
1
+ [project]
2
+ name = "myapp"
3
+ version = "1.0.0"
4
+
5
+ [dependency-groups]
6
+ a = [
7
+ {include-group = "b"},
8
+ "requests>=2.0",
9
+ ]
10
+ b = [
11
+ {include-group = "a"},
12
+ "flask>=2.0",
13
+ ]
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "myapp"
3
+ version = "1.0.0"
4
+ requires-python = ">=3.10"
5
+
6
+ dependencies = [
7
+ "requests>=2.13.0",
8
+ ]
9
+
10
+ [dependency-groups]
11
+ dev = [
12
+ "pytest==7.1.3",
13
+ "black==22.10.0",
14
+ ]
15
+ lint = [
16
+ {include-group = "dev"},
17
+ "flake8>=5.0",
18
+ ]
@@ -0,0 +1 @@
1
+ requests<3.0
@@ -0,0 +1 @@
1
+ pywin32>=1.0; sys_platform == "win32"
@@ -0,0 +1,2 @@
1
+ pytest>=7.0
2
+ black==22.10.0
@@ -0,0 +1,5 @@
1
+ # Basic requirements file
2
+ requests>=2.13.0,<3.0
3
+ urllib3==1.26.0
4
+ Flask[async]>=2.0
5
+ boto3 # AWS SDK
@@ -0,0 +1,2 @@
1
+ -c constraints.txt
2
+ requests>=2.13.0
File without changes
@@ -0,0 +1,16 @@
1
+ [metadata]
2
+ name = myapp
3
+ version = 1.0.0
4
+
5
+ [options]
6
+ install_requires =
7
+ requests>=2.13.0
8
+ urllib3==1.26.0
9
+ setup_requires =
10
+ setuptools>=68.0
11
+ tests_require =
12
+ pytest>=7.0
13
+
14
+ [options.extras_require]
15
+ socks =
16
+ PySocks>=1.5.6
@@ -0,0 +1,20 @@
1
+ from setuptools import setup
2
+
3
+ setup(
4
+ name="myapp",
5
+ version="1.0.0",
6
+ install_requires=[
7
+ "requests>=2.13.0",
8
+ "urllib3==1.26.0",
9
+ ],
10
+ setup_requires=[
11
+ "setuptools>=68.0",
12
+ ],
13
+ tests_require=[
14
+ "pytest>=7.0",
15
+ ],
16
+ extras_require={
17
+ "socks": ["PySocks>=1.5.6"],
18
+ "dev": ["black==22.10.0"],
19
+ },
20
+ )
@@ -0,0 +1,9 @@
1
+ from setuptools import setup
2
+
3
+ setup(
4
+ name="myapp",
5
+ version="1.0.0",
6
+ install_requires=[
7
+ "requests>=2.13.0 # HTTP library",
8
+ ],
9
+ )
@@ -0,0 +1,114 @@
1
+ import json
2
+ import os
3
+ import ssl
4
+ import sys
5
+ from unittest.mock import MagicMock, patch
6
+ from urllib.error import URLError
7
+
8
+ sys.path.insert(
9
+ 0, os.path.join(os.path.dirname(__file__), os.pardir, "lib")
10
+ )
11
+
12
+ import hasher # noqa: E402
13
+ import hashin as hashin_mod # noqa: E402
14
+
15
+
16
+ class TestGetDependencyHash:
17
+ @patch("hasher.hashin.get_package_hashes")
18
+ def test_returns_hashes(self, mock_get):
19
+ mock_get.return_value = {
20
+ "hashes": [
21
+ {"hash": "abc123", "platform": "linux"},
22
+ {"hash": "def456", "platform": "macos"},
23
+ ]
24
+ }
25
+
26
+ result = json.loads(hasher.get_dependency_hash(
27
+ "requests", "2.28.0", "sha256"
28
+ ))
29
+
30
+ assert "result" in result
31
+ assert len(result["result"]) == 2
32
+ assert result["result"][0]["hash"] == "abc123"
33
+ mock_get.assert_called_once()
34
+
35
+ @patch("hasher.hashin.get_package_hashes")
36
+ def test_custom_index_url(self, mock_get):
37
+ mock_get.return_value = {"hashes": []}
38
+
39
+ hasher.get_dependency_hash(
40
+ "requests", "2.28.0", "sha256",
41
+ index_url="https://custom.registry/simple/"
42
+ )
43
+
44
+ mock_get.assert_called_once_with(
45
+ "requests",
46
+ version="2.28.0",
47
+ algorithm="sha256",
48
+ index_url="https://custom.registry/simple/"
49
+ )
50
+
51
+ @patch("hasher.hashin.get_package_hashes")
52
+ def test_package_not_found(self, mock_get):
53
+ mock_get.side_effect = hashin_mod.PackageNotFoundError(
54
+ "no-such-package"
55
+ )
56
+
57
+ result = json.loads(hasher.get_dependency_hash(
58
+ "no-such-package", "1.0.0", "sha256"
59
+ ))
60
+
61
+ assert "error" in result
62
+
63
+ @patch("hasher.hashin.get_package_hashes")
64
+ def test_ssl_certificate_error(self, mock_get):
65
+ ssl_error = ssl.SSLError(
66
+ "CERTIFICATE_VERIFY_FAILED: unable to get local issuer"
67
+ )
68
+ mock_get.side_effect = URLError(ssl_error)
69
+
70
+ result = json.loads(hasher.get_dependency_hash(
71
+ "requests", "2.28.0", "sha256"
72
+ ))
73
+
74
+ assert "error" in result
75
+ assert "CERTIFICATE_VERIFY_FAILED" in result["error"]
76
+
77
+ @patch("hasher.hashin.get_package_hashes")
78
+ def test_non_ssl_url_error_raises(self, mock_get):
79
+ mock_get.side_effect = URLError("Connection refused")
80
+
81
+ try:
82
+ hasher.get_dependency_hash("requests", "2.28.0", "sha256")
83
+ assert False, "Expected URLError to be raised"
84
+ except URLError:
85
+ pass # expected
86
+
87
+
88
+ class TestGetPipfileHash:
89
+ @patch("builtins.open")
90
+ @patch("hasher.plette")
91
+ def test_returns_hash(self, mock_plette, mock_open):
92
+ mock_pipfile = MagicMock()
93
+ mock_pipfile.get_hash.return_value.value = "abc123hash"
94
+ mock_plette.Pipfile.load.return_value = mock_pipfile
95
+
96
+ result = json.loads(hasher.get_pipfile_hash("/tmp/project"))
97
+
98
+ assert result["result"] == "abc123hash"
99
+ mock_open.assert_called_once_with("/tmp/project/Pipfile")
100
+
101
+
102
+ class TestGetPyprojectHash:
103
+ @patch("hasher.Factory")
104
+ def test_returns_hash(self, mock_factory_cls):
105
+ mock_poetry = MagicMock()
106
+ mock_poetry.locker._get_content_hash.return_value = "xyz789hash"
107
+ mock_factory_cls.return_value.create_poetry.return_value = mock_poetry
108
+
109
+ result = json.loads(hasher.get_pyproject_hash("/tmp/project"))
110
+
111
+ assert result["result"] == "xyz789hash"
112
+ mock_factory_cls.return_value.create_poetry.assert_called_once_with(
113
+ "/tmp/project"
114
+ )
@@ -0,0 +1,103 @@
1
+ import json
2
+ import os
3
+ import sys
4
+
5
+ sys.path.insert(
6
+ 0, os.path.join(os.path.dirname(__file__), os.pardir, "lib")
7
+ )
8
+
9
+ from parser import parse_requirements # noqa: E402
10
+
11
+ FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures")
12
+
13
+
14
+ def parse(fixture_dir):
15
+ path = os.path.join(FIXTURES, fixture_dir)
16
+ result = json.loads(parse_requirements(path))
17
+ return result
18
+
19
+
20
+ def find_dep(deps, name):
21
+ return next((d for d in deps if d["name"] == name), None)
22
+
23
+
24
+ def find_dep_in_file(deps, name, file_substring):
25
+ return next(
26
+ (d for d in deps
27
+ if d["name"] == name and file_substring in d["file"]),
28
+ None
29
+ )
30
+
31
+
32
+ class TestParseRequirementsTxt:
33
+ def test_parses_basic_requirements(self):
34
+ result = parse("requirements")
35
+ deps = result["result"]
36
+ requests = find_dep_in_file(deps, "requests", "requirements.txt")
37
+ assert requests is not None
38
+ assert requests["requirement"] == "<3.0,>=2.13.0"
39
+
40
+ def test_parses_pinned_version(self):
41
+ result = parse("requirements")
42
+ deps = result["result"]
43
+ urllib3 = find_dep(deps, "urllib3")
44
+ assert urllib3 is not None
45
+ assert urllib3["version"] == "1.26.0"
46
+ assert urllib3["requirement"] == "==1.26.0"
47
+
48
+ def test_parses_extras(self):
49
+ result = parse("requirements")
50
+ deps = result["result"]
51
+ flask = find_dep(deps, "Flask")
52
+ assert flask is not None
53
+ assert flask["extras"] == ["async"]
54
+
55
+ def test_strips_inline_comments(self):
56
+ result = parse("requirements")
57
+ deps = result["result"]
58
+ boto3 = find_dep(deps, "boto3")
59
+ assert boto3 is not None
60
+ # boto3 has no version specifier, just a comment
61
+ assert boto3["requirement"] is None or boto3["requirement"] == ""
62
+
63
+ def test_file_path_is_relative(self):
64
+ result = parse("requirements")
65
+ deps = result["result"]
66
+ for dep in deps:
67
+ assert not os.path.isabs(dep["file"])
68
+
69
+ def test_parses_dev_requirements(self):
70
+ result = parse("requirements")
71
+ deps = result["result"]
72
+ black = find_dep(deps, "black")
73
+ assert black is not None
74
+ assert black["version"] == "22.10.0"
75
+ assert "requirements-dev.txt" in black["file"]
76
+
77
+ def test_parses_markers(self):
78
+ result = parse("requirements")
79
+ deps = result["result"]
80
+ pywin32 = find_dep(deps, "pywin32")
81
+ assert pywin32 is not None
82
+ assert pywin32["markers"] == 'sys_platform == "win32"'
83
+
84
+ def test_empty_directory_returns_empty(self):
85
+ result = parse("requirements_empty")
86
+ deps = result["result"]
87
+ assert deps == []
88
+
89
+ def test_constraint_file_deps(self):
90
+ """Requirements with -c constraints should still parse the deps."""
91
+ result = parse("requirements")
92
+ deps = result["result"]
93
+ # with_constraints.txt has requests
94
+ req_files = [d["file"] for d in deps if d["name"] == "requests"]
95
+ assert any("with_constraints" in f for f in req_files)
96
+
97
+ def test_multiple_files_parsed(self):
98
+ """All .txt files in the directory are parsed."""
99
+ result = parse("requirements")
100
+ deps = result["result"]
101
+ files = {d["file"] for d in deps}
102
+ assert any("requirements.txt" in f for f in files)
103
+ assert any("requirements-dev.txt" in f for f in files)
@@ -0,0 +1,127 @@
1
+ import json
2
+ import os
3
+ import sys
4
+
5
+ sys.path.insert(
6
+ 0, os.path.join(os.path.dirname(__file__), os.pardir, "lib")
7
+ )
8
+
9
+ from parser import parse_setup # noqa: E402
10
+
11
+ FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures")
12
+
13
+
14
+ def parse(fixture_dir):
15
+ path = os.path.join(FIXTURES, fixture_dir)
16
+ result = json.loads(parse_setup(path))
17
+ return result
18
+
19
+
20
+ def find_dep(deps, name):
21
+ return next((d for d in deps if d["name"] == name), None)
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # setup.py parsing
26
+ # ---------------------------------------------------------------------------
27
+ class TestSetupPy:
28
+ def test_parses_install_requires(self):
29
+ result = parse("setup_py")
30
+ deps = result["result"]
31
+ requests = find_dep(deps, "requests")
32
+ assert requests is not None
33
+ assert requests["requirement"] == ">=2.13.0"
34
+ assert requests["requirement_type"] == "install_requires"
35
+ assert requests["file"] == "setup.py"
36
+
37
+ def test_parses_pinned_version(self):
38
+ result = parse("setup_py")
39
+ deps = result["result"]
40
+ urllib3 = find_dep(deps, "urllib3")
41
+ assert urllib3 is not None
42
+ assert urllib3["version"] == "1.26.0"
43
+
44
+ def test_parses_setup_requires(self):
45
+ result = parse("setup_py")
46
+ deps = result["result"]
47
+ setuptools = find_dep(deps, "setuptools")
48
+ assert setuptools is not None
49
+ assert setuptools["requirement_type"] == "setup_requires"
50
+
51
+ def test_parses_tests_require(self):
52
+ result = parse("setup_py")
53
+ deps = result["result"]
54
+ pytest = find_dep(deps, "pytest")
55
+ assert pytest is not None
56
+ assert pytest["requirement_type"] == "tests_require"
57
+
58
+ def test_parses_extras_require(self):
59
+ result = parse("setup_py")
60
+ deps = result["result"]
61
+ pysocks = find_dep(deps, "PySocks")
62
+ assert pysocks is not None
63
+ assert pysocks["requirement_type"] == "extras_require:socks"
64
+
65
+ def test_parses_multiple_extras_groups(self):
66
+ result = parse("setup_py")
67
+ deps = result["result"]
68
+ extras = [d for d in deps if d["requirement_type"].startswith(
69
+ "extras_require:"
70
+ )]
71
+ groups = {d["requirement_type"] for d in extras}
72
+ assert "extras_require:socks" in groups
73
+ assert "extras_require:dev" in groups
74
+
75
+ def test_strips_comments(self):
76
+ result = parse("setup_py_comments")
77
+ deps = result["result"]
78
+ requests = find_dep(deps, "requests")
79
+ assert requests is not None
80
+ assert requests["requirement"] == ">=2.13.0"
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # setup.cfg parsing
85
+ # ---------------------------------------------------------------------------
86
+ class TestSetupCfg:
87
+ def test_parses_install_requires(self):
88
+ result = parse("setup_cfg")
89
+ deps = result["result"]
90
+ requests = find_dep(deps, "requests")
91
+ assert requests is not None
92
+ assert requests["requirement"] == ">=2.13.0"
93
+ assert requests["requirement_type"] == "install_requires"
94
+ assert requests["file"] == "setup.cfg"
95
+
96
+ def test_parses_pinned_version(self):
97
+ result = parse("setup_cfg")
98
+ deps = result["result"]
99
+ urllib3 = find_dep(deps, "urllib3")
100
+ assert urllib3 is not None
101
+ assert urllib3["version"] == "1.26.0"
102
+
103
+ def test_parses_setup_requires(self):
104
+ result = parse("setup_cfg")
105
+ deps = result["result"]
106
+ setuptools = find_dep(deps, "setuptools")
107
+ assert setuptools is not None
108
+ assert setuptools["requirement_type"] == "setup_requires"
109
+
110
+ def test_parses_tests_require(self):
111
+ result = parse("setup_cfg")
112
+ deps = result["result"]
113
+ pytest = find_dep(deps, "pytest")
114
+ assert pytest is not None
115
+ assert pytest["requirement_type"] == "tests_require"
116
+
117
+ def test_parses_extras_require(self):
118
+ result = parse("setup_cfg")
119
+ deps = result["result"]
120
+ pysocks = find_dep(deps, "PySocks")
121
+ assert pysocks is not None
122
+ assert pysocks["requirement_type"] == "extras_require:socks"
123
+
124
+ def test_empty_directory_returns_empty(self):
125
+ result = parse("requirements_empty")
126
+ deps = result["result"]
127
+ assert deps == []
@@ -0,0 +1,184 @@
1
+ import json
2
+ import os
3
+ import sys
4
+
5
+ # Add the helpers lib directory to the Python path so we can import parser
6
+ sys.path.insert(
7
+ 0, os.path.join(os.path.dirname(__file__), os.pardir, "lib")
8
+ )
9
+
10
+ from parser import parse_pep621_pep735_dependencies # noqa: E402
11
+
12
+ FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures")
13
+
14
+
15
+ def parse(fixture_name):
16
+ path = os.path.join(FIXTURES, fixture_name)
17
+ result = json.loads(parse_pep621_pep735_dependencies(path))
18
+ return result["result"]
19
+
20
+
21
+ def find_dep(deps, name):
22
+ return next((d for d in deps if d["name"] == name), None)
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # PEP 621 project.dependencies
27
+ # ---------------------------------------------------------------------------
28
+ class TestPep621Dependencies:
29
+ def test_parses_runtime_dependencies(self):
30
+ deps = parse("pep621_dependencies.toml")
31
+ requests = find_dep(deps, "requests")
32
+ assert requests is not None
33
+ # packaging normalises specifier order alphabetically by operator
34
+ assert requests["requirement"] == "<3.0,>=2.13.0"
35
+ assert requests["requirement_type"] == "dependencies"
36
+
37
+ def test_parses_exact_version(self):
38
+ deps = parse("pep621_dependencies.toml")
39
+ urllib3 = find_dep(deps, "urllib3")
40
+ assert urllib3 is not None
41
+ assert urllib3["version"] == "1.26.0"
42
+ assert urllib3["requirement"] == "==1.26.0"
43
+
44
+ def test_parses_optional_dependencies(self):
45
+ deps = parse("pep621_dependencies.toml")
46
+ pysocks = find_dep(deps, "PySocks")
47
+ assert pysocks is not None
48
+ assert pysocks["requirement_type"] == "socks"
49
+
50
+ def test_optional_dependency_specifiers(self):
51
+ deps = parse("pep621_dependencies.toml")
52
+ pysocks = find_dep(deps, "PySocks")
53
+ # packaging normalises specifiers: sorted, no spaces
54
+ assert "!=" in pysocks["requirement"]
55
+ assert ">=" in pysocks["requirement"]
56
+
57
+ def test_parses_multiple_optional_groups(self):
58
+ deps = parse("pep621_dependencies.toml")
59
+ group_types = {d["requirement_type"] for d in deps}
60
+ assert "socks" in group_types
61
+ assert "tests" in group_types
62
+
63
+ def test_parses_build_system_requires(self):
64
+ deps = parse("pep621_dependencies.toml")
65
+ setuptools = find_dep(deps, "setuptools")
66
+ assert setuptools is not None
67
+ assert setuptools["requirement_type"] == "build-system.requires"
68
+ assert setuptools["requirement"] == ">=68.0"
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # PEP 621 extras
73
+ # ---------------------------------------------------------------------------
74
+ class TestPep621Extras:
75
+ def test_parses_extras(self):
76
+ deps = parse("pep621_extras.toml")
77
+ cc = find_dep(deps, "cachecontrol")
78
+ assert cc is not None
79
+ assert cc["extras"] == ["filecache"]
80
+
81
+ def test_extras_requirement(self):
82
+ deps = parse("pep621_extras.toml")
83
+ cc = find_dep(deps, "cachecontrol")
84
+ assert cc["requirement"] == ">=0.14.0"
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # PEP 735 dependency-groups
89
+ # ---------------------------------------------------------------------------
90
+ class TestPep735DependencyGroups:
91
+ def test_parses_dependency_group(self):
92
+ deps = parse("pep735_dependency_groups.toml")
93
+ pytest_dep = find_dep(deps, "pytest")
94
+ assert pytest_dep is not None
95
+ assert pytest_dep["requirement_type"] == "dev"
96
+ assert pytest_dep["version"] == "7.1.3"
97
+
98
+ def test_include_group_resolves(self):
99
+ deps = parse("pep735_dependency_groups.toml")
100
+ # "lint" group includes "dev" via include-group, plus flake8
101
+ lint_deps = [d for d in deps if d["requirement_type"] == "lint"]
102
+ lint_names = {d["name"] for d in lint_deps}
103
+ assert "flake8" in lint_names
104
+ # include-group pulls dev deps into lint but they keep their
105
+ # original group name, so they appear under "dev" requirement_type
106
+ all_names = {d["name"] for d in deps}
107
+ assert "pytest" in all_names
108
+ assert "black" in all_names
109
+
110
+ def test_include_group_lists_all_resolved_deps(self):
111
+ deps = parse("pep735_dependency_groups.toml")
112
+ # pytest appears twice: once from direct "dev" processing and
113
+ # once from "lint" including "dev" (both with requirement_type="dev"
114
+ # because included deps keep their original group name)
115
+ pytests = [d for d in deps if d["name"] == "pytest"]
116
+ assert len(pytests) == 2
117
+ assert all(d["requirement_type"] == "dev" for d in pytests)
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Markers
122
+ # ---------------------------------------------------------------------------
123
+ class TestMarkers:
124
+ def test_parses_markers(self):
125
+ deps = parse("pep621_markers.toml")
126
+ requests = find_dep(deps, "requests")
127
+ assert requests is not None
128
+ assert requests["markers"] == 'python_version >= "3.8"'
129
+
130
+ def test_requirement_with_markers(self):
131
+ deps = parse("pep621_markers.toml")
132
+ requests = find_dep(deps, "requests")
133
+ assert requests["requirement"] == ">=2.13.0"
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # Edge cases
138
+ # ---------------------------------------------------------------------------
139
+ class TestEdgeCases:
140
+ def test_no_dependencies(self):
141
+ deps = parse("no_dependencies.toml")
142
+ assert deps == []
143
+
144
+ def test_only_build_system(self):
145
+ deps = parse("pep621_only_build_system.toml")
146
+ assert len(deps) == 2
147
+ names = {d["name"] for d in deps}
148
+ assert "setuptools" in names
149
+ assert "wheel" in names
150
+ assert all(
151
+ d["requirement_type"] == "build-system.requires" for d in deps
152
+ )
153
+
154
+ def test_empty_dependency_lists(self):
155
+ deps = parse("pep621_empty_deps.toml")
156
+ assert deps == []
157
+
158
+ def test_multiple_extras_on_single_dep(self):
159
+ deps = parse("pep621_multiple_extras.toml")
160
+ boto3 = find_dep(deps, "boto3")
161
+ assert boto3 is not None
162
+ assert boto3["extras"] == ["crt", "s3"]
163
+ assert boto3["requirement"] == ">=1.28.0"
164
+
165
+ def test_arbitrary_equality_operator(self):
166
+ deps = parse("pep621_arbitrary_equality.toml")
167
+ numpy = find_dep(deps, "numpy")
168
+ assert numpy is not None
169
+ assert numpy["version"] == "1.24.0rc1"
170
+ assert numpy["requirement"] == "===1.24.0rc1"
171
+
172
+ def test_no_version_specifier(self):
173
+ deps = parse("pep621_no_version.toml")
174
+ requests = find_dep(deps, "requests")
175
+ assert requests is not None
176
+ assert requests["version"] is None
177
+ assert requests["requirement"] == ""
178
+
179
+ def test_cyclic_include_group_does_not_loop(self):
180
+ deps = parse("pep735_cycle.toml")
181
+ names = [d["name"] for d in deps]
182
+ # Both groups should be processed without infinite recursion
183
+ assert "requests" in names
184
+ assert "flask" in names
@@ -0,0 +1,49 @@
1
+ import json
2
+ import os
3
+ import subprocess
4
+ import sys
5
+
6
+ FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures")
7
+ HELPERS_DIR = os.path.join(os.path.dirname(__file__), os.pardir)
8
+ RUN_PY = os.path.join(HELPERS_DIR, "run.py")
9
+
10
+
11
+ def run_helper(function, args):
12
+ input_json = json.dumps({"function": function, "args": args})
13
+ result = subprocess.run(
14
+ [sys.executable, RUN_PY],
15
+ input=input_json,
16
+ capture_output=True,
17
+ text=True,
18
+ cwd=HELPERS_DIR,
19
+ )
20
+ assert result.returncode == 0, (
21
+ f"run.py failed: {result.stderr}"
22
+ )
23
+ return json.loads(result.stdout)
24
+
25
+
26
+ class TestRunRouting:
27
+ def test_parse_pep621_routing(self):
28
+ fixture = os.path.join(FIXTURES, "pep621_dependencies.toml")
29
+ result = run_helper("parse_pep621_pep735_dependencies", [fixture])
30
+
31
+ assert "result" in result
32
+ names = {d["name"] for d in result["result"]}
33
+ assert "requests" in names
34
+
35
+ def test_parse_setup_routing(self):
36
+ fixture_dir = os.path.join(FIXTURES, "setup_py")
37
+ result = run_helper("parse_setup", [fixture_dir])
38
+
39
+ assert "result" in result
40
+ names = {d["name"] for d in result["result"]}
41
+ assert "requests" in names
42
+
43
+ def test_parse_requirements_routing(self):
44
+ fixture_dir = os.path.join(FIXTURES, "requirements")
45
+ result = run_helper("parse_requirements", [fixture_dir])
46
+
47
+ assert "result" in result
48
+ names = {d["name"] for d in result["result"]}
49
+ assert "requests" in names
@@ -124,8 +124,10 @@ module Dependabot
124
124
  declaration_match = content.match(declaration_regex)
125
125
  if declaration_match
126
126
  declaration = declaration_match[:declaration]
127
- new_declaration = T.must(declaration).sub(old_req, new_req)
128
- return content.sub(T.must(declaration), new_declaration)
127
+ if T.must(declaration).include?(old_req)
128
+ new_declaration = T.must(declaration).sub(old_req, new_req)
129
+ return content.sub(T.must(declaration), new_declaration)
130
+ end
129
131
  end
130
132
 
131
133
  # Try Poetry table format
@@ -220,9 +222,8 @@ module Dependabot
220
222
 
221
223
  sig { params(pyproject_content: String).returns(String) }
222
224
  def freeze_other_dependencies(pyproject_content)
223
- PyprojectPreparer
224
- .new(pyproject_content: pyproject_content, lockfile: lockfile)
225
- .freeze_top_level_dependencies_except(dependencies)
225
+ PyprojectPreparer.new(pyproject_content: pyproject_content, lockfile: lockfile)
226
+ .freeze_top_level_dependencies_except(dependencies)
226
227
  end
227
228
 
228
229
  sig { params(pyproject_content: String).returns(String) }
@@ -244,14 +245,18 @@ module Dependabot
244
245
  end
245
246
  end
246
247
 
248
+ # Freeze PEP 621 project.dependencies and project.optional-dependencies
249
+ PyprojectPreparer.freeze_pep621_deps!(pyproject_object, dependencies) do |dep|
250
+ !git_dependency_being_updated?(dep)
251
+ end
252
+
247
253
  TomlRB.dump(pyproject_object)
248
254
  end
249
255
 
250
256
  sig { params(pyproject_content: String).returns(String) }
251
257
  def update_python_requirement(pyproject_content)
252
- PyprojectPreparer
253
- .new(pyproject_content: pyproject_content)
254
- .update_python_requirement(language_version_manager.python_version)
258
+ PyprojectPreparer.new(pyproject_content: pyproject_content)
259
+ .update_python_requirement(language_version_manager.python_version)
255
260
  end
256
261
 
257
262
  sig { params(poetry_object: T::Hash[String, T.untyped], dep: Dependabot::Dependency).returns(T::Array[String]) }
@@ -262,6 +267,8 @@ module Dependabot
262
267
  next unless pkg_name
263
268
 
264
269
  if poetry_object[type][pkg_name].is_a?(Hash)
270
+ next unless poetry_object[type][pkg_name].key?("version") # skip enrichment-only entries
271
+
265
272
  poetry_object[type][pkg_name]["version"] = dep.version
266
273
  else
267
274
  poetry_object[type][pkg_name] = dep.version
@@ -284,9 +291,7 @@ module Dependabot
284
291
 
285
292
  sig { params(pyproject_content: String).returns(String) }
286
293
  def sanitize(pyproject_content)
287
- PyprojectPreparer
288
- .new(pyproject_content: pyproject_content)
289
- .sanitize
294
+ PyprojectPreparer.new(pyproject_content: pyproject_content).sanitize
290
295
  end
291
296
 
292
297
  sig { params(pyproject_content: String).returns(String) }
@@ -16,6 +16,80 @@ module Dependabot
16
16
  class PyprojectPreparer
17
17
  extend T::Sig
18
18
 
19
+ # Builds a regex pattern that matches a PEP 508 package name,
20
+ # treating hyphens, underscores, and dots as interchangeable per PEP 508.
21
+ sig { params(name: String).returns(String) }
22
+ def self.pep508_name_pattern(name)
23
+ Regexp.escape(name).gsub("\\-", "[-_.]").gsub("_", "[-_.]").gsub("\\.", "[-_.]")
24
+ end
25
+ private_class_method :pep508_name_pattern
26
+
27
+ # Pins a single PEP 508 dependency entry string to a specific version,
28
+ # preserving extras and environment markers.
29
+ sig { params(entry: String, name_pattern: String, version: String).returns(String) }
30
+ def self.pin_pep508_entry(entry, name_pattern, version)
31
+ if entry.match?(/\A#{name_pattern}(\[.*?\])?\s*[><=!~]/i)
32
+ entry.sub(
33
+ /(?<pre>#{name_pattern}(?:\[.*?\])?)\s*[><=!~][^;]*?(?=\s*;|\s*\z)/i,
34
+ "\\k<pre>==#{version}"
35
+ )
36
+ else
37
+ entry.sub(
38
+ /(?<pre>#{name_pattern}(?:\[.*?\])?)(?<rest>\s*(?:;.*)?)/i,
39
+ "\\k<pre>==#{version}\\k<rest>"
40
+ )
41
+ end
42
+ end
43
+ private_class_method :pin_pep508_entry
44
+
45
+ # Freezes PEP 621 dependencies in-place within a parsed pyproject object.
46
+ # Replaces version specifiers with ==dep.version for each matching dep.
47
+ # Accepts an optional block to filter which dependencies to freeze.
48
+ sig do
49
+ params(
50
+ pyproject_object: T::Hash[String, T.untyped],
51
+ deps: T::Array[Dependabot::Dependency],
52
+ blk: T.nilable(T.proc.params(dep: Dependabot::Dependency).returns(T::Boolean))
53
+ ).void
54
+ end
55
+ def self.freeze_pep621_deps!(pyproject_object, deps, &blk)
56
+ dep_arrays = collect_pep621_dep_arrays(pyproject_object)
57
+ return if dep_arrays.empty?
58
+
59
+ deps.each do |dep|
60
+ next if blk && !yield(dep)
61
+ next unless dep.version
62
+
63
+ pin_pep621_dep_in_arrays!(dep_arrays, dep)
64
+ end
65
+ end
66
+
67
+ sig { params(pyproject_object: T::Hash[String, T.untyped]).returns(T::Array[T::Array[String]]) }
68
+ def self.collect_pep621_dep_arrays(pyproject_object)
69
+ project_object = pyproject_object["project"]
70
+ return [] unless project_object
71
+
72
+ dep_arrays = [project_object["dependencies"]]
73
+ project_object["optional-dependencies"]&.each_value { |opt| dep_arrays << opt }
74
+ dep_arrays.compact!
75
+ dep_arrays.select! { |arr| arr.is_a?(Array) && arr.all?(String) }
76
+ dep_arrays
77
+ end
78
+ private_class_method :collect_pep621_dep_arrays
79
+
80
+ sig { params(dep_arrays: T::Array[T::Array[String]], dep: Dependabot::Dependency).void }
81
+ def self.pin_pep621_dep_in_arrays!(dep_arrays, dep)
82
+ name_pattern = pep508_name_pattern(dep.name)
83
+ dep_arrays.each do |arr|
84
+ arr.each_with_index do |entry, i|
85
+ next unless entry.match?(/\A#{name_pattern}(\[.*?\])?\s*(\z|[><=!~;,])/i)
86
+
87
+ arr[i] = pin_pep508_entry(entry, name_pattern, T.must(dep.version))
88
+ end
89
+ end
90
+ end
91
+ private_class_method :pin_pep621_dep_in_arrays!
92
+
19
93
  sig { params(pyproject_content: String, lockfile: T.nilable(Dependabot::DependencyFile)).void }
20
94
  def initialize(pyproject_content:, lockfile: nil)
21
95
  @pyproject_content = pyproject_content
@@ -109,6 +183,9 @@ module Dependabot
109
183
  end
110
184
  end
111
185
 
186
+ # Freeze PEP 621 project.dependencies and project.optional-dependencies
187
+ freeze_pep621_top_level_deps!(pyproject_object, excluded_names)
188
+
112
189
  TomlRB.dump(pyproject_object)
113
190
  end
114
191
  # rubocop:enable Metrics/AbcSize
@@ -122,6 +199,38 @@ module Dependabot
122
199
  sig { returns(T.nilable(Dependabot::DependencyFile)) }
123
200
  attr_reader :lockfile
124
201
 
202
+ sig { params(pyproject_object: T::Hash[String, T.untyped], excluded_names: T::Array[String]).void }
203
+ def freeze_pep621_top_level_deps!(pyproject_object, excluded_names)
204
+ project_object = pyproject_object["project"]
205
+ return unless project_object
206
+
207
+ freeze_pep621_dep_array!(project_object["dependencies"], excluded_names)
208
+
209
+ project_object["optional-dependencies"]&.each_value do |opt_deps|
210
+ freeze_pep621_dep_array!(opt_deps, excluded_names)
211
+ end
212
+ end
213
+
214
+ sig { params(dep_array: T.nilable(T::Array[String]), excluded_names: T::Array[String]).void }
215
+ def freeze_pep621_dep_array!(dep_array, excluded_names)
216
+ return unless dep_array
217
+
218
+ dep_array.each_with_index do |entry, index|
219
+ # Extract dependency name from PEP 508 string
220
+ match = entry.match(/\A([a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?)/i)
221
+ next unless match
222
+
223
+ dep_name = normalise(T.must(match[1]))
224
+ next if excluded_names.include?(dep_name)
225
+
226
+ locked_details = locked_details(dep_name)
227
+ next unless (locked_version = locked_details&.fetch("version"))
228
+
229
+ name_pattern = self.class.send(:pep508_name_pattern, T.must(match[1]))
230
+ dep_array[index] = self.class.send(:pin_pep508_entry, entry, name_pattern, locked_version)
231
+ end
232
+ end
233
+
125
234
  sig { params(dep_name: String).returns(T.nilable(T::Hash[String, T.untyped])) }
126
235
  def locked_details(dep_name)
127
236
  parsed_lockfile.fetch("package")
@@ -9,6 +9,7 @@ require "sorbet-runtime"
9
9
  require "dependabot/dependency"
10
10
  require "dependabot/git_commit_checker"
11
11
  require "dependabot/python/update_checker"
12
+ require "dependabot/update_checkers/cooldown_calculation"
12
13
  require "dependabot/update_checkers/version_filters"
13
14
  require "dependabot/registry_client"
14
15
  require "dependabot/python/authed_url_builder"
@@ -110,8 +111,9 @@ module Dependabot
110
111
  new_version = version_class.new(tag_version_str)
111
112
  days = cooldown_days_for(current_version, new_version)
112
113
 
113
- passed_seconds = Time.now.to_i - release_date_to_seconds(tag_with_detail.release_date)
114
- passed_seconds < days * DAY_IN_SECONDS
114
+ release_time = Time.at(release_date_to_seconds(tag_with_detail.release_date))
115
+ Dependabot::UpdateCheckers::CooldownCalculation
116
+ .within_cooldown_window?(release_time, days)
115
117
  end
116
118
 
117
119
  sig do
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dependabot-python
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.368.0
4
+ version: 0.369.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dependabot
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.368.0
18
+ version: 0.369.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - '='
24
24
  - !ruby/object:Gem::Version
25
- version: 0.368.0
25
+ version: 0.369.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: debug
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -247,6 +247,31 @@ files:
247
247
  - helpers/lib/parser.py
248
248
  - helpers/requirements.txt
249
249
  - helpers/run.py
250
+ - helpers/test/fixtures/no_dependencies.toml
251
+ - helpers/test/fixtures/pep621_arbitrary_equality.toml
252
+ - helpers/test/fixtures/pep621_dependencies.toml
253
+ - helpers/test/fixtures/pep621_empty_deps.toml
254
+ - helpers/test/fixtures/pep621_extras.toml
255
+ - helpers/test/fixtures/pep621_markers.toml
256
+ - helpers/test/fixtures/pep621_multiple_extras.toml
257
+ - helpers/test/fixtures/pep621_no_version.toml
258
+ - helpers/test/fixtures/pep621_only_build_system.toml
259
+ - helpers/test/fixtures/pep735_cycle.toml
260
+ - helpers/test/fixtures/pep735_dependency_groups.toml
261
+ - helpers/test/fixtures/requirements/constraints.txt
262
+ - helpers/test/fixtures/requirements/markers.txt
263
+ - helpers/test/fixtures/requirements/requirements-dev.txt
264
+ - helpers/test/fixtures/requirements/requirements.txt
265
+ - helpers/test/fixtures/requirements/with_constraints.txt
266
+ - helpers/test/fixtures/requirements_empty/.gitkeep
267
+ - helpers/test/fixtures/setup_cfg/setup.cfg
268
+ - helpers/test/fixtures/setup_py/setup.py
269
+ - helpers/test/fixtures/setup_py_comments/setup.py
270
+ - helpers/test/test_hasher.py
271
+ - helpers/test/test_parse_requirements.py
272
+ - helpers/test/test_parse_setup.py
273
+ - helpers/test/test_parser.py
274
+ - helpers/test/test_run.py
250
275
  - lib/dependabot/python.rb
251
276
  - lib/dependabot/python/authed_url_builder.rb
252
277
  - lib/dependabot/python/dependency_grapher.rb
@@ -294,7 +319,7 @@ licenses:
294
319
  - MIT
295
320
  metadata:
296
321
  bug_tracker_uri: https://github.com/dependabot/dependabot-core/issues
297
- changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.368.0
322
+ changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.369.0
298
323
  rdoc_options: []
299
324
  require_paths:
300
325
  - lib