Repository: pdm-project/pdm

Issue:
Supporting adding `extra` annotations to the exported `requirements` file
## Is your feature/enhancement proposal related to a problem? Please describe.

I am using https://github.com/bazelbuild/rules_python to manage my Python project and I noticed that the `pdm` is a really nice way to generate a cross-platform lock file `pdm.lock`. Moreover, it also has a feature to export the lock file to `requirements.txt`, which is `cross-platform`, i.e. it has annotations on what should be installed on which platform. Having a single lock file for multiple platforms would be great, but unfortunately it does not work at the moment with `rules_python` because the extras are removed as per #741.

At least for now, the latest `rules_python` version relies on those `extras` annotations to be present in the `requirements` file and I would love `pdm` to be able to provide those too.

## Describe the solution you'd like

A CLI flag for the `pdm export --format requirements` command to add `--extras`.

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/pdm-project/pdm/pull/2928?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 85.33%. Comparing base [(`3275dfd`)](https://app.codecov.io/gh/pdm-project/pdm/commit/3275dfd082df245956f8999caa595999b5beb7f7?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) to head [(`5ec43b9`)](https://app.codecov.io/gh/pdm-project/pdm/commit/5ec43b91a0018dbd8cc32a19bbbdd665f999dc72?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).


<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #2928      +/-   ##
==========================================
+ Coverage   85.32%   85.33%   +0.01%     
==========================================
  Files         106      106              
  Lines       10676    10687      +11     
  Branches     2326     2330       +4     
==========================================
+ Hits         9109     9120      +11     
  Misses       1094     1094              
  Partials      473      473              
```

| [Flag](https://app.codecov.io/gh/pdm-project/pdm/pull/2928/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/pdm-project/pdm/pull/2928/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) | `85.11% <100.00%> (+0.01%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#carryforward-flags-in-the-pull-request-comment) to find out more.


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/pdm-project/pdm/pull/2928?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).


Patch:
diff --git a/news/2519.feature.md b/news/2519.feature.md
new file mode 100644
index 00000000..fb97da18
--- /dev/null
+++ b/news/2519.feature.md
@@ -0,0 +1,1 @@
+Add `--no-extras` to `pdm export` to strip extras from the requirements. Now the default behavior is to keep extras.
diff --git a/src/pdm/cli/commands/export.py b/src/pdm/cli/commands/export.py
index 7a8da040..8e67b401 100644
--- a/src/pdm/cli/commands/export.py
+++ b/src/pdm/cli/commands/export.py
@@ -11,7 +11,7 @@ from pdm.cli.options import groups_group, lockfile_option
 from pdm.exceptions import PdmUsageError
 from pdm.formats import FORMATS
 from pdm.models.candidates import Candidate
-from pdm.models.requirements import Requirement
+from pdm.models.requirements import Requirement, strip_extras
 from pdm.project import Project
 
 
@@ -39,6 +39,9 @@ class Command(BaseCommand):
         parser.add_argument(
             "--no-markers", action="store_false", default=True, dest="markers", help="Don't include platform markers"
         )
+        parser.add_argument(
+            "--no-extras", action="store_false", default=True, dest="extras", help="Strip extras from the requirements"
+        )
         parser.add_argument(
             "-o",
             "--output",
@@ -73,9 +76,18 @@ class Command(BaseCommand):
             candidates = resolve_candidates_from_lockfile(
                 project, requirements.values(), groups=set(selection), cross_platform=options.markers
             )
-            # Remove candidates with [extras] because the bare candidates are already
-            # included
-            packages = (candidate for candidate in candidates.values() if not candidate.req.extras)
+
+            filtered_candidates: dict[str, Candidate] = {}
+            for k, candidate in candidates.items():
+                if options.extras:
+                    if candidate.req.extras:
+                        k = strip_extras(k)[0]
+                    elif k in filtered_candidates:
+                        continue
+                elif candidate.req.extras:
+                    continue
+                filtered_candidates[k] = candidate
+            packages = filtered_candidates.values()
 
         content = FORMATS[options.format].export(project, packages, options)
         if options.output:
diff --git a/src/pdm/cli/completions/pdm.bash b/src/pdm/cli/completions/pdm.bash
index ab470d8b..533b4577 100644
--- a/src/pdm/cli/completions/pdm.bash
+++ b/src/pdm/cli/completions/pdm.bash
@@ -49,7 +49,7 @@ _pdm_a919b69078acdf0a_complete()
             ;;
 
             (export)
-            opts="--dev --editable-self --expandvars --format --global --group --help --lockfile --no-default --no-markers --output --production --project --pyproject --quiet --self --verbose --without --without-hashes"
+            opts="--dev --editable-self --expandvars --format --global --group --help --lockfile --no-default --no-extras --no-markers --output --production --project --pyproject --quiet --self --verbose --without --without-hashes"
             ;;
 
             (fix)
diff --git a/src/pdm/cli/completions/pdm.fish b/src/pdm/cli/completions/pdm.fish
index 1a702ff8..3c380840 100644
--- a/src/pdm/cli/completions/pdm.fish
+++ b/src/pdm/cli/completions/pdm.fish
@@ -125,6 +125,7 @@ complete -c pdm -A -n '__fish_seen_subcommand_from export' -l group -d 'Select g
 complete -c pdm -A -n '__fish_seen_subcommand_from export' -l help -d 'Show this help message and exit.'
 complete -c pdm -A -n '__fish_seen_subcommand_from export' -l lockfile -d 'Specify another lockfile path. Default: pdm.lock. [env var: PDM_LOCKFILE]'
 complete -c pdm -A -n '__fish_seen_subcommand_from export' -l no-default -d 'Don\'t include dependencies from the default group'
+complete -c pdm -A -n '__fish_seen_subcommand_from export' -l no-extras -d 'Strip extras from the requirements'
 complete -c pdm -A -n '__fish_seen_subcommand_from export' -l no-markers -d 'Don\'t include platform markers'
 complete -c pdm -A -n '__fish_seen_subcommand_from export' -l output -d 'Write output to the given file, or print to stdout if not given'
 complete -c pdm -A -n '__fish_seen_subcommand_from export' -l production -d 'Unselect dev dependencies'
diff --git a/src/pdm/cli/completions/pdm.ps1 b/src/pdm/cli/completions/pdm.ps1
index adefd861..0faaee2a 100644
--- a/src/pdm/cli/completions/pdm.ps1
+++ b/src/pdm/cli/completions/pdm.ps1
@@ -253,7 +253,7 @@ function TabExpansion($line, $lastWord) {
                 $completer.AddOpts(@(
                         [Option]::new(@(
                             "--dev", "--output", "--global", "--no-default", "--expandvars", "--prod", "--production", "-g", "-d", "-o",
-                            "--no-hashes", "--no-markers", "-L", "--lockfile", "--self", "--editable-self"
+                            "--no-hashes", "--no-markers", "-L", "--lockfile", "--self", "--editable-self", "--no-extras"
                         )),
                         $formatOption,
                         $sectionOption,
diff --git a/src/pdm/cli/completions/pdm.zsh b/src/pdm/cli/completions/pdm.zsh
index 9c5e3844..c045f4d8 100644
--- a/src/pdm/cli/completions/pdm.zsh
+++ b/src/pdm/cli/completions/pdm.zsh
@@ -156,6 +156,7 @@ _pdm() {
         {-f+,--format+}"[Only requirements.txt is supported for now.]:format:(requirements)"
         "--no-hashes[Don't include artifact hashes]"
         "--no-markers[Don't include platform markers]"
+        "--no-extras[Strip extras from the requirements]"
         "--expandvars[Expand environment variables in requirements]"
         "--self[Include the project itself]"
         "--editable-self[Include the project itself as an editable dependency]"


Test patch:
diff --git a/tests/cli/test_others.py b/tests/cli/test_others.py
index 7a5123dc..04321474 100644
--- a/tests/cli/test_others.py
+++ b/tests/cli/test_others.py
@@ -202,13 +202,16 @@ def test_export_to_requirements_txt(pdm, fixture_project):
     assert (project.root / "requirements_output.txt").read_text() == requirements_txt.read_text()
 
 
-def test_export_doesnt_include_dep_with_extras(pdm, fixture_project):
+@pytest.mark.parametrize("extra_opt", [[], ["--no-extras"]])
+def test_export_doesnt_include_dep_with_extras(pdm, fixture_project, extra_opt):
     project = fixture_project("demo-package-has-dep-with-extras")
-    requirements_txt = project.root / "requirements.txt"
 
-    result = pdm(["export", "--without-hashes"], obj=project)
+    result = pdm(["export", "--without-hashes", *extra_opt], obj=project)
     assert result.exit_code == 0
-    assert result.output.strip() == requirements_txt.read_text().strip()
+    if extra_opt:
+        assert "requests==2.26.0" in result.output.splitlines()
+    else:
+        assert "requests[security]==2.26.0" in result.output.splitlines()
 
 
 def test_completion_command(pdm):