Skip to content

Commit 59b33ff

Browse files
authored
PrivatePath CLI Helper (#485)
Introduced a dedicated `PrivatePath` helper for all CLI logging and structured output, ensuring tilde-collapsed paths stay consistent without duplicating the contraction logic across commands.
2 parents 6e27b7d + 4580004 commit 59b33ff

File tree

15 files changed

+292
-110
lines changed

15 files changed

+292
-110
lines changed

CHANGES

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ $ uvx --from 'vcspull' --prerelease allow vcspull
3333

3434
_Upcoming changes will be written here._
3535

36+
### Development
37+
38+
#### PrivatePath centralizes home-directory redaction (#485)
39+
40+
- Introduced a dedicated `PrivatePath` helper for all CLI logging and structured
41+
output, ensuring tilde-collapsed paths stay consistent without duplicating the
42+
contraction logic across commands.
43+
3644
## vcspull v1.46.1 (2025-11-03)
3745

3846
### Bug Fixes

docs/api/internals/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ If you need an internal API stabilized please [file an issue](https://github.com
1010

1111
```{toctree}
1212
config_reader
13+
private_path
1314
```

docs/api/internals/private_path.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# PrivatePath – `vcspull._internal.private_path`
2+
3+
:::{warning}
4+
`PrivatePath` is an internal helper. Its import path and behavior may change
5+
without notice. File an issue if you rely on it downstream so we can discuss a
6+
supported API.
7+
:::
8+
9+
`PrivatePath` subclasses `pathlib.Path` and normalizes every textual rendering
10+
(`str()`/`repr()`) so the current user’s home directory is collapsed to `~`.
11+
The class behaves exactly like the standard path object for filesystem ops; it
12+
only alters how the path is displayed. This keeps CLI logs, JSON/NDJSON output,
13+
and tests from leaking usernames while preserving full absolute paths for
14+
internal logic.
15+
16+
```python
17+
from vcspull._internal.private_path import PrivatePath
18+
19+
home_repo = PrivatePath("~/code/vcspull")
20+
print(home_repo) # -> ~/code/vcspull
21+
print(repr(home_repo)) # -> "PrivatePath('~/code/vcspull')"
22+
```
23+
24+
## Usage guidelines
25+
26+
- Wrap any path destined for user-facing output (logs, console tables, JSON
27+
payloads) in `PrivatePath` before calling `str()`.
28+
- The helper is safe to instantiate with `pathlib.Path` objects or strings; it
29+
does not touch relative paths that lack a home prefix.
30+
- Prefer storing raw `pathlib.Path` objects (or strings) in configuration
31+
models, then convert to `PrivatePath` at the presentation layer. This keeps
32+
serialization and equality checks deterministic while still masking the home
33+
directory when needed.
34+
35+
## Why not `contract_user_home`?
36+
37+
The previous `contract_user_home()` helper duplicated the tilde-collapsing logic
38+
in multiple modules and required callers to remember to run it themselves. By
39+
centralizing the behavior in a `pathlib.Path` subclass we get:
40+
41+
- Built-in protection—`str()` and `repr()` automatically apply the privacy
42+
filter.
43+
- Consistent behavior across every CLI command and test fixture.
44+
- Easier mocking in tests, because `PrivatePath` respects monkeypatched
45+
`Path.home()` implementations.
46+
47+
If you need alternative redaction behavior, consider composing your own helper
48+
around `PrivatePath` instead of reintroducing ad hoc string munging.
49+
50+
```{eval-rst}
51+
.. automodule:: vcspull._internal.private_path
52+
:members:
53+
:show-inheritance:
54+
:undoc-members:
55+
```
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
from __future__ import annotations
2+
3+
import os
4+
import pathlib
5+
import typing as t
6+
7+
if t.TYPE_CHECKING:
8+
PrivatePathBase = pathlib.Path
9+
else:
10+
PrivatePathBase = type(pathlib.Path())
11+
12+
13+
class PrivatePath(PrivatePathBase):
14+
"""Path subclass that hides the user's home directory in textual output.
15+
16+
The class behaves like :class:`pathlib.Path`, but normalizes string and
17+
representation output to replace the current user's home directory with
18+
``~``. This is useful when logging or displaying paths that should not leak
19+
potentially sensitive information.
20+
21+
Examples
22+
--------
23+
>>> from pathlib import Path
24+
>>> home = Path.home()
25+
>>> PrivatePath(home)
26+
PrivatePath('~')
27+
>>> PrivatePath(home / "projects" / "vcspull")
28+
PrivatePath('~/projects/vcspull')
29+
>>> str(PrivatePath("/tmp/example"))
30+
'/tmp/example'
31+
>>> f'build dir: {PrivatePath(home / "build")}'
32+
'build dir: ~/build'
33+
>>> '{}'.format(PrivatePath(home / 'notes.txt'))
34+
'~/notes.txt'
35+
"""
36+
37+
def __new__(cls, *args: t.Any, **kwargs: t.Any) -> PrivatePath:
38+
return super().__new__(cls, *args, **kwargs)
39+
40+
@classmethod
41+
def _collapse_home(cls, value: str) -> str:
42+
"""Collapse the user's home directory to ``~`` in ``value``."""
43+
if value.startswith("~"):
44+
return value
45+
46+
home = str(pathlib.Path.home())
47+
if value == home:
48+
return "~"
49+
50+
separators = {os.sep}
51+
if os.altsep:
52+
separators.add(os.altsep)
53+
54+
for sep in separators:
55+
home_with_sep = home + sep
56+
if value.startswith(home_with_sep):
57+
return "~" + value[len(home) :]
58+
59+
return value
60+
61+
def __str__(self) -> str:
62+
original = pathlib.Path.__str__(self)
63+
return self._collapse_home(original)
64+
65+
def __repr__(self) -> str:
66+
return f"{self.__class__.__name__}({str(self)!r})"
67+
68+
69+
__all__ = ["PrivatePath"]

src/vcspull/cli/add.py

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from colorama import Fore, Style
1313

1414
from vcspull._internal.config_reader import DuplicateAwareConfigReader
15+
from vcspull._internal.private_path import PrivatePath
1516
from vcspull.config import (
1617
canonicalize_workspace_path,
1718
expand_dir,
@@ -21,7 +22,6 @@
2122
save_config_yaml_with_items,
2223
workspace_root_label,
2324
)
24-
from vcspull.util import contract_user_home
2525

2626
if t.TYPE_CHECKING:
2727
import argparse
@@ -220,11 +220,11 @@ def handle_add_command(args: argparse.Namespace) -> None:
220220
repo_path = expand_dir(pathlib.Path(repo_input), cwd=cwd)
221221

222222
if not repo_path.exists():
223-
log.error("Repository path %s does not exist.", repo_path)
223+
log.error("Repository path %s does not exist.", PrivatePath(repo_path))
224224
return
225225

226226
if not repo_path.is_dir():
227-
log.error("Repository path %s is not a directory.", repo_path)
227+
log.error("Repository path %s is not a directory.", PrivatePath(repo_path))
228228
return
229229

230230
override_name = getattr(args, "override_name", None)
@@ -238,7 +238,7 @@ def handle_add_command(args: argparse.Namespace) -> None:
238238
display_url, config_url = _normalize_detected_url(detected_remote)
239239

240240
if not config_url:
241-
display_url = contract_user_home(repo_path)
241+
display_url = str(PrivatePath(repo_path))
242242
config_url = str(repo_path)
243243
log.warning(
244244
"Unable to determine git remote for %s; using local path in config.",
@@ -262,7 +262,7 @@ def handle_add_command(args: argparse.Namespace) -> None:
262262

263263
summary_url = display_url or config_url
264264

265-
display_path = contract_user_home(repo_path)
265+
display_path = str(PrivatePath(repo_path))
266266

267267
log.info("%sFound new repository to import:%s", Fore.GREEN, Style.RESET_ALL)
268268
log.info(
@@ -319,7 +319,11 @@ def handle_add_command(args: argparse.Namespace) -> None:
319319
response = ""
320320
proceed = response.strip().lower() in {"y", "yes"}
321321
if not proceed:
322-
log.info("Aborted import of '%s' from %s", repo_name, repo_path)
322+
log.info(
323+
"Aborted import of '%s' from %s",
324+
repo_name,
325+
PrivatePath(repo_path),
326+
)
323327
return
324328

325329
add_repo(
@@ -370,7 +374,7 @@ def add_repo(
370374
config_file_path = pathlib.Path.cwd() / ".vcspull.yaml"
371375
log.info(
372376
"No config specified and no default found, will create at %s",
373-
contract_user_home(config_file_path),
377+
PrivatePath(config_file_path),
374378
)
375379
elif len(home_configs) > 1:
376380
log.error(
@@ -384,7 +388,7 @@ def add_repo(
384388
raw_config: dict[str, t.Any]
385389
duplicate_root_occurrences: dict[str, list[t.Any]]
386390
top_level_items: list[tuple[str, t.Any]]
387-
display_config_path = contract_user_home(config_file_path)
391+
display_config_path = str(PrivatePath(config_file_path))
388392

389393
if config_file_path.exists() and config_file_path.is_file():
390394
try:
@@ -400,7 +404,10 @@ def add_repo(
400404
)
401405
return
402406
except Exception:
403-
log.exception("Error loading YAML from %s. Aborting.", config_file_path)
407+
log.exception(
408+
"Error loading YAML from %s. Aborting.",
409+
PrivatePath(config_file_path),
410+
)
404411
if log.isEnabledFor(logging.DEBUG):
405412
traceback.print_exc()
406413
return
@@ -579,7 +586,10 @@ def _prepare_no_merge_items(
579586
Style.RESET_ALL,
580587
)
581588
except Exception:
582-
log.exception("Error saving config to %s", config_file_path)
589+
log.exception(
590+
"Error saving config to %s",
591+
PrivatePath(config_file_path),
592+
)
583593
if log.isEnabledFor(logging.DEBUG):
584594
traceback.print_exc()
585595
elif (duplicate_merge_changes > 0 or config_was_relabelled) and dry_run:
@@ -635,7 +645,10 @@ def _prepare_no_merge_items(
635645
Style.RESET_ALL,
636646
)
637647
except Exception:
638-
log.exception("Error saving config to %s", config_file_path)
648+
log.exception(
649+
"Error saving config to %s",
650+
PrivatePath(config_file_path),
651+
)
639652
if log.isEnabledFor(logging.DEBUG):
640653
traceback.print_exc()
641654
return
@@ -719,7 +732,10 @@ def _prepare_no_merge_items(
719732
Style.RESET_ALL,
720733
)
721734
except Exception:
722-
log.exception("Error saving config to %s", config_file_path)
735+
log.exception(
736+
"Error saving config to %s",
737+
PrivatePath(config_file_path),
738+
)
723739
if log.isEnabledFor(logging.DEBUG):
724740
traceback.print_exc()
725741
return
@@ -778,6 +794,9 @@ def _prepare_no_merge_items(
778794
Style.RESET_ALL,
779795
)
780796
except Exception:
781-
log.exception("Error saving config to %s", config_file_path)
797+
log.exception(
798+
"Error saving config to %s",
799+
PrivatePath(config_file_path),
800+
)
782801
if log.isEnabledFor(logging.DEBUG):
783802
traceback.print_exc()

src/vcspull/cli/discover.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from colorama import Fore, Style
1313

1414
from vcspull._internal.config_reader import DuplicateAwareConfigReader
15+
from vcspull._internal.private_path import PrivatePath
1516
from vcspull.config import (
1617
canonicalize_workspace_path,
1718
expand_dir,
@@ -212,7 +213,10 @@ def discover_repos(
212213
)
213214
return
214215
except Exception:
215-
log.exception("Error loading YAML from %s. Aborting.", config_file_path)
216+
log.exception(
217+
"Error loading YAML from %s. Aborting.",
218+
PrivatePath(config_file_path),
219+
)
216220
if log.isEnabledFor(logging.DEBUG):
217221
traceback.print_exc()
218222
return
@@ -458,7 +462,10 @@ def discover_repos(
458462
Style.RESET_ALL,
459463
)
460464
except Exception:
461-
log.exception("Error saving config to %s", config_file_path)
465+
log.exception(
466+
"Error saving config to %s",
467+
PrivatePath(config_file_path),
468+
)
462469
if log.isEnabledFor(logging.DEBUG):
463470
traceback.print_exc()
464471
return
@@ -551,7 +558,10 @@ def discover_repos(
551558
Style.RESET_ALL,
552559
)
553560
except Exception:
554-
log.exception("Error saving config to %s", config_file_path)
561+
log.exception(
562+
"Error saving config to %s",
563+
PrivatePath(config_file_path),
564+
)
555565
if log.isEnabledFor(logging.DEBUG):
556566
traceback.print_exc()
557567
return

src/vcspull/cli/fmt.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from colorama import Fore, Style
1212

1313
from vcspull._internal.config_reader import DuplicateAwareConfigReader
14+
from vcspull._internal.private_path import PrivatePath
1415
from vcspull.config import (
1516
find_config_files,
1617
find_home_config_files,
@@ -176,10 +177,16 @@ def format_single_config(
176177
DuplicateAwareConfigReader.load_with_duplicates(config_file_path)
177178
)
178179
except TypeError:
179-
log.exception("Config file %s is not a mapping", config_file_path)
180+
log.exception(
181+
"Config file %s is not a mapping",
182+
PrivatePath(config_file_path),
183+
)
180184
return False
181185
except Exception:
182-
log.exception("Error loading config from %s", config_file_path)
186+
log.exception(
187+
"Error loading config from %s",
188+
PrivatePath(config_file_path),
189+
)
183190
if log.isEnabledFor(logging.DEBUG):
184191
traceback.print_exc()
185192
return False
@@ -359,7 +366,10 @@ def format_single_config(
359366
Style.RESET_ALL,
360367
)
361368
except Exception:
362-
log.exception("Error saving formatted config to %s", config_file_path)
369+
log.exception(
370+
"Error saving formatted config to %s",
371+
PrivatePath(config_file_path),
372+
)
363373
if log.isEnabledFor(logging.DEBUG):
364374
traceback.print_exc()
365375
return False

0 commit comments

Comments
 (0)