Repository: PrefectHQ/prefect

Issue:
Add `pip_install_requirements` project step
### Summary

Add a deployment action step called `pip_install_requirements`, which can be used to install dependencies from a `requirements.txt` file. This deployment action step will primarily allow users to install the requirements for their flow before flow run execution. We'll only support using `pip` to install from a `requirements.txt` file for this issue.

Note: this step will depend on an upstream step that outputs a `directory` since it will need to know the path to the `requirements.txt` file to use when running `pip install`.

### Example Usage

Install requirement after cloning from 
```yaml
pull:
    - prefect.projects.steps.git_clone_project:
        id: clone-step
        repository: https://github.com/org/repo.git
    - prefect.projects.steps.pip_install_requirements:
        requirements_file: requirements.txt
        directory: "{{ clone-step.directory }}"
        stream_output: False
```

### Details

Args:
- `requirements_file`: The name of the requirements file. Should default to `requirements.txt`.
- `directory`: The directory in which to run `pip install`. Should be optional and default to `None`. If `None`, the step should run `pip install` in the current directory.
- `stream_output`: Whether to stream the output from `pip install` should be streamed to the console. Should default to `True`.



Discussion:

Resolving patch:
diff --git a/src/prefect/projects/steps/__init__.py b/src/prefect/projects/steps/__init__.py
--- a/src/prefect/projects/steps/__init__.py
+++ b/src/prefect/projects/steps/__init__.py
@@ -1,3 +1,3 @@
 from .core import run_step
 from .pull import git_clone_project, set_working_directory
-from .utility import run_shell_script
+from .utility import run_shell_script, pip_install_requirements
diff --git a/src/prefect/projects/steps/utility.py b/src/prefect/projects/steps/utility.py
--- a/src/prefect/projects/steps/utility.py
+++ b/src/prefect/projects/steps/utility.py
@@ -171,3 +171,60 @@ async def run_shell_script(
         "stdout": stdout_sink.getvalue().strip(),
         "stderr": stderr_sink.getvalue().strip(),
     }
+
+
+async def pip_install_requirements(
+    directory: Optional[str] = None,
+    requirements_file: str = "requirements.txt",
+    stream_output: bool = True,
+):
+    """
+    Installs dependencies from a requirements.txt file.
+
+    Args:
+        requirements_file: The requirements.txt to use for installation.
+        directory: The directory the requirements.txt file is in. Defaults to
+            the current working directory.
+        stream_output: Whether to stream the output from pip install should be
+            streamed to the console
+
+    Returns:
+        A dictionary with the keys `stdout` and `stderr` containing the output
+            the `pip install` command
+
+    Raises:
+        subprocess.CalledProcessError: if the pip install command fails for any reason
+
+    Example:
+        ```yaml
+        pull:
+            - prefect.projects.steps.git_clone_project:
+                id: clone-step
+                repository: https://github.com/org/repo.git
+            - prefect.projects.steps.pip_install_requirements:
+                directory: {{ clone-step.directory }}
+                requirements_file: requirements.txt
+                stream_output: False
+        ```
+    """
+    stdout_sink = io.StringIO()
+    stderr_sink = io.StringIO()
+
+    async with open_process(
+        [sys.executable, "-m", "pip", "install", "-r", requirements_file],
+        stdout=subprocess.PIPE,
+        stderr=subprocess.PIPE,
+        cwd=directory,
+    ) as process:
+        await _stream_capture_process_output(
+            process,
+            stdout_sink=stdout_sink,
+            stderr_sink=stderr_sink,
+            stream_output=stream_output,
+        )
+        await process.wait()
+
+    return {
+        "stdout": stdout_sink.getvalue().strip(),
+        "stderr": stderr_sink.getvalue().strip(),
+    }


Test patch:
diff --git a/tests/projects/test_steps.py b/tests/projects/test_steps.py
--- a/tests/projects/test_steps.py
+++ b/tests/projects/test_steps.py
@@ -1,6 +1,7 @@
 from pathlib import Path
 import sys
-from unittest.mock import MagicMock, ANY
+from prefect.testing.utilities import AsyncMock, MagicMock
+from unittest.mock import ANY
 import pytest
 
 from prefect.blocks.system import Secret
@@ -316,3 +317,130 @@ async def test_run_shell_script_in_directory(self):
         )
         assert result["stdout"] == parent_dir
         assert result["stderr"] == ""
+
+
+class TestPipInstallRequirements:
+    async def test_pip_install_reqs_runs_expected_command(self, monkeypatch):
+        open_process_mock = MagicMock()
+        monkeypatch.setattr(
+            "prefect.projects.steps.utility.open_process",
+            open_process_mock,
+        )
+
+        mock_stream_capture = AsyncMock()
+
+        monkeypatch.setattr(
+            "prefect.projects.steps.utility._stream_capture_process_output",
+            mock_stream_capture,
+        )
+
+        await run_step(
+            {
+                "prefect.projects.steps.pip_install_requirements": {
+                    "id": "pip-install-step"
+                }
+            }
+        )
+
+        open_process_mock.assert_called_once_with(
+            [sys.executable, "-m", "pip", "install", "-r", "requirements.txt"],
+            cwd=None,
+            stderr=ANY,
+            stdout=ANY,
+        )
+
+    async def test_pip_install_reqs_custom_requirements_file(self, monkeypatch):
+        open_process_mock = MagicMock()
+        monkeypatch.setattr(
+            "prefect.projects.steps.utility.open_process",
+            open_process_mock,
+        )
+
+        mock_stream_capture = AsyncMock()
+
+        monkeypatch.setattr(
+            "prefect.projects.steps.utility._stream_capture_process_output",
+            mock_stream_capture,
+        )
+
+        await run_step(
+            {
+                "prefect.projects.steps.pip_install_requirements": {
+                    "id": "pip-install-step",
+                    "requirements_file": "dev-requirements.txt",
+                }
+            }
+        )
+
+        open_process_mock.assert_called_once_with(
+            [sys.executable, "-m", "pip", "install", "-r", "dev-requirements.txt"],
+            cwd=None,
+            stderr=ANY,
+            stdout=ANY,
+        )
+
+    async def test_pip_install_reqs_with_directory_step_output_succeeds(
+        self, monkeypatch
+    ):
+        subprocess_mock = MagicMock()
+        monkeypatch.setattr(
+            "prefect.projects.steps.pull.subprocess",
+            subprocess_mock,
+        )
+
+        open_process_mock = MagicMock()
+        monkeypatch.setattr(
+            "prefect.projects.steps.utility.open_process",
+            open_process_mock,
+        )
+
+        mock_stream_capture = AsyncMock()
+
+        monkeypatch.setattr(
+            "prefect.projects.steps.utility._stream_capture_process_output",
+            mock_stream_capture,
+        )
+
+        steps = [
+            {
+                "prefect.projects.steps.git_clone_project": {
+                    "id": "clone-step",
+                    "repository": "https://github.com/PrefectHQ/hello-projects.git",
+                }
+            },
+            {
+                "prefect.projects.steps.pip_install_requirements": {
+                    "id": "pip-install-step",
+                    "directory": "{{ clone-step.directory }}",
+                    "requirements_file": "requirements.txt",
+                }
+            },
+        ]
+
+        step_outputs = {
+            "clone-step": {"directory": "hello-projects"},
+            "directory": "hello-projects",
+            "pip-install-step": {"stdout": "", "stderr": ""},
+            "stdout": "",
+            "stderr": "",
+        }
+
+        open_process_mock.run.return_value = MagicMock(**step_outputs)
+
+        output = await run_steps(steps)
+
+        assert output == step_outputs
+
+        open_process_mock.assert_called_once_with(
+            [
+                sys.executable,
+                "-m",
+                "pip",
+                "install",
+                "-r",
+                "requirements.txt",
+            ],
+            cwd="hello-projects",
+            stderr=ANY,
+            stdout=ANY,
+        )