Repository: python-poetry/poetry

Issue:
poetry install with nested packages with "--no-root-nested" or similar option to improve docker builds
<!--
  Hi there! Thank you for wanting to make Poetry better.

  Before you submit this; let's make sure of a few things.
  Plepoetry instase make sure the following boxes are ticked if they are correct.
  If not, please try and fulfill these first.
-->

<!-- Checked checkbox should look like this: [x] -->
- [ x] I have searched the [issues](https://github.com/python-poetry/poetry/issues) of this repo and believe that this is not a duplicate.
- [ x] I have searched the [documentation](https://python-poetry.org/docs/) and believe that my question is not covered.

## Feature Request

Right now it is possible to install only the dependencies of a package with the `poetry install --no-root --no-dev` options. This is great to improve docker builds (cache behaviour for example).

it would be great to extend that functionality to nested packages. Something like

`poetry install --no-root --no-dev --no-root-nested`

which would tell poetry to only install the *dependencies* of the nested package, but
not the package itself. This would greatly improve the caching behaviour of docker builds as we would only need
to copy  the pyproject.toml and poetry.lock files into the docker machine which causes much better caching behaviour.




Discussion:
A possible workaround could be done using `poetry export`. It's not ideal since it uses pip.

```dockerfile
# install dependencies, cached build
COPY pyproject.toml poetry.lock ./
RUN bash -c "pip install -r <(poetry export)" # optionally --without-hashes

# Actually install the app
COPY . .
RUN poetry install --no-dev
```
I have found a temporary workaround right now by only copying "pypoject.toml" and "poetry.lock" for each subproject. Additionally in order for this to work one needs a project subfolder with an \__init__.py in it. After all dependencies got resolved and installed in docker I copy the rest of the files into the docker image. This way we get somewhat nice caching behaviour:

```dockerfile
# copy only the pyproject.toml and lockfile in order to make sure they are cached
# as graphengine depends on myprojectlibrary we also copy the myprojectlibrary pyproject.toml and poetry.lock directly
# to the dockerfile. 
COPY ./myproject/pyproject.toml ./myproject/poetry.lock "$PROJECT_DIR/myproject/"
COPY ./myprojectlibrary/pyproject.toml ./myprojectlibrary/poetry.lock "$PROJECT_DIR/myprojectlibrary/"
WORKDIR "$PROJECT_DIR/myproject"

# now we can do the installation without the actual packages which
# enables us to make use of the docker-cache. We will
# pull in the actual project files at a later stage
# In order for this to work we also need to create a "fake" myprojectlibrary directory
# we can install the project "normally" because we don't have the actual project files included here
RUN mkdir "$PROJECT_DIR/myprojectlibrary/myprojectlibrary" &&\
    touch "$PROJECT_DIR/myprojectlibrary/myprojectlibrary/__init__.py" &&\
    mkdir "$PROJECT_DIR/myproject/myproject" &&\
    touch "$PROJECT_DIR/myproject/myproject/__init__.py" &&\
    POETRY_VIRTUALENVS_IN_PROJECT=true poetry install --no-dev -vvv

COPY . $PROJECT_DIR
```
where "myproject" depends on "myprojectlibrary" like this in the pyporject.toml file:

`myprojectlibrary = { path = "../myprojectlibrary/", develop = true}`
So given that the above method works, it would probably be sufficient for poetry to simply ignore the library folder inside a python project when called with a flag like the one I mentioned above (`--no-root-nested`). (in my case "myprojectlibrary" and "myproject"). Those would normally get linked when installing subprojects. But that obviously isn't needed in this case ..
What is being asked for right now is very unclear to me, but as best I can tell this is a sub-ask for #2270 -- if that is not correct, please update this issue with a clear explanation of what, from a environment/technical level, is being requested.

Resolving patch:
diff --git a/src/poetry/console/commands/install.py b/src/poetry/console/commands/install.py
--- a/src/poetry/console/commands/install.py
+++ b/src/poetry/console/commands/install.py
@@ -30,6 +30,16 @@ class InstallCommand(InstallerCommand):
         option(
             "no-root", None, "Do not install the root package (the current project)."
         ),
+        option(
+            "no-directory",
+            None,
+            (
+                "Do not install any directory path dependencies; useful to install"
+                " dependencies without source code, e.g. for caching of Docker layers)"
+            ),
+            flag=True,
+            multiple=False,
+        ),
         option(
             "dry-run",
             None,
@@ -148,6 +158,7 @@ def handle(self) -> int:
             with_synchronization = True
 
         self.installer.only_groups(self.activated_groups)
+        self.installer.skip_directory(self.option("no-directory"))
         self.installer.dry_run(self.option("dry-run"))
         self.installer.requires_synchronization(with_synchronization)
         self.installer.executor.enable_bytecode_compilation(self.option("compile"))
diff --git a/src/poetry/installation/installer.py b/src/poetry/installation/installer.py
--- a/src/poetry/installation/installer.py
+++ b/src/poetry/installation/installer.py
@@ -59,6 +59,7 @@ def __init__(
         self._verbose = False
         self._write_lock = True
         self._groups: Iterable[str] | None = None
+        self._skip_directory = False
 
         self._execute_operations = True
         self._lock = False
@@ -150,6 +151,11 @@ def update(self, update: bool = True) -> Installer:
 
         return self
 
+    def skip_directory(self, skip_directory: bool = False) -> Installer:
+        self._skip_directory = skip_directory
+
+        return self
+
     def lock(self, update: bool = True) -> Installer:
         """
         Prepare the installer for locking only.
@@ -334,6 +340,7 @@ def _do_install(self) -> int:
             ops = solver.solve(use_latest=self._whitelist).calculate_operations(
                 with_uninstalls=self._requires_synchronization,
                 synchronize=self._requires_synchronization,
+                skip_directory=self._skip_directory,
             )
 
         if not self._requires_synchronization:
diff --git a/src/poetry/puzzle/transaction.py b/src/poetry/puzzle/transaction.py
--- a/src/poetry/puzzle/transaction.py
+++ b/src/poetry/puzzle/transaction.py
@@ -27,7 +27,11 @@ def __init__(
         self._root_package = root_package
 
     def calculate_operations(
-        self, with_uninstalls: bool = True, synchronize: bool = False
+        self,
+        with_uninstalls: bool = True,
+        synchronize: bool = False,
+        *,
+        skip_directory: bool = False,
     ) -> list[Operation]:
         from poetry.installation.operations import Install
         from poetry.installation.operations import Uninstall
@@ -70,7 +74,10 @@ def calculate_operations(
 
                     break
 
-            if not installed:
+            if not (
+                installed
+                or (skip_directory and result_package.source_type == "directory")
+            ):
                 operations.append(Install(result_package, priority=priority))
 
         if with_uninstalls:


Test patch:
diff --git a/tests/console/commands/test_install.py b/tests/console/commands/test_install.py
--- a/tests/console/commands/test_install.py
+++ b/tests/console/commands/test_install.py
@@ -184,6 +184,24 @@ def test_compile_option_is_passed_to_the_installer(
     enable_bytecode_compilation_mock.assert_called_once_with(compile)
 
 
+@pytest.mark.parametrize("skip_directory_cli_value", [True, False])
+def test_no_directory_is_passed_to_installer(
+    tester: CommandTester, mocker: MockerFixture, skip_directory_cli_value: bool
+):
+    """
+    The --no-directory option is passed to the installer.
+    """
+
+    mocker.patch.object(tester.command.installer, "run", return_value=1)
+
+    if skip_directory_cli_value is True:
+        tester.execute("--no-directory")
+    else:
+        tester.execute()
+
+    assert tester.command.installer._skip_directory is skip_directory_cli_value
+
+
 def test_no_all_extras_doesnt_populate_installer(
     tester: CommandTester, mocker: MockerFixture
 ):
diff --git a/tests/installation/test_installer.py b/tests/installation/test_installer.py
--- a/tests/installation/test_installer.py
+++ b/tests/installation/test_installer.py
@@ -60,12 +60,12 @@ class Executor(BaseExecutor):
     def __init__(self, *args: Any, **kwargs: Any) -> None:
         super().__init__(*args, **kwargs)
 
-        self._installs: list[DependencyPackage] = []
+        self._installs: list[Package] = []
         self._updates: list[DependencyPackage] = []
         self._uninstalls: list[DependencyPackage] = []
 
     @property
-    def installations(self) -> list[DependencyPackage]:
+    def installations(self) -> list[Package]:
         return self._installs
 
     @property
@@ -1276,14 +1276,18 @@ def test_run_installs_with_local_poetry_directory_and_extras(
     assert installer.executor.installations_count == 2
 
 
-def test_run_installs_with_local_poetry_directory_transitive(
+@pytest.mark.parametrize("skip_directory", [True, False])
+def test_run_installs_with_local_poetry_directory_and_skip_directory_flag(
     installer: Installer,
     locker: Locker,
     repo: Repository,
     package: ProjectPackage,
-    tmpdir: Path,
     fixture_dir: FixtureDirGetter,
+    skip_directory: bool,
 ):
+    """When we set Installer.skip_directory(True) no path dependencies should
+    be installed (including transitive dependencies).
+    """
     root_dir = fixture_dir("directory")
     package.root_dir = root_dir
     locker.set_lock_path(root_dir)
@@ -1299,14 +1303,27 @@ def test_run_installs_with_local_poetry_directory_transitive(
     repo.add_package(get_package("pendulum", "1.4.4"))
     repo.add_package(get_package("cachy", "0.2.0"))
 
+    installer.skip_directory(skip_directory)
+
     result = installer.run()
     assert result == 0
 
+    executor: Executor = installer.executor  # type: ignore
+
     expected = fixture("with-directory-dependency-poetry-transitive")
 
     assert locker.written_data == expected
 
-    assert installer.executor.installations_count == 6
+    directory_installs = [
+        p.name for p in executor.installations if p.source_type == "directory"
+    ]
+
+    if skip_directory:
+        assert not directory_installs, directory_installs
+        assert installer.executor.installations_count == 2
+    else:
+        assert directory_installs, directory_installs
+        assert installer.executor.installations_count == 6
 
 
 def test_run_installs_with_local_poetry_file_transitive(