diff --git a/docs/html/cli/pip_install.rst b/docs/html/cli/pip_install.rst index 00d7f7d23b1..ff1dca9f4e9 100644 --- a/docs/html/cli/pip_install.rst +++ b/docs/html/cli/pip_install.rst @@ -66,8 +66,8 @@ for the name and project version (this is in theory slightly less reliable than using the ``egg_info`` command, but avoids downloading and processing unnecessary numbers of files). -Any URL may use the ``#egg=name`` syntax (see :doc:`../topics/vcs-support`) to -explicitly state the project name. +The :pep:`508` requirement syntax can be used to explicitly state the project +name (see :doc:`../topics/vcs-support`). Satisfying Requirements ----------------------- @@ -367,21 +367,21 @@ Examples .. code-block:: shell - python -m pip install -e 'git+https://git.repo/some_pkg.git#egg=SomePackage' # from git - python -m pip install -e 'hg+https://hg.repo/some_pkg.git#egg=SomePackage' # from mercurial - python -m pip install -e 'svn+svn://svn.repo/some_pkg/trunk/#egg=SomePackage' # from svn - python -m pip install -e 'git+https://git.repo/some_pkg.git@feature#egg=SomePackage' # from 'feature' branch - python -m pip install -e 'git+https://git.repo/some_repo.git#egg=subdir&subdirectory=subdir_path' # install a python package from a repo subdirectory + python -m pip install -e 'SomePackage @ git+https://git.repo/some_pkg.git' # from git + python -m pip install -e 'SomePackage @ hg+https://hg.repo/some_pkg.git' # from mercurial + python -m pip install -e 'SomePakcage @ svn+svn://svn.repo/some_pkg/trunk/' # from svn + python -m pip install -e 'SomePackage @ git+https://git.repo/some_pkg.git@feature' # from 'feature' branch + python -m pip install -e 'subdir @ git+https://git.repo/some_repo.git#subdirectory=subdir_path' # install a python package from a repo subdirectory .. tab:: Windows .. code-block:: shell - py -m pip install -e "git+https://git.repo/some_pkg.git#egg=SomePackage" # from git - py -m pip install -e "hg+https://hg.repo/some_pkg.git#egg=SomePackage" # from mercurial - py -m pip install -e "svn+svn://svn.repo/some_pkg/trunk/#egg=SomePackage" # from svn - py -m pip install -e "git+https://git.repo/some_pkg.git@feature#egg=SomePackage" # from 'feature' branch - py -m pip install -e "git+https://git.repo/some_repo.git#egg=subdir&subdirectory=subdir_path" # install a python package from a repo subdirectory + py -m pip install -e "SomePackage @ git+https://git.repo/some_pkg.git" # from git + py -m pip install -e "SomePackage @ hg+https://hg.repo/some_pkg.git" # from mercurial + py -m pip install -e "SomePackage @ svn+svn://svn.repo/some_pkg/trunk/" # from svn + py -m pip install -e "SomePackage @ git+https://git.repo/some_pkg.git@feature" # from 'feature' branch + py -m pip install -e "subdir @ git+https://git.repo/some_repo.git#subdirectory=subdir_path" # install a python package from a repo subdirectory #. Install a package with extras, i.e., optional dependencies (:ref:`specification `). diff --git a/docs/html/topics/vcs-support.md b/docs/html/topics/vcs-support.md index c8169dbe24c..d637d66f00e 100644 --- a/docs/html/topics/vcs-support.md +++ b/docs/html/topics/vcs-support.md @@ -10,6 +10,25 @@ control system being used). It is used through URL prefixes: - Subversion -- `svn+` - Bazaar -- `bzr+` +The general form of a VCS requirement is `ProjectName @ VCS_URL`, e.g. + +```none +MyProject @ git+https://git.example.com/MyProject +MyProject[extra] @ git+https:/git.example.com/MyProject +``` + +This is the Direct URL ({pep}`508`) requirement syntax. It is also permissible +to remove `MyProject @` portion is removed and provide a bare VCS URL. + +```none +git+https://git.example.com/MyProject +``` + +This is a pip specific extension. This form can be used as long as pip does +not need to know the project name in advance. pip is generally able to infer +the project name except in the case of {ref}`editable-vcs-installs`. In +addition, extras cannot be requested using a bare VCS URL. + ## Supported VCS ### Git @@ -81,8 +100,8 @@ MyProject @ svn+ssh://user@svn.example.com/MyProject You can also give specific revisions to an SVN URL, like so: ```none --e svn+http://svn.example.com/svn/MyProject/trunk@2019#egg=MyProject --e svn+http://svn.example.com/svn/MyProject/trunk@{20080101}#egg=MyProject +-e MyProject @ svn+http://svn.example.com/svn/MyProject/trunk@2019 +-e MyProject @ svn+http://svn.example.com/svn/MyProject/trunk@{20080101} ``` Note that you need to use [Editable VCS installs](#editable-vcs-installs) for @@ -115,6 +134,9 @@ MyProject @ bzr+http://bzr.example.com/MyProject/trunk@v1.0 VCS projects can be installed in {ref}`editable mode ` (using the {ref}`--editable ` option) or not. +In editable mode, the project name must be provided upfront using the Direct URL +(`MyProject @ URL`) form so pip can determine the VCS clone location. + - The default clone location (for editable installs) is: - `/src/SomeProject` in virtual environments @@ -133,15 +155,15 @@ take on the VCS requirement (not the commit itself). ## URL fragments pip looks at the `subdirectory` fragments of VCS URLs for specifying the path to the -Python package, when it is not in the root of the VCS directory. eg: `pkg_dir`. +Python package, when it is not in the root of the VCS directory. -pip also looks at the `egg` fragment specifying the "project name". In practice the -`egg` fragment is only required to help pip determine the VCS clone location in editable -mode. In all other circumstances, the `egg` fragment is not necessary and its use is -discouraged. +```{note} +pip also supports an `egg` fragment to specify the "project name". This is a legacy +feature and its use is discouraged in favour of the Direct URL ({pep}`508`) form. The `egg` fragment **should** be a bare {ref}`project name `. Anything else is not guaranteed to work. +``` ````{admonition} Example If your repository layout is: @@ -164,6 +186,6 @@ $ pip install "pkg @ vcs+protocol://repo_url/#subdirectory=pkg_dir" or: ```{pip-cli} -$ pip install -e "vcs+protocol://repo_url/#egg=pkg&subdirectory=pkg_dir" +$ pip install -e "pkg @ vcs+protocol://repo_url/#subdirectory=pkg_dir" ``` ```` diff --git a/docs/html/user_guide.rst b/docs/html/user_guide.rst index d6a0acf9cd8..f1d00279ca5 100644 --- a/docs/html/user_guide.rst +++ b/docs/html/user_guide.rst @@ -183,7 +183,7 @@ In practice, there are 4 common uses of Requirements files: ``sometag``. You'd reference it in your requirements file with a line like so:: - git+https://myvcs.com/some_dependency@sometag#egg=SomeDependency + SomeDependency @ git+https://myvcs.com/some_dependency@sometag If ``SomeDependency`` was previously a top-level requirement in your requirements file, then **replace** that line with the new line. If diff --git a/news/13495.feature.rst b/news/13495.feature.rst new file mode 100644 index 00000000000..911a086e192 --- /dev/null +++ b/news/13495.feature.rst @@ -0,0 +1 @@ +Add support installing an editable requirement written as a Direct URL. diff --git a/src/pip/_internal/req/constructors.py b/src/pip/_internal/req/constructors.py index 056e7e3a7f1..a28c8cbc9da 100644 --- a/src/pip/_internal/req/constructors.py +++ b/src/pip/_internal/req/constructors.py @@ -86,17 +86,25 @@ def _set_requirement_extras(req: Requirement, new_extras: set[str]) -> Requireme return get_requirement(f"{pre}{extras}{post}") -def parse_editable(editable_req: str) -> tuple[str | None, str, set[str]]: - """Parses an editable requirement into: - - a requirement name - - an URL - - extras - - editable options - Accepted requirements: - svn+http://blahblah@rev#egg=Foobar[baz]&subdirectory=version_subdir - .[some_extra] - """ +def _parse_direct_url_editable(editable_req: str) -> tuple[str | None, str, set[str]]: + try: + req = Requirement(editable_req) + except InvalidRequirement: + pass + else: + if req.url: + # Join the marker back into the name part. This will be parsed out + # later into a Requirement again. + if req.marker: + name = f"{req.name} ; {req.marker}" + else: + name = req.name + return (name, req.url, req.extras) + + raise ValueError + +def _parse_pip_syntax_editable(editable_req: str) -> tuple[str | None, str, set[str]]: url = editable_req # If a file path is specified with extras, strip off the extras. @@ -122,9 +130,27 @@ def parse_editable(editable_req: str) -> tuple[str | None, str, set[str]]: url = f"{version_control}+{url}" break + return Link(url).egg_fragment, url, set() + + +def parse_editable(editable_req: str) -> tuple[str | None, str, set[str]]: + """Parses an editable requirement into: + - a requirement name + - an URL + - extras + Accepted requirements: + - svn+http://blahblah@rev#egg=Foobar[baz]&subdirectory=version_subdir + - local_path[some_extra] + - Foobar[extra] @ svn+http://blahblah@rev#subdirectory=subdir ; markers + """ + try: + package_name, url, extras = _parse_direct_url_editable(editable_req) + except ValueError: + package_name, url, extras = _parse_pip_syntax_editable(editable_req) + link = Link(url) - if not link.is_vcs: + if not link.is_vcs and not link.url.startswith("file:"): backends = ", ".join(vcs.all_schemes) raise InstallationError( f"{editable_req} is not a valid editable requirement. " @@ -132,13 +158,13 @@ def parse_editable(editable_req: str) -> tuple[str | None, str, set[str]]: f"(beginning with {backends})." ) - package_name = link.egg_fragment - if not package_name: + # The project name can be inferred from local file URIs easily. + if not package_name and not link.url.startswith("file:"): raise InstallationError( f"Could not detect requirement name for '{editable_req}', " - "please specify one with #egg=your_package_name" + "please specify one with your_package_name @ URL" ) - return package_name, url, set() + return package_name, url, extras def check_first_requirement_in_file(filename: str) -> None: diff --git a/src/pip/_internal/resolution/resolvelib/candidates.py b/src/pip/_internal/resolution/resolvelib/candidates.py index a8315349791..07c19671374 100644 --- a/src/pip/_internal/resolution/resolvelib/candidates.py +++ b/src/pip/_internal/resolution/resolvelib/candidates.py @@ -85,8 +85,12 @@ def make_install_req_from_editable( link: Link, template: InstallRequirement ) -> InstallRequirement: assert template.editable, "template not editable" + if template.name: + req_string = f"{template.name} @ {link.url}" + else: + req_string = link.url ireq = install_req_from_editable( - link.url, + req_string, user_supplied=template.user_supplied, comes_from=template.comes_from, use_pep517=template.use_pep517, diff --git a/tests/functional/test_install_reqs.py b/tests/functional/test_install_reqs.py index 3c5b6db4a68..e841fcfbf87 100644 --- a/tests/functional/test_install_reqs.py +++ b/tests/functional/test_install_reqs.py @@ -10,6 +10,7 @@ PipTestEnvironment, ResolverVariant, TestData, + _create_test_package, _create_test_package_with_subdirectory, create_basic_sdist_for_package, create_basic_wheel_for_package, @@ -941,3 +942,52 @@ def test_nonpep517_setuptools_import_failure(script: PipTestEnvironment) -> None exc_message = "ImportError: this 'setuptools' was intentionally poisoned" assert nice_message in result.stderr assert exc_message in result.stderr + + +class TestEditableDirectURL: + def test_install_local_project( + self, script: PipTestEnvironment, data: TestData, common_wheels: Path + ) -> None: + uri = (data.src / "simplewheel-2.0").as_uri() + script.pip( + "install", "--no-index", "-e", f"simplewheel @ {uri}", "-f", common_wheels + ) + script.assert_installed(simplewheel="2.0") + + def test_install_local_project_with_extra( + self, script: PipTestEnvironment, data: TestData, common_wheels: Path + ) -> None: + uri = (data.src / "requires_simple_extra").as_uri() + script.pip( + "install", + "--no-index", + "-e", + f"requires-simple-extra[extra] @ {uri}", + "-f", + common_wheels, + "-f", + data.packages, + ) + script.assert_installed(requires_simple_extra="0.1") + script.assert_installed(simple="1.0") + + def test_install_local_git_repo( + self, script: PipTestEnvironment, common_wheels: Path + ) -> None: + repo_path = _create_test_package(script.scratch_path, "simple") + url = "git+" + repo_path.as_uri() + script.pip( + "install", "--no-index", "-e", f"simple @ {url}", "-f", common_wheels + ) + script.assert_installed(simple="0.1") + + @pytest.mark.network + def test_install_remote_git_repo_with_extra( + self, script: PipTestEnvironment, data: TestData, common_wheels: Path + ) -> None: + req = "pip-test-package[extra] @ git+https://github.com/pypa/pip-test-package" + script.pip( + "install", "--no-index", "-e", req, "-f", common_wheels, "-f", data.packages + ) + script.assert_installed(pip_test_package="0.1.1") + script.assert_installed(simple="3.0") diff --git a/tests/unit/test_req.py b/tests/unit/test_req.py index 0547131134e..f812f2c7ed9 100644 --- a/tests/unit/test_req.py +++ b/tests/unit/test_req.py @@ -853,6 +853,25 @@ def test_install_req_extend_extras( assert extended.permit_editable_wheels == req.permit_editable_wheels +@pytest.mark.parametrize( + "req_str, expected", + [ + ( + 'foo[extra] @ svn+http://foo ; os_name == "nt"', + ('foo ; os_name == "nt"', "svn+http://foo", {"extra"}), + ), + ( + "foo @ svn+http://foo", + ("foo", "svn+http://foo", set()), + ), + ], +) +def test_parse_editable_pep508( + req_str: str, expected: tuple[str, str, set[str]] +) -> None: + assert parse_editable(req_str) == expected + + @mock.patch("pip._internal.req.req_install.os.path.abspath") @mock.patch("pip._internal.req.req_install.os.path.exists") @mock.patch("pip._internal.req.req_install.os.path.isdir")