Repository: pdm-project/pdm

Issue:
Support for `--contstraint` file
## Is your feature/enhancement proposal related to a problem? Please describe.

PIP allows the use of constraint files. You can find more about it here https://pip.pypa.io/en/stable/user_guide/#constraints-files
Apache Airflow is one of the projects that heavily relies on the constraints file. We are having many problems with not being able to use the constraint file. We have to export dependencies to requirements.txt, add this constraint file location, and use PIP to install dependencies instead of using `pdm install` and leveraging `pdm.lock`.

I know PDM has a [similar feature](https://pdm-project.org/latest/usage/config/#override-the-resolved-package-versions), but it is not usable with Apache Airflow. Here is an example of it https://raw.githubusercontent.com/apache/airflow/constraints-2.8.1/constraints-3.11.txt. As you can see it is over 700 lines, moving all of them to the `pyproject.toml` is a huge mess. 

Another advantage of being able to specify constraint file is being able to host this file at some centralized location like Apache Airflow does.

It should be nice if PDM supports it. This is a blocker for us and I believe for many teams.

## Describe the solution you'd like

Similar to resolution overrides, I can think of something like this
```
[tool.pdm.resolution]
constraint-file = "https://raw.githubusercontent.com/apache/airflow/constraints-2.8.1/constraints-3.11.txt"
```

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/pdm-project/pdm/pull/3003?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) Report
Attention: Patch coverage is `85.10638%` with `7 lines` in your changes missing coverage. Please review.
> Project coverage is 85.66%. Comparing base [(`8068a36`)](https://app.codecov.io/gh/pdm-project/pdm/commit/8068a3641a338ee406126dff029d7cb4eb89154f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) to head [(`0873981`)](https://app.codecov.io/gh/pdm-project/pdm/commit/087398153508704488f2c8fe6ff08bb3dc81af40?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).

| [Files](https://app.codecov.io/gh/pdm-project/pdm/pull/3003?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) | Patch % | Lines |
|---|---|---|
| [src/pdm/formats/requirements.py](https://app.codecov.io/gh/pdm-project/pdm/pull/3003?src=pr&el=tree&filepath=src%2Fpdm%2Fformats%2Frequirements.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9mb3JtYXRzL3JlcXVpcmVtZW50cy5weQ==) | 72.00% | [5 Missing and 2 partials :warning: ](https://app.codecov.io/gh/pdm-project/pdm/pull/3003?src=pr&el=tree&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    #3003      +/-   ##
==========================================
+ Coverage   85.61%   85.66%   +0.04%     
==========================================
  Files         109      109              
  Lines       10869    10890      +21     
  Branches     2371     2376       +5     
==========================================
+ Hits         9306     9329      +23     
+ Misses       1076     1075       -1     
+ Partials      487      486       -1     
```

| [Flag](https://app.codecov.io/gh/pdm-project/pdm/pull/3003/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/3003/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) | `85.47% <85.10%> (+0.04%)` | :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/3003?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/docs/usage/dependency.md b/docs/usage/dependency.md
index 658db5d4..389e650e 100644
--- a/docs/usage/dependency.md
+++ b/docs/usage/dependency.md
@@ -312,6 +312,35 @@ Besides, if you don't want the root project to be installed, add `--no-self` opt
 
 You may also use the pdm lock command with these options to lock only the specified groups, which will be recorded in the `[metadata]` table of the lock file. If no `--group/--prod/--dev/--no-default` option is specified, `pdm sync` and `pdm update` will operate using the groups in the lockfile. However, if any groups that are not included in the lockfile are given as arguments to the commands, PDM will raise an error.
 
+## Constraint files
+
++++ 2.17.0
+
+PDM also supports [constraint files](https://pip.pypa.io/en/stable/user_guide/#constraints-files) to limit the versions of packages to be resolved or installed.
+
+Constraint files are pip-style requirement files that only control which version of a requirement is installed, not whether it is installed or not. The syntax is the same as the pip's `requirements.txt` file:
+
+```
+# This is a pip constraints file
+requests==2.20.0
+django==1.11.8
+certifi==2018.11.17
+chardet==3.0.4
+idna==2.7
+pytz==2019.3
+urllib3==1.23
+```
+
+And you can pass the constraint file to various PDM commands that would perform a resolution, such as [`pdm install`](../reference/cli.md#install), [`pdm lock`](../reference/cli.md#lock), [`pdm add`](../reference/cli.md#add), etc.
+
+```bash
+pdm lock -c constraints.txt
+```
+
+Note if the lock file is already present and up-to-date, `pdm install` won't perform a resolution and hence the constraint file won't be used.
+
+Constraints files can also be served via a URL, e.g. http://example.com/constraints.txt, so that your organization can store and serve them in a centralized place.
+
 ## Show what packages are installed
 
 Similar to `pip list`, you can list all packages installed in the packages directory:
diff --git a/news/2896.feature.md b/news/2896.feature.md
new file mode 100644
index 00000000..4bddf922
--- /dev/null
+++ b/news/2896.feature.md
@@ -0,0 +1,1 @@
+Support reading requirement constraints from pip-style constraints files via `-c`/`--constraint` option.
diff --git a/src/pdm/cli/commands/add.py b/src/pdm/cli/commands/add.py
index 3dc36ca4..12391989 100644
--- a/src/pdm/cli/commands/add.py
+++ b/src/pdm/cli/commands/add.py
@@ -7,6 +7,7 @@ from pdm.cli.commands.base import BaseCommand
 from pdm.cli.filters import GroupSelection
 from pdm.cli.hooks import HookManager
 from pdm.cli.options import (
+    constraint_option,
     dry_run_option,
     frozen_lockfile_option,
     install_group,
@@ -36,6 +37,7 @@ class Command(BaseCommand):
         lockfile_option,
         frozen_lockfile_option,
         save_strategy_group,
+        constraint_option,
         update_strategy_group,
         prerelease_option,
         unconstrained_option,
diff --git a/src/pdm/cli/commands/install.py b/src/pdm/cli/commands/install.py
index d525b109..efa60920 100644
--- a/src/pdm/cli/commands/install.py
+++ b/src/pdm/cli/commands/install.py
@@ -7,6 +7,7 @@ from pdm.cli.commands.base import BaseCommand
 from pdm.cli.filters import GroupSelection
 from pdm.cli.hooks import HookManager
 from pdm.cli.options import (
+    constraint_option,
     dry_run_option,
     frozen_lockfile_option,
     groups_group,
@@ -25,6 +26,7 @@ class Command(BaseCommand):
         *BaseCommand.arguments,
         groups_group,
         install_group,
+        constraint_option,
         dry_run_option,
         lockfile_option,
         frozen_lockfile_option,
diff --git a/src/pdm/cli/commands/lock.py b/src/pdm/cli/commands/lock.py
index 48e3766f..de86148b 100644
--- a/src/pdm/cli/commands/lock.py
+++ b/src/pdm/cli/commands/lock.py
@@ -10,6 +10,7 @@ from pdm.cli.filters import GroupSelection
 from pdm.cli.hooks import HookManager
 from pdm.cli.options import (
     config_setting_option,
+    constraint_option,
     groups_group,
     lock_strategy_group,
     lockfile_option,
@@ -30,6 +31,7 @@ class Command(BaseCommand):
         lockfile_option,
         no_isolation_option,
         config_setting_option,
+        constraint_option,
         skip_option,
         groups_group,
         lock_strategy_group,
diff --git a/src/pdm/cli/commands/remove.py b/src/pdm/cli/commands/remove.py
index 8b92f3a6..a880ffdb 100644
--- a/src/pdm/cli/commands/remove.py
+++ b/src/pdm/cli/commands/remove.py
@@ -7,6 +7,7 @@ from pdm.cli.commands.base import BaseCommand
 from pdm.cli.filters import GroupSelection
 from pdm.cli.hooks import HookManager
 from pdm.cli.options import (
+    constraint_option,
     dry_run_option,
     frozen_lockfile_option,
     install_group,
@@ -31,6 +32,7 @@ class Command(BaseCommand):
         install_group,
         dry_run_option,
         lockfile_option,
+        constraint_option,
         frozen_lockfile_option,
         skip_option,
         venv_option,
diff --git a/src/pdm/cli/commands/update.py b/src/pdm/cli/commands/update.py
index af2416dd..b57c259a 100644
--- a/src/pdm/cli/commands/update.py
+++ b/src/pdm/cli/commands/update.py
@@ -8,6 +8,7 @@ from pdm.cli.commands.base import BaseCommand
 from pdm.cli.filters import GroupSelection
 from pdm.cli.hooks import HookManager
 from pdm.cli.options import (
+    constraint_option,
     frozen_lockfile_option,
     groups_group,
     install_group,
@@ -38,6 +39,7 @@ class Command(BaseCommand):
         lockfile_option,
         frozen_lockfile_option,
         save_strategy_group,
+        constraint_option,
         update_strategy_group,
         prerelease_option,
         unconstrained_option,
diff --git a/src/pdm/cli/completions/pdm.bash b/src/pdm/cli/completions/pdm.bash
index 31697b36..32d9a1af 100644
--- a/src/pdm/cli/completions/pdm.bash
+++ b/src/pdm/cli/completions/pdm.bash
@@ -29,7 +29,7 @@ _pdm_a919b69078acdf0a_complete()
         case "$com" in
 
             (add)
-            opts="--config-setting --dev --dry-run --editable --fail-fast --frozen-lockfile --global --group --help --lockfile --no-editable --no-isolation --no-self --no-sync --prerelease --project --quiet --save-compatible --save-exact --save-minimum --save-wildcard --skip --stable --unconstrained --update-all --update-eager --update-reuse --update-reuse-installed --venv --verbose"
+            opts="--config-setting --constraint --dev --dry-run --editable --fail-fast --frozen-lockfile --global --group --help --lockfile --no-editable --no-isolation --no-self --no-sync --prerelease --project --quiet --save-compatible --save-exact --save-minimum --save-wildcard --skip --stable --unconstrained --update-all --update-eager --update-reuse --update-reuse-installed --venv --verbose"
             ;;
 
             (build)
@@ -69,7 +69,7 @@ _pdm_a919b69078acdf0a_complete()
             ;;
 
             (install)
-            opts="--check --config-setting --dev --dry-run --fail-fast --frozen-lockfile --global --group --help --lockfile --no-default --no-editable --no-isolation --no-self --plugins --production --project --quiet --skip --venv --verbose --without"
+            opts="--check --config-setting --constraint --dev --dry-run --fail-fast --frozen-lockfile --global --group --help --lockfile --no-default --no-editable --no-isolation --no-self --plugins --production --project --quiet --skip --venv --verbose --without"
             ;;
 
             (list)
@@ -77,7 +77,7 @@ _pdm_a919b69078acdf0a_complete()
             ;;
 
             (lock)
-            opts="--append --check --config-setting --dev --exclude-newer --global --group --help --implementation --lockfile --no-cross-platform --no-default --no-isolation --no-static-urls --platform --production --project --python --quiet --refresh --skip --static-urls --strategy --update-reuse --update-reuse-installed --verbose --without"
+            opts="--append --check --config-setting --constraint --dev --exclude-newer --global --group --help --implementation --lockfile --no-cross-platform --no-default --no-isolation --no-static-urls --platform --production --project --python --quiet --refresh --skip --static-urls --strategy --update-reuse --update-reuse-installed --verbose --without"
             ;;
 
             (outdated)
@@ -101,7 +101,7 @@ _pdm_a919b69078acdf0a_complete()
             ;;
 
             (remove)
-            opts="--config-setting --dev --dry-run --fail-fast --frozen-lockfile --global --group --help --lockfile --no-editable --no-isolation --no-self --no-sync --project --quiet --skip --venv --verbose"
+            opts="--config-setting --constraint --dev --dry-run --fail-fast --frozen-lockfile --global --group --help --lockfile --no-editable --no-isolation --no-self --no-sync --project --quiet --skip --venv --verbose"
             ;;
 
             (run)
@@ -125,7 +125,7 @@ _pdm_a919b69078acdf0a_complete()
             ;;
 
             (update)
-            opts="--config-setting --dev --fail-fast --frozen-lockfile --global --group --help --lockfile --no-default --no-editable --no-isolation --no-self --no-sync --outdated --prerelease --production --project --quiet --save-compatible --save-exact --save-minimum --save-wildcard --skip --stable --top --unconstrained --update-all --update-eager --update-reuse --update-reuse-installed --venv --verbose --without"
+            opts="--config-setting --constraint --dev --fail-fast --frozen-lockfile --global --group --help --lockfile --no-default --no-editable --no-isolation --no-self --no-sync --outdated --prerelease --production --project --quiet --save-compatible --save-exact --save-minimum --save-wildcard --skip --stable --top --unconstrained --update-all --update-eager --update-reuse --update-reuse-installed --venv --verbose --without"
             ;;
 
             (use)
diff --git a/src/pdm/cli/completions/pdm.fish b/src/pdm/cli/completions/pdm.fish
index d70c238a..5dbf1557 100644
--- a/src/pdm/cli/completions/pdm.fish
+++ b/src/pdm/cli/completions/pdm.fish
@@ -24,6 +24,7 @@ complete -c pdm -n '__fish_pdm_a919b69078acdf0a_complete_no_subcommand' -l versi
 # add
 complete -c pdm -f -n '__fish_pdm_a919b69078acdf0a_complete_no_subcommand' -a add -d 'Add package(s) to pyproject.toml and install them'
 complete -c pdm -A -n '__fish_seen_subcommand_from add' -l config-setting -d 'Pass options to the builder. options with a value must be specified after "=": `--config-setting=key(=value)` or `-Ckey(=value)`'
+complete -c pdm -A -n '__fish_seen_subcommand_from add' -l constraint -d 'Use the constraint file in pip-requirements format. [env var: PDM_CONSTRAINT] This option can be used multiple times. See https://pip.pypa.io/en/stable/user_guide/#constraints-files'
 complete -c pdm -A -n '__fish_seen_subcommand_from add' -l dev -d 'Add packages into dev dependencies'
 complete -c pdm -A -n '__fish_seen_subcommand_from add' -l dry-run -d 'Show the difference only and don\'t perform any action'
 complete -c pdm -A -n '__fish_seen_subcommand_from add' -l editable -d 'Specify editable packages'
@@ -193,6 +194,7 @@ complete -c pdm -A -n '__fish_seen_subcommand_from init' -l verbose -d 'Use `-v`
 complete -c pdm -f -n '__fish_pdm_a919b69078acdf0a_complete_no_subcommand' -a install -d 'Install dependencies from lock file'
 complete -c pdm -A -n '__fish_seen_subcommand_from install' -l check -d 'Check if the lock file is up to date and fail otherwise'
 complete -c pdm -A -n '__fish_seen_subcommand_from install' -l config-setting -d 'Pass options to the builder. options with a value must be specified after "=": `--config-setting=key(=value)` or `-Ckey(=value)`'
+complete -c pdm -A -n '__fish_seen_subcommand_from install' -l constraint -d 'Use the constraint file in pip-requirements format. [env var: PDM_CONSTRAINT] This option can be used multiple times. See https://pip.pypa.io/en/stable/user_guide/#constraints-files'
 complete -c pdm -A -n '__fish_seen_subcommand_from install' -l dev -d 'Select dev dependencies'
 complete -c pdm -A -n '__fish_seen_subcommand_from install' -l dry-run -d 'Show the difference only and don\'t perform any action'
 complete -c pdm -A -n '__fish_seen_subcommand_from install' -l fail-fast -d 'Abort on first installation error'
@@ -239,6 +241,7 @@ complete -c pdm -f -n '__fish_pdm_a919b69078acdf0a_complete_no_subcommand' -a lo
 complete -c pdm -A -n '__fish_seen_subcommand_from lock' -l append -d 'Append the result to the current lock file'
 complete -c pdm -A -n '__fish_seen_subcommand_from lock' -l check -d 'Check if the lock file is up to date and quit'
 complete -c pdm -A -n '__fish_seen_subcommand_from lock' -l config-setting -d 'Pass options to the builder. options with a value must be specified after "=": `--config-setting=key(=value)` or `-Ckey(=value)`'
+complete -c pdm -A -n '__fish_seen_subcommand_from lock' -l constraint -d 'Use the constraint file in pip-requirements format. [env var: PDM_CONSTRAINT] This option can be used multiple times. See https://pip.pypa.io/en/stable/user_guide/#constraints-files'
 complete -c pdm -A -n '__fish_seen_subcommand_from lock' -l dev -d 'Select dev dependencies'
 complete -c pdm -A -n '__fish_seen_subcommand_from lock' -l exclude-newer -d 'Exclude packages newer than the given UTC date in format `YYYY-MM-DD[THH:MM:SSZ]`'
 complete -c pdm -A -n '__fish_seen_subcommand_from lock' -l global -d 'Use the global project, supply the project root with `-p` option'
@@ -383,6 +386,7 @@ complete -c pdm -A -n '__fish_seen_subcommand_from python; and __fish_seen_subco
 # remove
 complete -c pdm -f -n '__fish_pdm_a919b69078acdf0a_complete_no_subcommand' -a remove -d 'Remove packages from pyproject.toml'
 complete -c pdm -A -n '__fish_seen_subcommand_from remove' -l config-setting -d 'Pass options to the builder. options with a value must be specified after "=": `--config-setting=key(=value)` or `-Ckey(=value)`'
+complete -c pdm -A -n '__fish_seen_subcommand_from remove' -l constraint -d 'Use the constraint file in pip-requirements format. [env var: PDM_CONSTRAINT] This option can be used multiple times. See https://pip.pypa.io/en/stable/user_guide/#constraints-files'
 complete -c pdm -A -n '__fish_seen_subcommand_from remove' -l dev -d 'Remove packages from dev dependencies'
 complete -c pdm -A -n '__fish_seen_subcommand_from remove' -l dry-run -d 'Show the difference only and don\'t perform any action'
 complete -c pdm -A -n '__fish_seen_subcommand_from remove' -l fail-fast -d 'Abort on first installation error'
@@ -502,6 +506,7 @@ complete -c pdm -A -n '__fish_seen_subcommand_from sync' -l without -d 'Exclude
 # update
 complete -c pdm -f -n '__fish_pdm_a919b69078acdf0a_complete_no_subcommand' -a update -d 'Update package(s) in pyproject.toml'
 complete -c pdm -A -n '__fish_seen_subcommand_from update' -l config-setting -d 'Pass options to the builder. options with a value must be specified after "=": `--config-setting=key(=value)` or `-Ckey(=value)`'
+complete -c pdm -A -n '__fish_seen_subcommand_from update' -l constraint -d 'Use the constraint file in pip-requirements format. [env var: PDM_CONSTRAINT] This option can be used multiple times. See https://pip.pypa.io/en/stable/user_guide/#constraints-files'
 complete -c pdm -A -n '__fish_seen_subcommand_from update' -l dev -d 'Select dev dependencies'
 complete -c pdm -A -n '__fish_seen_subcommand_from update' -l fail-fast -d 'Abort on first installation error'
 complete -c pdm -A -n '__fish_seen_subcommand_from update' -l frozen-lockfile -d 'Don\'t try to create or update the lockfile. [env var: PDM_FROZEN_LOCKFILE]'
diff --git a/src/pdm/cli/completions/pdm.ps1 b/src/pdm/cli/completions/pdm.ps1
index b8495ad4..cd187f8d 100644
--- a/src/pdm/cli/completions/pdm.ps1
+++ b/src/pdm/cli/completions/pdm.ps1
@@ -212,7 +212,7 @@ function TabExpansion($line, $lastWord) {
             "add" {
                 $completer.AddOpts(@(
                         [Option]::new((
-                            "-d", "--dev", "--save-compatible", "--save-wildcard", "--dry-run", "--save-exact",
+                            "-d", "--dev", "--save-compatible", "--save-wildcard", "--dry-run", "--save-exact", "-c", "--constraint",
                             "--save-minimum", "--update-eager", "--update-reuse", "--update-all", "-g", "--global",
                             "--no-sync", "--no-editable", "--no-self", "-u", "--unconstrained", "--no-isolation", "-C", "--config-setting", "--stable",
                             "--pre", "--prerelease", "-L", "--lockfile", "--fail-fast", "-x", "--frozen-lockfile", "--update-reuse-installed"
@@ -304,7 +304,7 @@ function TabExpansion($line, $lastWord) {
                         [Option]::new((
                             "-d", "--dev", "-g", "--global", "--dry-run", "--no-default", "--frozen-lockfile", "--prod",
                             "--production", "--no-editable", "--no-self", "-C", "--config-setting", "--no-isolation", "--check", "-L",
-                            "--lockfile", "--fail-fast", "-x", "--plugins"
+                            "--lockfile", "--fail-fast", "-x", "--plugins", "-c", "--constraint"
                         )),
                         $sectionOption,
                         $skipOption,
@@ -330,7 +330,7 @@ function TabExpansion($line, $lastWord) {
                     @(
                         [Option]::new(@(
                             "--global", "-g", "-C", "--config-setting", "--no-isolation", "--refresh", "-L", "--lockfile", "--check", "--dev", "--prod",
-                            "--production", "-d", "--no-default", "--no-cross-platform", "--static-urls", "--no-static-urls",
+                            "--production", "-d", "--no-default", "--no-cross-platform", "--static-urls", "--no-static-urls", "-c", "--constraint",
                             "--strategy", "-S", "--update-reuse", "--update-reuse-installed", "--exclude-newer", "--append",
                             "--platform", "--python", "--implementation"
                         )),
@@ -417,7 +417,7 @@ function TabExpansion($line, $lastWord) {
                 $completer.AddOpts(
                     @(
                         [Option]::new(@(
-                            "--global", "-g", "--dev", "-d", "--dry-run", "--no-sync", "--no-editable", "--no-self",
+                            "--global", "-g", "--dev", "-d", "--dry-run", "--no-sync", "--no-editable", "--no-self", "-c", "--constraint",
                             "-C", "--config-setting", "--no-isolation", "-L", "--lockfile", "--fail-fast", "-x", "--frozen-lockfile"
                         )),
                         $projectOption,
@@ -470,7 +470,7 @@ function TabExpansion($line, $lastWord) {
                             "--save-minimum", "--update-eager", "--update-reuse", "--update-all", "-g", "--global", "--dry-run",
                             "--outdated", "--top", "-u", "--unconstrained", "--no-editable", "--no-self", "--no-isolation",
                             "--no-sync", "--pre", "--prerelease", "-L", "--lockfile", "--fail-fast", "-x", "--frozen-lockfile",
-                            "-C", "--config-setting", "--update-reuse-installed", "--stable"
+                            "-C", "--config-setting", "--update-reuse-installed", "--stable", "-c", "--constraint"
                         )),
                         $sectionOption,
                         $skipOption,
diff --git a/src/pdm/cli/completions/pdm.zsh b/src/pdm/cli/completions/pdm.zsh
index 15aae611..f9d42f98 100644
--- a/src/pdm/cli/completions/pdm.zsh
+++ b/src/pdm/cli/completions/pdm.zsh
@@ -68,6 +68,7 @@ _pdm() {
         {-d,--dev}'[Add packages into dev dependencies]'
         {-G,--group}'[Specify the target dependency group to add into]:group:_pdm_groups'
         {-L,--lockfile}'[Specify another lockfile path, or use `PDM_LOCKFILE` env variable. Default: pdm.lock]:lockfile:_files'
+        {-c+,--constraint+}'[Use the constraint file in pip-requirements format. \[env var: PDM_CONSTRAINT\] This option can be used multiple times. See https://pip.pypa.io/en/stable/user_guide/#constraints-files]:constraint:_files'
         '--no-sync[Only write pyproject.toml and do not sync the working set]'
         '--save-compatible[Save compatible version specifiers]'
         '--save-wildcard[Save wildcard version specifiers]'
@@ -217,6 +218,7 @@ _pdm() {
         "--without+[Exclude groups of optional-dependencies or dev-dependencies]:group:_pdm_groups"
         {-d,--dev}"[Select dev dependencies]"
         {-L,--lockfile}'[Specify another lockfile path, or use `PDM_LOCKFILE` env variable. Default: pdm.lock]:lockfile:_files'
+        {-c+,--constraint+}'[Use the constraint file in pip-requirements format. \[env var: PDM_CONSTRAINT\] This option can be used multiple times. See https://pip.pypa.io/en/stable/user_guide/#constraints-files]:constraint:_files'
         {--prod,--production}"[Unselect dev dependencies]"
         {-k,--skip}'[Skip some tasks and/or hooks by their comma-separated names]'
         "--frozen-lockfile[Don't try to create or update the lockfile. \[env var: PDM_FROZEN_LOCKFILE\]]"
@@ -261,6 +263,7 @@ _pdm() {
         "--check[Check if the lock file is up to date and quit]"
         {-G+,--group+,--with+}'[Select group of optional-dependencies or dev-dependencies(with -d). Can be supplied multiple times, use ":all" to include all groups under the same species]:group:_pdm_groups'
         "--without+[Exclude groups of optional-dependencies or dev-dependencies]:group:_pdm_groups"
+        {-c+,--constraint+}'[Use the constraint file in pip-requirements format. \[env var: PDM_CONSTRAINT\] This option can be used multiple times. See https://pip.pypa.io/en/stable/user_guide/#constraints-files]:constraint:_files'
         {-d,--dev}"[Select dev dependencies]"
         {--prod,--production}"[Unselect dev dependencies]"
         '--update-reuse[Reuse pinned versions already present in lock file if possible]'
@@ -390,6 +393,7 @@ _pdm() {
         {-G,--group}'[Specify the target dependency group to remove from]:group:_pdm_groups'
         {-d,--dev}"[Remove packages from dev dependencies]"
         {-L,--lockfile}'[Specify another lockfile path, or use `PDM_LOCKFILE` env variable. Default: pdm.lock]:lockfile:_files'
+        {-c+,--constraint+}'[Use the constraint file in pip-requirements format. \[env var: PDM_CONSTRAINT\] This option can be used multiple times. See https://pip.pypa.io/en/stable/user_guide/#constraints-files]:constraint:_files'
         {-k,--skip}'[Skip some tasks and/or hooks by their comma-separated names]'
         "--no-sync[Only write pyproject.toml and do not uninstall packages]"
         '--no-editable[Install non-editable versions for all packages]'
@@ -467,6 +471,7 @@ _pdm() {
         {-G+,--group+,--with+}'[Select group of optional-dependencies or dev-dependencies(with -d). Can be supplied multiple times, use ":all" to include all groups under the same species]:group:_pdm_groups'
         "--without+[Exclude groups of optional-dependencies or dev-dependencies]:group:_pdm_groups"
         {-L,--lockfile}'[Specify another lockfile path, or use `PDM_LOCKFILE` env variable. Default: pdm.lock]:lockfile:_files'
+        {-c+,--constraint+}'[Use the constraint file in pip-requirements format. \[env var: PDM_CONSTRAINT\] This option can be used multiple times. See https://pip.pypa.io/en/stable/user_guide/#constraints-files]:constraint:_files'
         '--save-compatible[Save compatible version specifiers]'
         '--save-wildcard[Save wildcard version specifiers]'
         '--save-exact[Save exact version specifiers]'
diff --git a/src/pdm/cli/options.py b/src/pdm/cli/options.py
index 91297679..a419501c 100644
--- a/src/pdm/cli/options.py
+++ b/src/pdm/cli/options.py
@@ -486,3 +486,13 @@ config_setting_option = Option(
     "or `-Ckey(=value)`",
 )
 install_group.options.append(config_setting_option)
+
+constraint_option = Option(
+    "-c",
+    "--constraint",
+    default=os.getenv("PDM_CONSTRAINT"),
+    action="append",
+    help="Use the constraint file in pip-requirements format. [env var: PDM_CONSTRAINT] "
+    "This option can be used multiple times. "
+    "See https://pip.pypa.io/en/stable/user_guide/#constraints-files",
+)
diff --git a/src/pdm/core.py b/src/pdm/core.py
index aee3933d..7191d962 100644
--- a/src/pdm/core.py
+++ b/src/pdm/core.py
@@ -19,7 +19,7 @@ import sys
 from datetime import datetime
 from pathlib import Path
 from tempfile import TemporaryDirectory
-from typing import TYPE_CHECKING, Iterator, cast
+from typing import TYPE_CHECKING, cast
 
 from resolvelib import Resolver
 
@@ -56,6 +56,8 @@ class State:
     """Whether to make an isolated environment and install requirements for build"""
     enable_cache: bool = True
     """Whether to enable the cache"""
+    constraints: list[str] = dc.field(default_factory=list)
+    """The requirement constraints for the resolver"""
 
 
 class Core:
@@ -74,34 +76,11 @@ class Core:
         self.version = __version__
         self.exit_stack = contextlib.ExitStack()
         self.ui = termui.UI(exit_stack=self.exit_stack)
-        self._states = [State()]
+        self.state = State()
         self.exit_stack.callback(setattr, self, "config_settings", None)
         self.init_parser()
         self.load_plugins()
 
-    @property
-    def state(self) -> State:
-        """Get the current state object."""
-        return self._states[-1]
-
-    @contextlib.contextmanager
-    def push_state(self, __empty: bool = False, /, **kwargs: Any) -> Iterator[State]:  # pragma: no cover
-        """Push a new state object to the stack.
-
-        Args:
-            __empty (bool): Whether to make an empty state.
-            **kwargs: The new attributes to set to the state object.
-        """
-        if __empty:
-            new_state = State(**kwargs)
-        else:
-            new_state = dc.replace(self.state, **kwargs)
-        self._states.append(new_state)
-        try:
-            yield new_state
-        finally:
-            self._states.pop()
-
     def create_temp_dir(self, *args: Any, **kwargs: Any) -> str:
         return self.exit_stack.enter_context(TemporaryDirectory(*args, **kwargs))
 
@@ -200,12 +179,15 @@ class Core:
         for callback in getattr(options, "callbacks", []):
             callback(project, options)
 
-        if getattr(options, "lockfile", None):
-            project.set_lockfile(cast(str, options.lockfile))
+        if lockfile := getattr(options, "lockfile", None):
+            project.set_lockfile(cast(str, lockfile))
 
         if getattr(options, "use_venv", None):
             use_venv(project, cast(str, options.use_venv))
 
+        if constraints := getattr(options, "constraint", None):
+            self.state.constraints = constraints
+
         if command is None:
             self.parser.print_help()
             sys.exit(0)
diff --git a/src/pdm/formats/requirements.py b/src/pdm/formats/requirements.py
index f4ff02b9..966f776b 100644
--- a/src/pdm/formats/requirements.py
+++ b/src/pdm/formats/requirements.py
@@ -5,9 +5,9 @@ import hashlib
 import shlex
 import urllib.parse
 from pathlib import Path
-from typing import TYPE_CHECKING, Any, Mapping
+from typing import TYPE_CHECKING, Any, Iterable, Mapping
 
-from pdm.exceptions import PdmUsageError
+from pdm.exceptions import PdmException, PdmUsageError
 from pdm.formats.base import make_array
 from pdm.models.requirements import FileRequirement, Requirement, parse_requirement
 from pdm.utils import expand_env_vars_in_auth
@@ -16,6 +16,7 @@ if TYPE_CHECKING:
     from argparse import Namespace
     from os import PathLike
 
+    from pdm.environments import BaseEnvironment
     from pdm.models.candidates import Candidate
     from pdm.project import Project
 
@@ -27,7 +28,7 @@ class RequirementParser:
 
     # TODO: support no_binary, only_binary, prefer_binary, pre and no_index
 
-    def __init__(self) -> None:
+    def __init__(self, environment: BaseEnvironment) -> None:
         self.requirements: list[Requirement] = []
         self.index_url: str | None = None
         self.extra_index_urls: list[str] = []
@@ -43,6 +44,7 @@ class RequirementParser:
         parser.add_argument("-e", "--editable", nargs="+")
         parser.add_argument("-r", "--requirement")
         self._parser = parser
+        self._env = environment
 
     def _clean_line(self, line: str) -> str:
         """Strip the surrounding whitespaces and comment from the line"""
@@ -75,21 +77,32 @@ class RequirementParser:
         if args.editable:
             self.requirements.append(parse_requirement(" ".join(args.editable), True))
         if args.requirement:
-            referenced_requirements = str(Path(filename).parent.joinpath(args.requirement))
-            self.parse(referenced_requirements)
-
-    def parse(self, filename: str) -> None:
-        with open(filename, encoding="utf-8") as f:
+            referenced_requirements = Path(filename).parent.joinpath(args.requirement).as_posix()
+            self.parse_file(referenced_requirements)
+
+    def parse_lines(self, lines: Iterable[str], filename: str = "<temp file>") -> None:
+        this_line = ""
+        for line in filter(None, map(self._clean_line, lines)):
+            if line.endswith("\\"):
+                this_line += line[:-1].rstrip() + " "
+                continue
+            this_line += line
+            self._parse_line(filename, this_line)
             this_line = ""
-            for line in filter(None, map(self._clean_line, f)):
-                if line.endswith("\\"):
-                    this_line += line[:-1].rstrip() + " "
-                    continue
-                this_line += line
-                self._parse_line(filename, this_line)
-                this_line = ""
-            if this_line:
-                self._parse_line(filename, this_line)
+        if this_line:
+            self._parse_line(filename, this_line)
+
+    def parse_file(self, filename_or_url: str) -> None:
+        parsed = urllib.parse.urlparse(filename_or_url)
+        if parsed.scheme in ("http", "https", "file"):
+            resp = self._env.session.get(filename_or_url)
+            if resp.is_error:  # pragma: no cover
+                raise PdmException(
+                    f"Failed to fetch {filename_or_url}: ({resp.status_code} - {resp.reason_phrase}) {resp.text}"
+                )
+            return self.parse_lines(resp.text.splitlines(), filename_or_url)
+        with open(filename_or_url, encoding="utf-8") as f:
+            self.parse_lines(f, filename_or_url)
 
 
 def check_fingerprint(project: Project, filename: PathLike) -> bool:
@@ -130,8 +143,8 @@ def convert_url_to_source(url: str, name: str | None, trusted_hosts: list[str],
 
 
 def convert(project: Project, filename: PathLike, options: Namespace) -> tuple[Mapping[str, Any], Mapping[str, Any]]:
-    parser = RequirementParser()
-    parser.parse(str(filename))
+    parser = RequirementParser(project.environment)
+    parser.parse_file(str(filename))
     backend = project.backend
 
     deps = make_array([], True)
diff --git a/src/pdm/resolver/providers.py b/src/pdm/resolver/providers.py
index fd3a4b24..26808e11 100644
--- a/src/pdm/resolver/providers.py
+++ b/src/pdm/resolver/providers.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import os
+from functools import cached_property
 from typing import TYPE_CHECKING, Callable
 
 from packaging.specifiers import InvalidSpecifier, SpecifierSet
@@ -75,6 +76,18 @@ class BaseProvider(AbstractProvider):
         self.locked_candidates = locked_candidates
         self._known_depth: dict[str, int] = {}
 
+    @cached_property
+    def requirement_constraints(self) -> dict[str, list[Requirement]]:
+        from pdm.formats.requirements import RequirementParser
+
+        parser = RequirementParser(self.repository.environment)
+        for constraint in self.repository.environment.project.core.state.constraints:
+            parser.parse_file(constraint)
+        result: dict[str, list[Requirement]] = {}
+        for req in parser.requirements:
+            result.setdefault(req.identify(), []).append(req)
+        return result
+
     def requirement_preference(self, requirement: Requirement) -> Comparable:
         """Return the preference of a requirement to find candidates.
 
@@ -191,6 +204,10 @@ class BaseProvider(AbstractProvider):
         requirements: Mapping[str, Iterator[Requirement]],
         incompatibilities: Mapping[str, Iterator[Candidate]],
     ) -> Callable[[], Iterator[Candidate]]:
+        from resolvelib.structs import IteratorMapping
+
+        requirements = IteratorMapping(requirements, lambda v: v, self.requirement_constraints)
+
         def matches_gen() -> Iterator[Candidate]:
             incompat = list(incompatibilities[identifier])
             if identifier == "python":


Test patch:
diff --git a/tests/cli/test_lock.py b/tests/cli/test_lock.py
index eb9090f9..45fdc15a 100644
--- a/tests/cli/test_lock.py
+++ b/tests/cli/test_lock.py
@@ -8,7 +8,8 @@ from pdm.exceptions import PdmUsageError
 from pdm.models.requirements import parse_requirement
 from pdm.models.specifiers import PySpecSet
 from pdm.project.lockfile import FLAG_CROSS_PLATFORM, Compatibility
-from pdm.utils import parse_version
+from pdm.utils import parse_version, path_to_url
+from tests import FIXTURES
 
 
 def test_lock_command(project, pdm, mocker):
@@ -371,3 +372,17 @@ def test_lock_for_multiple_targets(project, pdm, repository, nested):
     assert len(locked.targets) == 1
     pytz = candidates["pytz"][0]
     assert str(pytz.req.marker) == 'sys_platform == "win32"'
+
+
+CONSTRAINT_FILE = str(FIXTURES / "constraints.txt")
+
+
+@pytest.mark.usefixtures("repository")
+@pytest.mark.parametrize("constraint", [CONSTRAINT_FILE, path_to_url(CONSTRAINT_FILE)])
+def test_lock_with_constraints_file(project, pdm, constraint):
+    project.add_dependencies(["requests"])
+    pdm(["lock", "--constraint", constraint], obj=project, strict=True)
+    candidates = project.get_locked_repository().candidates
+    assert candidates["requests"].version == "2.20.0b1"
+    assert candidates["urllib3"].version == "1.23b0"
+    assert "django" not in candidates
diff --git a/tests/fixtures/constraints.txt b/tests/fixtures/constraints.txt
new file mode 100644
index 00000000..2bec911d
--- /dev/null
+++ b/tests/fixtures/constraints.txt
@@ -0,0 +1,8 @@
+# This is a pip constraints file
+requests==2.20.0b1
+django==1.11.8
+certifi==2018.11.17
+chardet==3.0.4
+idna==2.7
+pytz==2019.3
+urllib3==1.23b0