11from __future__ import annotations
22
3+ import dataclasses
34import hashlib
45import os
56import pathlib
67import re
78import shutil
89import subprocess
910import sys
11+ import typing
1012from textwrap import dedent
1113from unittest import mock
1214from unittest .mock import MagicMock
3941)
4042
4143
44+ @pytest .fixture (scope = "session" )
45+ def installed_pip_version ():
46+ return get_pip_version_for_python_executable (sys .executable )
47+
48+
49+ @pytest .fixture (scope = "session" )
50+ def pip_produces_absolute_paths (installed_pip_version ):
51+ # in pip v24.3, new normalization will occur because `comes_from` started
52+ # to be normalized to abspaths
53+ return installed_pip_version >= Version ("24.3" )
54+
55+
56+ @dataclasses .dataclass
57+ class TestFilesCollection :
58+ """
59+ A small data-builder for setting up files in a tmp dir.
60+
61+ Contains a name for use as the ID in parametrized tests and contents.
62+ 'contents' maps from subpaths in the tmp dir to file content or callables
63+ which produce file content given the tmp dir.
64+ """
65+
66+ # the name for the collection of files
67+ name : str
68+ # static or computed contents
69+ contents : dict [str , str | typing .Callable [[pathlib .Path ], str ]]
70+
71+ def __str__ (self ) -> str :
72+ return self .name
73+
74+ def populate (self , tmp_path : pathlib .Path ) -> None :
75+ """Populate the tmp dir with file contents."""
76+ for path_str , content in self .contents .items ():
77+ path = tmp_path / path_str
78+ path .parent .mkdir (exist_ok = True , parents = True )
79+ if isinstance (content , str ):
80+ path .write_text (content )
81+ else :
82+ path .write_text (content (tmp_path ))
83+
84+ def get_path_to (self , filename : str ) -> str :
85+ """Given a filename, find the (first) path to that filename in the contents."""
86+ return next (
87+ k
88+ for k in self .contents .keys ()
89+ if (k == filename ) or k .endswith (f"/{ filename } " )
90+ )
91+
92+
4293@pytest .fixture (
4394 autouse = True ,
4495 params = [
@@ -3840,31 +3891,34 @@ def test_stdout_should_not_be_read_when_stdin_is_not_a_plain_file(
38403891 assert out .exit_code == 0 , out
38413892
38423893
3894+ @pytest .mark .parametrize ("input_path_absolute" , (True , False ))
38433895@pytest .mark .parametrize (
3896+ "test_files_collection" ,
38443897 (
3845- "first_order_path_absolute" ,
3846- "second_order_path_absolute" ,
3847- "normed_output_absolute" ,
3848- ),
3849- (
3850- # requirements.in + requirements2.in -> requirements2.in
3851- pytest .param (False , False , False , id = "both-relative" ),
3852- # /foo/requirements.in + /foo/requirements2.in -> /foo/requirements2.in
3853- pytest .param (True , True , True , id = "both-absolute" ),
3854- # /foo/requirements.in + requirements2.in -> /foo/requirements2.in
3855- pytest .param (True , False , True , id = "absolute-includes-relative" ),
3856- # requirements.in + /foo/requirements2.in -> requirements2.in
3857- pytest .param (False , True , False , id = "relative-includes-absolute" ),
3898+ TestFilesCollection (
3899+ "relative_include" ,
3900+ {
3901+ "requirements2.in" : "small-fake-a\n " ,
3902+ "requirements.in" : "-r requirements2.in\n " ,
3903+ },
3904+ ),
3905+ TestFilesCollection (
3906+ "absolute_include" ,
3907+ {
3908+ "requirements2.in" : "small-fake-a\n " ,
3909+ "requirements.in" : lambda tmpdir : f"-r { (tmpdir / 'requirements2.in' ).as_posix ()} " ,
3910+ },
3911+ ),
38583912 ),
3913+ ids = str ,
38593914)
38603915def test_second_order_requirements_path_handling (
38613916 pip_conf ,
38623917 runner ,
38633918 tmp_path ,
38643919 monkeypatch ,
3865- first_order_path_absolute ,
3866- second_order_path_absolute ,
3867- normed_output_absolute ,
3920+ input_path_absolute ,
3921+ test_files_collection ,
38683922):
38693923 """
38703924 Given nested requirements files, the internal requirements will be written
@@ -3876,67 +3930,34 @@ def test_second_order_requirements_path_handling(
38763930 On lower pip versions, assert the previous output is produced, in which
38773931 `-r` inputs are preserved.
38783932 """
3879- # in pip v24.3, new normalization will occur because `comes_from` started
3880- # to be normalized to abspaths
3881- pip_current_version = get_pip_version_for_python_executable (sys .executable )
3882- expect_new_normalization = pip_current_version >= Version ("24.3" )
3883-
3884- # setup: there are two files, the second one has an actual package name
3885- req_in = tmp_path / "requirements.in"
3886- req_in2 = tmp_path / "requirements2.in"
3887- req_in2 .write_text ("small-fake-a\n " )
3888- # the data for the first file is written based on whether or not the
3889- # second-order path is absolute
3890- if second_order_path_absolute :
3891- req_in .write_text (f"-r { req_in2 .as_posix ()} \n " )
3892- else :
3893- req_in .write_text ("-r ./requirements2.in\n " )
3933+ test_files_collection .populate (tmp_path )
38943934
3895- # and the first order path is given on the CLI as absolute or relative
3896- if first_order_path_absolute :
3897- input_path = req_in .as_posix ()
3935+ # the input path is given on the CLI as absolute or relative
3936+ # and this determines the expected output path as well
3937+ if input_path_absolute :
3938+ input_path = (tmp_path / "requirements.in" ).as_posix ()
3939+ output_path = (tmp_path / "requirements2.in" ).as_posix ()
38983940 else :
38993941 input_path = "requirements.in"
3942+ output_path = "requirements2.in"
39003943
3901- monkeypatch .chdir (tmp_path )
3902- out = runner .invoke (
3903- cli ,
3904- [
3905- "--output-file" ,
3906- "-" ,
3907- "--quiet" ,
3908- "--no-header" ,
3909- "--no-emit-options" ,
3910- "-r" ,
3911- input_path ,
3912- ],
3913- )
3914- monkeypatch .undo ()
3944+ with monkeypatch .context () as m :
3945+ m .chdir (tmp_path )
3946+
3947+ out = runner .invoke (
3948+ cli ,
3949+ [
3950+ "--output-file" ,
3951+ "-" ,
3952+ "--quiet" ,
3953+ "--no-header" ,
3954+ "--no-emit-options" ,
3955+ "-r" ,
3956+ input_path ,
3957+ ],
3958+ )
39153959
39163960 assert out .exit_code == 0
3917- # either we have the new normalization steps apply, and we'll get the abspath
3918- # or the relative path
3919- if expect_new_normalization :
3920- if normed_output_absolute :
3921- output_path = req_in2 .as_posix ()
3922- else :
3923- output_path = "requirements2.in"
3924- # or else it's the older codepath (earlier pip versions) and each of the four input
3925- # cases produces slightly different output
3926- # exact input path in the output
3927- else :
3928- if first_order_path_absolute and second_order_path_absolute : # True, True
3929- output_path = req_in2 .as_posix ()
3930- elif (
3931- not first_order_path_absolute and second_order_path_absolute
3932- ): # False, True
3933- output_path = "requirements2.in"
3934- elif (
3935- first_order_path_absolute and not second_order_path_absolute
3936- ): # True, False
3937- output_path = tmp_path .as_posix () + "/./requirements2.in"
3938- else : # False, False
3939- output_path = "./requirements2.in"
39403961 assert out .stdout == dedent (
39413962 f"""\
39423963 small-fake-a==0.2
@@ -3946,10 +3967,39 @@ def test_second_order_requirements_path_handling(
39463967
39473968
39483969@pytest .mark .parametrize (
3949- "relative_requirement_location" , ("parent" , "subdir" , "sibling_dir" )
3970+ "test_files_collection" ,
3971+ (
3972+ TestFilesCollection (
3973+ "parent_dir" ,
3974+ {
3975+ "requirements2.in" : "small-fake-a\n " ,
3976+ "subdir/requirements.in" : "-r ../requirements2.in\n " ,
3977+ },
3978+ ),
3979+ TestFilesCollection (
3980+ "subdir" ,
3981+ {
3982+ "requirements.in" : "-r ./subdir/requirements2.in" ,
3983+ "subdir/requirements2.in" : "small-fake-a\n " ,
3984+ },
3985+ ),
3986+ TestFilesCollection (
3987+ "sibling_dir" ,
3988+ {
3989+ "subdir1/requirements.in" : "-r ../subdir2/requirements2.in" ,
3990+ "subdir2/requirements2.in" : "small-fake-a\n " ,
3991+ },
3992+ ),
3993+ ),
3994+ ids = str ,
39503995)
39513996def test_second_order_requirements_relative_path_in_separate_dir (
3952- pip_conf , runner , tmp_path , monkeypatch , relative_requirement_location
3997+ pip_conf ,
3998+ runner ,
3999+ tmp_path ,
4000+ monkeypatch ,
4001+ test_files_collection ,
4002+ pip_produces_absolute_paths ,
39534003):
39544004 """
39554005 As in the above test, nested requirements file path handling.
@@ -3959,85 +4009,39 @@ def test_second_order_requirements_relative_path_in_separate_dir(
39594009 The expectation is that the output path will be relative to the current
39604010 working directory, *not* relative to the dir containing the initial
39614011 requirements file.
3962-
3963- Parent dir layout:
3964- - root_dir/
3965- - requirements2.in
3966- - subdir/
3967- - requirements.in # containing '-r ../requirements2.in'
3968-
3969- Subdir layout:
3970- - root_dir/
3971- - requirements.in # containing '-r ./subdir/requirements2.in'
3972- - subdir/
3973- - requirements2.in
3974-
3975- Sibling dir layout:
3976- - root_dir/
3977- - subdir1/
3978- - requirements.in # containing '-r ../subdir2/requirements2.in'
3979- - subdir2/
3980- - requirements2.in
39814012 """
3982- pip_current_version = get_pip_version_for_python_executable (sys .executable )
3983- expect_path_normalization = pip_current_version >= Version ("24.3" )
3984-
3985- # setup the two files in the requested dir layout
3986- if relative_requirement_location == "parent" :
3987- (tmp_path / "subdir" ).mkdir ()
3988- req_in = tmp_path / "subdir" / "requirements.in"
3989- req_in .write_text ("-r ../requirements2.in\n " )
3990- req_in2 = tmp_path / "requirements2.in"
3991- req_in2 .write_text ("small-fake-a\n " )
3992-
3993- if expect_path_normalization :
3994- output_path = "requirements2.in"
3995- else :
3996- output_path = "subdir/../requirements2.in"
3997- elif relative_requirement_location == "subdir" :
3998- req_in = tmp_path / "requirements.in"
3999- req_in .write_text ("-r ./subdir/requirements2.in\n " )
4000- (tmp_path / "subdir" ).mkdir ()
4001- req_in2 = tmp_path / "subdir" / "requirements2.in"
4002- req_in2 .write_text ("small-fake-a\n " )
4003-
4004- if expect_path_normalization :
4005- output_path = "subdir/requirements2.in"
4006- else :
4007- output_path = "./subdir/requirements2.in"
4008- elif relative_requirement_location == "sibling_dir" :
4009- (tmp_path / "subdir1" ).mkdir ()
4010- req_in = tmp_path / "subdir1" / "requirements.in"
4011- req_in .write_text ("-r ../subdir2/requirements2.in\n " )
4012- (tmp_path / "subdir2" ).mkdir ()
4013- req_in2 = tmp_path / "subdir2" / "requirements2.in"
4014- req_in2 .write_text ("small-fake-a\n " )
4015-
4016- if expect_path_normalization :
4017- output_path = "subdir2/requirements2.in"
4018- else :
4019- output_path = "subdir1/../subdir2/requirements2.in"
4020- else :
4021- # test params didn't match body
4022- raise NotImplementedError (relative_requirement_location )
4023-
4024- # the input is given relative to the starting dir
4025- input_path = req_in .relative_to (tmp_path ).as_posix ()
4013+ test_files_collection .populate (tmp_path )
4014+ # the input is the path to 'requirements.in' relative to the starting dir
4015+ input_path = test_files_collection .get_path_to ("requirements.in" )
4016+ # the output should also be relative to the starting dir, the path to 'requirements2.in'
4017+ output_path = test_files_collection .get_path_to ("requirements2.in" )
4018+
4019+ # for older pip versions, recompute the output path to be relative to the input path
4020+ if not pip_produces_absolute_paths :
4021+ # traverse upwards to the root tmp dir, and append the output path to that
4022+ # similar to pathlib.Path.relative_to(..., walk_up=True)
4023+ relative_segments = len (pathlib .Path (input_path ).parents ) - 1
4024+ output_path = str (
4025+ pathlib .Path (input_path ).parent / ("../" * relative_segments ) / output_path
4026+ )
4027+ # pathlib paths normalize `./foo` to `foo` automatically; de-norm that case
4028+ if str (pathlib .Path (input_path ).parent ) == "." :
4029+ output_path = "./" + output_path
40264030
4027- monkeypatch .chdir ( tmp_path )
4028- out = runner . invoke (
4029- cli ,
4030- [
4031- "--output-file" ,
4032- "- " ,
4033- "--quiet " ,
4034- "--no-header " ,
4035- "--no-emit-options " ,
4036- "-r " ,
4037- input_path ,
4038- ] ,
4039- )
4040- monkeypatch . undo ( )
4031+ with monkeypatch .context () as m :
4032+ m . chdir ( tmp_path )
4033+ out = runner . invoke (
4034+ cli ,
4035+ [
4036+ "--output-file " ,
4037+ "- " ,
4038+ "--quiet " ,
4039+ "--no-header " ,
4040+ "--no-emit-options " ,
4041+ "-r" ,
4042+ input_path ,
4043+ ],
4044+ )
40414045
40424046 assert out .exit_code == 0
40434047 assert out .stdout == dedent (
0 commit comments