Repository: pdm-project/pdm

Issue:
Credentials for additional indexes configured with PDM are not passed to uv
### Describe the bug

I have several indexes configured with PDM in the global config file (~/.config/pdm/config.toml). One is a mirror for PyPI, the others are additional internal indexes. All indexes require authentication.
When using `uv` with PDM (`PDM_USE_UV` = `True`), all the indexes are passed to `uv` on the command line but without the credentials which obviously causes operations trying to access these indexes to fail.

### To reproduce

1. Use latest PDM (tested with 2.25.0) and also install uv (tested with 0.7.13)
2. Configure an index with PDM that requires authentication in ~/.config/pdm/config.toml
3. Create a new empty project (`pdm init`) and configure it to use uv (`pdm config -l use_uv true`)
4. Try to install a dependency that is only available on the the private index
5. The installation fails with the following message: "No solution found when resolving dependencies: Because <your-dependency> was not found in the package registry and your project depends on <your-dependency>, we can conclude that your project's requirements are unsatisfiable."

At the moment I can work around this issue, by providing a uv config (~/.config/uv/uv.conf) that repeats the indexes configured with PDM.

### Expected Behavior

Ideally, PDM would pass the credentials for its configured indexes to uv when it is called under the hood.

### Environment Information

```text
PDM version:
  2.25.0
Python Interpreter:
  /home/<redacted>/tmp/uv-test-credentials/.venv/bin/python (3.10)
Project Root:
  /home/<redacted>/tmp/uv-test-credentials
Local Packages:

INFO: PDM 2.25.0 is installed, while 2.25.3 is available.
Please run `pdm self update` to upgrade.
Run `pdm config check_update false` to disable the check.
{
  "implementation_name": "cpython",
  "implementation_version": "3.10.17",
  "os_name": "posix",
  "platform_machine": "x86_64",
  "platform_release": "6.6.87.2-microsoft-standard-WSL2",
  "platform_system": "Linux",
  "platform_version": "#1 SMP PREEMPT_DYNAMIC Thu Jun  5 18:30:46 UTC 2025",
  "python_full_version": "3.10.17",
  "platform_python_implementation": "CPython",
  "python_version": "3.10",
  "sys_platform": "linux"
}
INFO: PDM 2.25.0 is installed, while 2.25.3 is available.
Please run `pdm self update` to upgrade.
Run `pdm config check_update false` to disable the check.
```

### Verbose Command Output

```text
Adding packages to default dependencies: <redacted-package>
INFO: Using uv is experimental and might break due to uv updates.
Running uv lock command: ['/home/<redacted-user>/dev/python/python3.10-pdm2.25.0/bin/python3', '-m', 'uv', 'lock', '-p', '/home/<redacted-user>/tmp/uv-test-credentials/.venv/bin/python', '--verbose', '--index-url', 'https://<redacted-host>/repository/api/pypi/pypi-remote/simple', '--extra-index-url', 'https://<redacted-host>/repository/api/pypi/<redacted-repo1>/simple', '--extra-index-url',
'https://<redacted-host>/repository/api/pypi/<redacted-repo2>/simple', '--index-strategy=unsafe-best-match', '-P', '<redacted-package>']
DEBUG uv 0.7.13
DEBUG Found workspace root: `/home/<redacted-user>/tmp/uv-test-credentials`
DEBUG Adding root workspace member: `/home/<redacted-user>/tmp/uv-test-credentials`
DEBUG Using Python request `/home/<redacted-user>/tmp/uv-test-credentials/.venv/bin/python` from explicit request
DEBUG Checking for Python environment at `.venv`
DEBUG The project environment's Python version satisfies the request: `path `.venv/bin/python``
DEBUG Using request timeout of 30s
DEBUG Ignoring existing lockfile due to `--upgrade-package`
DEBUG Found static `pyproject.toml` for: uv-test-credentials @ file:///home/<redacted-user>/tmp/uv-test-credentials
DEBUG No workspace root found, using project root
DEBUG Solving with installed Python version: 3.10.17
DEBUG Solving with target Python version: ==3.10.*
DEBUG Adding direct dependency: uv-test-credentials*
DEBUG Searching for a compatible version of uv-test-credentials @ file:///home/<redacted-user>/tmp/uv-test-credentials (*)
DEBUG Adding direct dependency: <redacted-package>*
DEBUG Found stale response for: https://<redacted-host>/repository/api/pypi/<redacted-repo1>/simple/<redacted-package>/
DEBUG Sending revalidation request for: https://<redacted-host>/repository/api/pypi/<redacted-repo1>/simple/<redacted-package>/
DEBUG No cache entry for: https://<redacted-host>/repository/api/pypi/pypi-remote/simple/<redacted-package>/
DEBUG Found stale response for: https://<redacted-host>/repository/api/pypi/<redacted-repo2>/simple/<redacted-package>/
DEBUG Sending revalidation request for: https://<redacted-host>/repository/api/pypi/<redacted-repo2>/simple/<redacted-package>/
DEBUG No netrc file found
DEBUG Searching for a compatible version of <redacted-package> (*)
DEBUG No compatible version found for: <redacted-package>
DEBUG Recording unit propagation conflict of <redacted-package> from incompatibility of (uv-test-credentials)
DEBUG Searching for a compatible version of uv-test-credentials @ file:///home/<redacted-user>/tmp/uv-test-credentials (<0.1.0 | >0.1.0)
DEBUG No compatible version found for: uv-test-credentials
  × No solution found when resolving dependencies:
  ╰─▶ Because <redacted-package> was not found in the package registry and your project depends on <redacted-package>, we can conclude that your project's requirements are unsatisfiable.
Traceback (most recent call last):
  File "/home/<redacted-user>/dev/python/python3.10-pdm2.25.0/bin/pdm", line 8, in <module>
    sys.exit(main())
  File "/home/<redacted-user>/dev/python/python3.10-pdm2.25.0/lib/python3.10/site-packages/pdm/core.py", line 391, in main
    return core.main(args or sys.argv[1:])
  File "/home/<redacted-user>/dev/python/python3.10-pdm2.25.0/lib/python3.10/site-packages/pdm/core.py", line 270, in main
    raise cast(Exception, err).with_traceback(traceback) from None
  File "/home/<redacted-user>/dev/python/python3.10-pdm2.25.0/lib/python3.10/site-packages/pdm/core.py", line 265, in main
    self.handle(project, options)
  File "/home/<redacted-user>/dev/python/python3.10-pdm2.25.0/lib/python3.10/site-packages/pdm/core.py", line 195, in handle
    command.handle(project, options)
  File "/home/<redacted-user>/dev/python/python3.10-pdm2.25.0/lib/python3.10/site-packages/pdm/cli/commands/add.py", line 71, in handle
    self.do_add(
  File "/home/<redacted-user>/dev/python/python3.10-pdm2.25.0/lib/python3.10/site-packages/pdm/cli/commands/add.py", line 159, in do_add
    resolved = do_lock(
  File "/home/<redacted-user>/dev/python/python3.10-pdm2.25.0/lib/python3.10/site-packages/pdm/cli/actions.py", line 135, in do_lock
    resolved, new_groups = resolver.resolve()
  File "/home/<redacted-user>/dev/python/python3.10-pdm2.25.0/lib/python3.10/site-packages/pdm/resolver/uv.py", line 188, in resolve
    subprocess.run(uv_lock_command, cwd=self.project.root, check=True)
  File "/home/<redacted-user>/dev/python/python3.10-pdm2.25.0/lib/python3.10/subprocess.py", line 526, in run
    raise CalledProcessError(retcode, process.args,
subprocess.CalledProcessError: Command '['/home/<redacted-user>/dev/python/python3.10-pdm2.25.0/bin/python3', '-m', 'uv', 'lock', '-p', '/home/<redacted-user>/tmp/uv-test-credentials/.venv/bin/python', '--verbose', '--index-url', 'https://<redacted-host>/repository/api/pypi/pypi-remote/simple', '--extra-index-url', 'https://<redacted-host>/repository/api/pypi/<redacted-repo1>/simple', '--extra-index-url', 'https://<redacted-host>/repository/api/pypi/<redacted-repo2>/simple', '--index-strategy=unsafe-best-match', '-P', '<redacted-package>']' returned non-zero exit status 1.
```

### Additional Context

_No response_

### Are you willing to submit a PR to fix this bug?

- [ ] Yes, I would like to submit a PR.


Discussion:
Thanks for your quick fix! I just tested the development version from the main branch (b728ef77) and now the error produced when running the lock command through uv disappeared.
When trying to sync the environment as part of the `pdm add` command, however, still fails with the same problem.

I suggest reopening this issue.
In addition, I just noticed, that the credentials are part of the log output, when calling `pdm add` with `-v`:

```text
Running uv lock command: ['/home/<redacted-user>/dev/python/python3.10-pdm-main/bin/python3', '-m', 'uv', 'lock', '-p', '/home/<redacted-user>/tmp/uv-test-credentials/.venv/bin/python', '--verbose', '--index-url',
'https://<redacted-username>:<redacted-password>@<redacted-host>/repository/api/pypi/pypi-remote/simple', '--extra-index-url',
'https://<redacted-username>:<redacted-password>@<redacted-host>/repository/api/pypi/<redacted-repo1>/simple', '--extra-index-url',
'https://<redacted-username>:<redacted-password>@<redacted-host>/repository/api/pypi/<redacted-repo2>/simple', '--index-strategy=unsafe-best-match']
```




Patch:
diff --git a/news/3555.bugfix.md b/news/3555.bugfix.md
new file mode 100644
index 0000000000..eddaba3d72
--- /dev/null
+++ b/news/3555.bugfix.md
@@ -0,0 +1,1 @@
+Redact credentials in source urls in the log output, and inject credentials into the source url for uv sync command as well.
diff --git a/src/pdm/_types.py b/src/pdm/_types.py
index d3fb1cd94f..5b33388944 100644
--- a/src/pdm/_types.py
+++ b/src/pdm/_types.py
@@ -73,19 +73,20 @@ def __rich__(self) -> str:
         return "\n".join(lines)
 
     @property
-    def url_with_credentials(self) -> str:
+    def url_with_credentials(self) -> HiddenText:
         from urllib.parse import urlsplit, urlunsplit
 
-        from pdm.utils import expand_env_vars_in_auth
+        from pdm.utils import expand_env_vars_in_auth, hide_url
 
         assert self.url is not None
         self.populate_keyring_auth()
         if not self.username or not self.password:
-            return expand_env_vars_in_auth(self.url)
+            return hide_url(expand_env_vars_in_auth(self.url))
         parsed = urlsplit(self.url)
         *_, netloc = parsed.netloc.rpartition("@")
         netloc = f"{self.username}:{self.password}@{netloc}"
-        return urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment))
+        url = urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment))
+        return hide_url(url)
 
 
 RequirementDict = Union[str, dict[str, Union[str, bool]]]
@@ -130,3 +131,15 @@ class NotSetType:
 
 
 NotSet = NotSetType()
+
+
+@dc.dataclass(frozen=True)
+class HiddenText:
+    secret: str
+    redacted: str = dc.field(compare=False, hash=False)
+
+    def __str__(self) -> str:
+        return self.redacted
+
+    def __repr__(self) -> str:
+        return repr(str(self))
diff --git a/src/pdm/installers/uv.py b/src/pdm/installers/uv.py
index 1ad20eb3d4..507d3b32fb 100644
--- a/src/pdm/installers/uv.py
+++ b/src/pdm/installers/uv.py
@@ -3,6 +3,7 @@
 import subprocess
 from typing import Any
 
+from pdm._types import HiddenText
 from pdm.environments.local import PythonLocalEnvironment
 from pdm.exceptions import PdmUsageError
 from pdm.installers.base import BaseSynchronizer
@@ -44,11 +45,12 @@ def synchronize(self) -> None:
             builder.build_uv_lock(include_self=self.install_self)
             cmd = self._get_sync_command()
             self.environment.project.core.ui.echo(f"Running uv sync command: {cmd}", verbosity=Verbosity.DETAIL)
-            subprocess.run(cmd, check=True, cwd=self.environment.project.root)
+            real_cmd = [s.secret if isinstance(s, HiddenText) else s for s in cmd]
+            subprocess.run(real_cmd, check=True, cwd=self.environment.project.root)
 
-    def _get_sync_command(self) -> list[str]:
+    def _get_sync_command(self) -> list[str | HiddenText]:
         core = self.environment.project.core
-        cmd = [
+        cmd: list[str | HiddenText] = [
             *core.uv_cmd,
             "sync",
             "--all-extras",
@@ -68,21 +70,21 @@ def _get_sync_command(self) -> list[str]:
             cmd.append("--no-install-project")
         first_index = True
         for source in self.environment.project.sources:
-            assert source.url is not None
+            url = source.url_with_credentials
             if source.type == "find_links":
-                cmd.extend(["--find-links", source.url])
+                cmd.extend(["--find-links", url])
             elif first_index:
-                cmd.extend(["--index-url", source.url])
+                cmd.extend(["--index-url", url])
                 first_index = False
             else:
-                cmd.extend(["--extra-index-url", source.url])
+                cmd.extend(["--extra-index-url", url])
         if self.use_install_cache:
             cmd.extend(["--link-mode", self.environment.project.config["install.cache_method"]])
         return cmd
 
 
 class QuietUvSynchronizer(UvSynchronizer):
-    def _get_sync_command(self) -> list[str]:
+    def _get_sync_command(self) -> list[str | HiddenText]:
         cmd = super()._get_sync_command()
         if "--verbose" in cmd:
             cmd.remove("--verbose")
diff --git a/src/pdm/resolver/uv.py b/src/pdm/resolver/uv.py
index 50dc73ff25..417f7ef5de 100644
--- a/src/pdm/resolver/uv.py
+++ b/src/pdm/resolver/uv.py
@@ -8,6 +8,7 @@
 from pathlib import Path
 from typing import TYPE_CHECKING, Any
 
+from pdm._types import HiddenText
 from pdm.models.candidates import Candidate
 from pdm.models.markers import get_marker
 from pdm.models.repositories import Package
@@ -53,8 +54,13 @@ def __post_init__(self) -> None:
                 "the resolution may be inaccurate."
             )
 
-    def _build_lock_command(self) -> list[str]:
-        cmd = [*self.project.core.uv_cmd, "lock", "-p", str(self.environment.interpreter.executable)]
+    def _build_lock_command(self) -> list[str | HiddenText]:
+        cmd: list[str | HiddenText] = [
+            *self.project.core.uv_cmd,
+            "lock",
+            "-p",
+            str(self.environment.interpreter.executable),
+        ]
         if self.project.core.ui.verbosity > 0:
             cmd.append("--verbose")
         if not self.project.core.state.enable_cache:
@@ -185,7 +191,8 @@ def resolve(self) -> Resolution:
                     self.reporter.stop()
                 uv_lock_command = self._build_lock_command()
                 self.project.core.ui.echo(f"Running uv lock command: {uv_lock_command}", verbosity=Verbosity.DETAIL)
-                subprocess.run(uv_lock_command, cwd=self.project.root, check=True)
+                real_command = [s.secret if isinstance(s, HiddenText) else s for s in uv_lock_command]
+                subprocess.run(real_command, cwd=self.project.root, check=True)
             finally:
                 if isinstance(self.reporter, RichLockReporter):
                     self.reporter.start()
diff --git a/src/pdm/utils.py b/src/pdm/utils.py
index b2b8768f67..a4145d48c3 100644
--- a/src/pdm/utils.py
+++ b/src/pdm/utils.py
@@ -34,7 +34,7 @@
     from re import Match
     from typing import IO, Any, Iterator
 
-    from pdm._types import FileHash, RepositoryConfig
+    from pdm._types import FileHash, HiddenText, RepositoryConfig
     from pdm.compat import Distribution
 
 try:
@@ -567,3 +567,16 @@ def get_requirement_from_override(name: str, value: str) -> str:
         else:
             req = f"{name}{value}"
     return req
+
+
+def hide_url(url: str) -> HiddenText:
+    """Redact the URL for display purposes."""
+    from pdm._types import HiddenText
+
+    parsed = parse.urlsplit(url)
+    if "@" not in parsed.netloc:
+        return HiddenText(url, url)
+    *_, netloc = parsed.netloc.rpartition("@")
+    netloc = f"*****@{netloc}"
+    redacted = parse.urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment))
+    return HiddenText(url, redacted)


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
index de728b96dd..2a7a83196f 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -496,3 +496,17 @@ def test_invalidate_project_name(name):
 )
 def test_sanitize_project_name(given, sanitized):
     assert utils.sanitize_project_name(given) == sanitized
+
+
+@pytest.mark.parametrize(
+    "url, redacted",
+    [
+        ("http://user:pass@localhost:8000/path?query=1#fragment", "http://*****@localhost:8000/path?query=1#fragment"),
+        ("https://example.com", "https://example.com"),
+        ("ftp://ftp.example.com/file.txt", "ftp://ftp.example.com/file.txt"),
+    ],
+)
+def test_hide_url(url, redacted):
+    hidden = utils.hide_url(url)
+    assert hidden.secret == url
+    assert hidden.redacted == str(hidden) == redacted