shortenpath 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- shortenpath/__init__.py +11 -0
- shortenpath/__main__.py +10 -0
- shortenpath/_cli.py +107 -0
- shortenpath/_name.py +45 -0
- shortenpath/_parts.py +133 -0
- shortenpath/_shorten.py +127 -0
- shortenpath/_width.py +84 -0
- shortenpath/py.typed +0 -0
- shortenpath-0.2.0.dist-info/METADATA +189 -0
- shortenpath-0.2.0.dist-info/RECORD +13 -0
- shortenpath-0.2.0.dist-info/WHEEL +4 -0
- shortenpath-0.2.0.dist-info/entry_points.txt +2 -0
- shortenpath-0.2.0.dist-info/licenses/LICENSE +21 -0
shortenpath/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Abbreviate a filesystem path for display within a terminal column budget."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._parts import Flavor
|
|
6
|
+
from ._shorten import shorten
|
|
7
|
+
from ._width import display_width
|
|
8
|
+
|
|
9
|
+
__version__ = "0.2.0"
|
|
10
|
+
|
|
11
|
+
__all__ = ["Flavor", "display_width", "shorten"]
|
shortenpath/__main__.py
ADDED
shortenpath/_cli.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Command line tool: shorten paths given as arguments or read one per line from stdin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import errno
|
|
7
|
+
import io
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import sys
|
|
11
|
+
from collections.abc import Iterable, Iterator, Sequence
|
|
12
|
+
from functools import partial
|
|
13
|
+
|
|
14
|
+
from ._shorten import shorten
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _width(text: str) -> int:
|
|
18
|
+
try:
|
|
19
|
+
value = int(text)
|
|
20
|
+
except ValueError:
|
|
21
|
+
raise argparse.ArgumentTypeError(f"width must be a whole number, not {text!r}") from None
|
|
22
|
+
if value < 1:
|
|
23
|
+
raise argparse.ArgumentTypeError(f"width must be at least 1, not {value}")
|
|
24
|
+
return value
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parser() -> argparse.ArgumentParser:
|
|
28
|
+
from . import __version__
|
|
29
|
+
|
|
30
|
+
parser = argparse.ArgumentParser(
|
|
31
|
+
prog="shortenpath",
|
|
32
|
+
description="Shorten paths to fit a number of terminal columns, one per line. "
|
|
33
|
+
"Without PATH arguments, paths are read one per line from standard input.",
|
|
34
|
+
epilog="examples:\n shortenpath -w 30 /usr/local/share/doc/python3/README.md\n"
|
|
35
|
+
' pwd | shortenpath -w 24\n shortenpath -w 20 -- "-odd name"',
|
|
36
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument("paths", nargs="*", metavar="PATH", help="paths to shorten")
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"-w",
|
|
41
|
+
"--width",
|
|
42
|
+
type=_width,
|
|
43
|
+
metavar="COLS",
|
|
44
|
+
help="column budget (default: the terminal width)",
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"-f",
|
|
48
|
+
"--flavor",
|
|
49
|
+
choices=("native", "posix", "windows"),
|
|
50
|
+
default="native",
|
|
51
|
+
help="path rules (default: those of this system)",
|
|
52
|
+
)
|
|
53
|
+
parser.add_argument("--no-tilde", action="store_true", help="do not fold the home folder")
|
|
54
|
+
parser.add_argument("--home", help="home folder to fold to ~ (default: the current user's)")
|
|
55
|
+
parser.add_argument("--sep", help="a custom separator instead of the flavor's rules")
|
|
56
|
+
parser.add_argument("--placeholder", default="...", help="text put where segments go")
|
|
57
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
58
|
+
return parser
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _inputs(paths: list[str]) -> Iterator[str]:
|
|
62
|
+
if paths:
|
|
63
|
+
yield from paths
|
|
64
|
+
return
|
|
65
|
+
# One line at a time, so ``tail -f log | shortenpath`` prints as lines arrive.
|
|
66
|
+
for line in sys.stdin:
|
|
67
|
+
yield line.rstrip("\r\n")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _emit(lines: Iterable[str]) -> None:
|
|
71
|
+
if isinstance(sys.stdout, io.TextIOWrapper):
|
|
72
|
+
# A pipe on Windows may use a legacy code page; a name it cannot hold must not crash.
|
|
73
|
+
sys.stdout.reconfigure(errors="replace")
|
|
74
|
+
try:
|
|
75
|
+
for line in lines:
|
|
76
|
+
sys.stdout.write(line + "\n")
|
|
77
|
+
sys.stdout.flush()
|
|
78
|
+
except OSError as error:
|
|
79
|
+
# The reader went away (``| head``). Windows reports EINVAL rather than EPIPE.
|
|
80
|
+
if not isinstance(error, BrokenPipeError) and error.errno != errno.EINVAL:
|
|
81
|
+
raise
|
|
82
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
83
|
+
os.dup2(devnull, sys.stdout.fileno())
|
|
84
|
+
os.close(devnull)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
88
|
+
"""Entry point for ``shortenpath`` and ``python -m shortenpath``; returns the exit code."""
|
|
89
|
+
parser = _parser()
|
|
90
|
+
args = parser.parse_args(sys.argv[1:] if argv is None else list(argv))
|
|
91
|
+
width = args.width if args.width is not None else shutil.get_terminal_size().columns
|
|
92
|
+
shrink = partial(
|
|
93
|
+
shorten,
|
|
94
|
+
width=width,
|
|
95
|
+
flavor=args.flavor,
|
|
96
|
+
home_tilde=not args.no_tilde,
|
|
97
|
+
home=args.home,
|
|
98
|
+
sep=args.sep,
|
|
99
|
+
placeholder=args.placeholder,
|
|
100
|
+
)
|
|
101
|
+
try:
|
|
102
|
+
# Options are checked once, before any input is read.
|
|
103
|
+
shrink("")
|
|
104
|
+
except ValueError as error:
|
|
105
|
+
parser.error(str(error))
|
|
106
|
+
_emit(shrink(path) for path in _inputs(args.paths))
|
|
107
|
+
return 0
|
shortenpath/_name.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Cut a single name to a column budget, keeping its start and its extension."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._width import clusters, display_width, head, tail
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def extension(name: str) -> str:
|
|
9
|
+
"""The extension of ``name`` as ``os.path.splitext`` sees it, leading dots ignored."""
|
|
10
|
+
dot = name.rfind(".")
|
|
11
|
+
# No dot, or only dots before the last one (".bashrc", "..x"): no extension.
|
|
12
|
+
if not name[: max(dot, 0)].strip("."):
|
|
13
|
+
return ""
|
|
14
|
+
return name[dot:]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def squeeze(name: str, budget: int, placeholder: str) -> str:
|
|
18
|
+
"""Return ``name`` cut in the middle to fit ``budget`` columns.
|
|
19
|
+
|
|
20
|
+
The extension stays whole with the last character of the stem before it when that
|
|
21
|
+
leaves room for the first one; otherwise the columns are shared between start and end,
|
|
22
|
+
the start taking the odd one, and the start always gets its first character when it
|
|
23
|
+
fits (the end takes every column when it does not). Without room for the placeholder
|
|
24
|
+
the start of the name is kept alone, which is empty when not even its first character
|
|
25
|
+
fits.
|
|
26
|
+
"""
|
|
27
|
+
units = clusters(name)
|
|
28
|
+
if sum(width for _, width in units) <= budget:
|
|
29
|
+
return name
|
|
30
|
+
room = budget - display_width(placeholder)
|
|
31
|
+
if room <= 0:
|
|
32
|
+
return head(units, budget)[0]
|
|
33
|
+
end_budget = room // 2
|
|
34
|
+
ext = extension(name)
|
|
35
|
+
stem = clusters(name[: len(name) - len(ext)])
|
|
36
|
+
if ext:
|
|
37
|
+
keep = display_width(ext) + stem[-1][1]
|
|
38
|
+
if room >= keep + stem[0][1]:
|
|
39
|
+
end_budget = max(keep, end_budget)
|
|
40
|
+
# The start gets at least its first character; if even that cannot fit, the end takes all.
|
|
41
|
+
first = units[0][1]
|
|
42
|
+
end_budget = min(end_budget, room - first) if first <= room else room
|
|
43
|
+
end, used = tail(units, end_budget)
|
|
44
|
+
start, _ = head(units, room - used)
|
|
45
|
+
return start + placeholder + end
|
shortenpath/_parts.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Split a path into its anchor and its named segments, and fold the home directory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
Flavor = Literal["native", "posix", "windows"]
|
|
11
|
+
|
|
12
|
+
_BACK = "\\"
|
|
13
|
+
_VERBATIM = _BACK * 2 + "?" + _BACK
|
|
14
|
+
_UNC_DEVICES = (_VERBATIM + "UNC" + _BACK, _BACK * 2 + ".\\UNC" + _BACK)
|
|
15
|
+
_POSIX_RUN = re.compile(r"/+")
|
|
16
|
+
_WINDOWS_RUN = re.compile(r"[\\/]+")
|
|
17
|
+
_BACK_RUN = re.compile(r"\\+")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Parts:
|
|
22
|
+
"""A path as anchor, names, the separator runs between names, and a trailing run.
|
|
23
|
+
|
|
24
|
+
``root_sep`` is the separator run that ends the anchor, empty when it ends in a name.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
anchor: str
|
|
28
|
+
names: tuple[str, ...]
|
|
29
|
+
seps: tuple[str, ...]
|
|
30
|
+
trailing: str
|
|
31
|
+
root_sep: str = ""
|
|
32
|
+
|
|
33
|
+
def render(self) -> str:
|
|
34
|
+
pieces = [self.anchor]
|
|
35
|
+
for index, name in enumerate(self.names):
|
|
36
|
+
if index:
|
|
37
|
+
pieces.append(self.seps[index - 1])
|
|
38
|
+
pieces.append(name)
|
|
39
|
+
pieces.append(self.trailing)
|
|
40
|
+
return "".join(pieces)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def resolve(flavor: str) -> str:
|
|
44
|
+
"""Return ``"posix"`` or ``"windows"`` for a flavor name."""
|
|
45
|
+
if flavor == "native":
|
|
46
|
+
return "windows" if os.name == "nt" else "posix"
|
|
47
|
+
if flavor in ("posix", "windows"):
|
|
48
|
+
return flavor
|
|
49
|
+
raise ValueError(f"flavor must be 'native', 'posix' or 'windows', not {flavor!r}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def windows_splitroot(path: str) -> tuple[str, str, str]:
|
|
53
|
+
"""Split a Windows path into drive, root and tail, like ``ntpath.splitroot``.
|
|
54
|
+
|
|
55
|
+
A path that starts exactly with ``\\\\?\\`` skips normalization in Windows, so only the
|
|
56
|
+
backslash separates its parts and a forward slash is part of a name.
|
|
57
|
+
"""
|
|
58
|
+
norm = path if path.startswith(_VERBATIM) else path.replace("/", _BACK)
|
|
59
|
+
if norm[:1] == _BACK:
|
|
60
|
+
if norm[1:2] != _BACK:
|
|
61
|
+
return "", path[:1], path[1:]
|
|
62
|
+
# UNC (\\server\share) or device (\\.\C:, \\?\UNC\server\share): two parts after it.
|
|
63
|
+
# Microsoft documents \\.\UNC\ as a UNC device too; ntpath.splitroot does not.
|
|
64
|
+
start = 8 if norm[:8].upper() in _UNC_DEVICES else 2
|
|
65
|
+
first = norm.find(_BACK, start)
|
|
66
|
+
second = norm.find(_BACK, first + 1) if first != -1 else -1
|
|
67
|
+
if second == -1:
|
|
68
|
+
return path, "", ""
|
|
69
|
+
return path[:second], path[second : second + 1], path[second + 1 :]
|
|
70
|
+
if norm[1:2] == ":":
|
|
71
|
+
if norm[2:3] == _BACK:
|
|
72
|
+
return path[:2], path[2:3], path[3:]
|
|
73
|
+
return path[:2], "", path[2:]
|
|
74
|
+
return "", "", path
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def split(path: str, flavor: str, sep: str | None) -> Parts:
|
|
78
|
+
"""Split ``path`` into :class:`Parts` under ``flavor`` or a custom ``sep``."""
|
|
79
|
+
drive = root = ""
|
|
80
|
+
if sep is not None:
|
|
81
|
+
run = re.compile(f"(?:{re.escape(sep)})+")
|
|
82
|
+
elif flavor == "windows":
|
|
83
|
+
drive, root, _ = windows_splitroot(path)
|
|
84
|
+
run = _BACK_RUN if path.startswith(_VERBATIM) else _WINDOWS_RUN
|
|
85
|
+
else:
|
|
86
|
+
run = _POSIX_RUN
|
|
87
|
+
rest = path[len(drive + root) :]
|
|
88
|
+
lead = run.match(rest)
|
|
89
|
+
root_sep = root + (lead.group() if lead else "")
|
|
90
|
+
rest = rest[len(root_sep) - len(root) :]
|
|
91
|
+
pieces = run.split(rest)
|
|
92
|
+
gaps = run.findall(rest)
|
|
93
|
+
if pieces[-1] == "" and gaps:
|
|
94
|
+
trailing = gaps.pop()
|
|
95
|
+
pieces.pop()
|
|
96
|
+
else:
|
|
97
|
+
trailing = ""
|
|
98
|
+
names = tuple(pieces) if pieces != [""] else ()
|
|
99
|
+
return Parts(drive + root_sep, names, tuple(gaps), trailing, root_sep)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _anchor_key(anchor: str, flavor: str, sep: str | None) -> str:
|
|
103
|
+
if sep is not None:
|
|
104
|
+
return anchor and sep
|
|
105
|
+
if flavor == "posix":
|
|
106
|
+
# Two leading slashes are implementation defined; three or more mean one.
|
|
107
|
+
return "//" if anchor == "//" else anchor[:1]
|
|
108
|
+
if not anchor.startswith(_VERBATIM):
|
|
109
|
+
anchor = anchor.replace("/", _BACK)
|
|
110
|
+
# The anchor took in the separator run after the root; its length does not matter.
|
|
111
|
+
stripped = anchor.rstrip(_BACK)
|
|
112
|
+
return (stripped + _BACK if stripped != anchor else anchor).lower()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def fold_home(parts: Parts, home: Parts, flavor: str, sep: str | None) -> Parts:
|
|
116
|
+
"""Replace a leading ``home`` in ``parts`` with ``~``; ``parts`` unchanged otherwise."""
|
|
117
|
+
count = len(home.names)
|
|
118
|
+
if count == 0 or not home.root_sep:
|
|
119
|
+
# A relative home, or one that is only a root, says nothing about where a path is.
|
|
120
|
+
return parts
|
|
121
|
+
if _anchor_key(parts.anchor, flavor, sep) != _anchor_key(home.anchor, flavor, sep):
|
|
122
|
+
return parts
|
|
123
|
+
mine, theirs = parts.names[:count], home.names
|
|
124
|
+
if sep is None and flavor == "windows":
|
|
125
|
+
# Windows names compare without regard to case.
|
|
126
|
+
mine = tuple(name.lower() for name in mine)
|
|
127
|
+
theirs = tuple(name.lower() for name in theirs)
|
|
128
|
+
if mine != theirs:
|
|
129
|
+
return parts
|
|
130
|
+
if len(parts.names) == count:
|
|
131
|
+
return Parts("~", (), (), parts.trailing)
|
|
132
|
+
root_sep = parts.seps[count - 1]
|
|
133
|
+
return Parts("~" + root_sep, parts.names[count:], parts.seps[count:], parts.trailing, root_sep)
|
shortenpath/_shorten.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Width-budgeted abbreviation of filesystem paths for display."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from bisect import bisect_right
|
|
7
|
+
from itertools import accumulate
|
|
8
|
+
|
|
9
|
+
from ._name import squeeze
|
|
10
|
+
from ._parts import Flavor, Parts, fold_home, resolve, split
|
|
11
|
+
from ._width import display_width
|
|
12
|
+
|
|
13
|
+
_PLACEHOLDER = "..."
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def shorten(
|
|
17
|
+
path: str | os.PathLike[str],
|
|
18
|
+
width: int,
|
|
19
|
+
*,
|
|
20
|
+
flavor: Flavor = "native",
|
|
21
|
+
home_tilde: bool = True,
|
|
22
|
+
home: str | os.PathLike[str] | None = None,
|
|
23
|
+
sep: str | None = None,
|
|
24
|
+
placeholder: str = _PLACEHOLDER,
|
|
25
|
+
) -> str:
|
|
26
|
+
"""Return ``path`` shortened to fit ``width`` terminal columns.
|
|
27
|
+
|
|
28
|
+
``flavor`` picks the path rules (``"posix"``, ``"windows"`` or ``"native"`` for the
|
|
29
|
+
running system); ``sep`` replaces them with a single custom separator. The home directory
|
|
30
|
+
collapses to ``~`` when ``home_tilde`` is set. A path still too wide keeps its anchor
|
|
31
|
+
(root, drive, UNC share or device prefix) and its last name, and as many whole segments
|
|
32
|
+
around ``placeholder`` as fit. Display only: the filesystem is never read.
|
|
33
|
+
"""
|
|
34
|
+
text = _text(path, "path")
|
|
35
|
+
if isinstance(width, bool) or not isinstance(width, int):
|
|
36
|
+
raise TypeError(f"width must be int, not {type(width).__name__}")
|
|
37
|
+
if width < 1:
|
|
38
|
+
raise ValueError("width must be at least 1")
|
|
39
|
+
if sep is not None and (not isinstance(sep, str) or not sep):
|
|
40
|
+
raise ValueError("sep must be a non-empty string")
|
|
41
|
+
if not isinstance(placeholder, str) or not placeholder:
|
|
42
|
+
raise ValueError("placeholder must be a non-empty string")
|
|
43
|
+
rules = resolve(flavor)
|
|
44
|
+
parts = split(text, rules, sep)
|
|
45
|
+
if home_tilde:
|
|
46
|
+
base = _home(home, rules, sep)
|
|
47
|
+
if base:
|
|
48
|
+
parts = fold_home(parts, split(base, rules, sep), rules, sep)
|
|
49
|
+
text = parts.render()
|
|
50
|
+
if display_width(text) <= width:
|
|
51
|
+
return text
|
|
52
|
+
return _fit(parts, width, placeholder)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _text(value: object, label: str) -> str:
|
|
56
|
+
if isinstance(value, os.PathLike):
|
|
57
|
+
value = os.fspath(value)
|
|
58
|
+
if not isinstance(value, str):
|
|
59
|
+
raise TypeError(f"{label} must be str or os.PathLike[str], not {type(value).__name__}")
|
|
60
|
+
return value
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _home(home: object, rules: str, sep: str | None) -> str:
|
|
64
|
+
if home is not None:
|
|
65
|
+
return _text(home, "home")
|
|
66
|
+
if sep is not None or rules != resolve("native"):
|
|
67
|
+
# The running user's home only means something for paths of the running system.
|
|
68
|
+
return ""
|
|
69
|
+
# Without a known home this gives back "~", which folds nothing that would change.
|
|
70
|
+
return os.path.expanduser("~")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _fit(parts: Parts, width: int, placeholder: str) -> str:
|
|
74
|
+
names, seps = parts.names, parts.seps
|
|
75
|
+
count = len(names)
|
|
76
|
+
if count == 0:
|
|
77
|
+
return squeeze(parts.render(), width, placeholder)
|
|
78
|
+
if count > 1:
|
|
79
|
+
best = _search(parts, width - display_width(parts.anchor) - display_width(placeholder))
|
|
80
|
+
if best is not None:
|
|
81
|
+
keep_head, keep_tail = best
|
|
82
|
+
start = "".join(name + seps[i] for i, name in enumerate(names[:keep_head]))
|
|
83
|
+
end = "".join(seps[i - 1] + names[i] for i in range(count - keep_tail, count))
|
|
84
|
+
return parts.anchor + start + placeholder + end + parts.trailing
|
|
85
|
+
# The whole last name is worth more than the anchor: after the placeholder and the
|
|
86
|
+
# separator as written, then a single separator, then alone.
|
|
87
|
+
before = seps[-1] if count > 1 else parts.root_sep
|
|
88
|
+
for run in dict.fromkeys((before, before[-1:])):
|
|
89
|
+
bare = placeholder + run + names[-1] + parts.trailing
|
|
90
|
+
if run and display_width(bare) <= width:
|
|
91
|
+
return bare
|
|
92
|
+
for whole in (names[-1] + parts.trailing, names[-1]):
|
|
93
|
+
if display_width(whole) <= width:
|
|
94
|
+
return whole
|
|
95
|
+
if count == 1:
|
|
96
|
+
room = width - display_width(parts.anchor) - display_width(parts.trailing)
|
|
97
|
+
if (parts.anchor or parts.trailing) and room > display_width(placeholder) + 1:
|
|
98
|
+
return parts.anchor + squeeze(names[0], room, placeholder) + parts.trailing
|
|
99
|
+
return squeeze(names[-1], width, placeholder)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _search(parts: Parts, room: int) -> tuple[int, int] | None:
|
|
103
|
+
"""Most segments kept around the placeholder, then most at the head; None if none fit."""
|
|
104
|
+
names, seps = parts.names, parts.seps
|
|
105
|
+
count = len(names)
|
|
106
|
+
# heads[h]: columns of the first h names, each with the separator after it.
|
|
107
|
+
heads = [
|
|
108
|
+
0,
|
|
109
|
+
*accumulate(display_width(names[i]) + display_width(seps[i]) for i in range(count - 1)),
|
|
110
|
+
]
|
|
111
|
+
# tails[t]: columns of the last t names, each with the separator before it.
|
|
112
|
+
trail = display_width(parts.trailing)
|
|
113
|
+
tails = [
|
|
114
|
+
0,
|
|
115
|
+
*accumulate(
|
|
116
|
+
display_width(seps[count - 2 - t])
|
|
117
|
+
+ display_width(names[count - 1 - t])
|
|
118
|
+
+ (trail if t == 0 else 0)
|
|
119
|
+
for t in range(count - 1)
|
|
120
|
+
),
|
|
121
|
+
]
|
|
122
|
+
best: tuple[int, int] | None = None
|
|
123
|
+
for keep_head in range(count - 1):
|
|
124
|
+
keep_tail = min(bisect_right(tails, room - heads[keep_head]) - 1, count - 1 - keep_head)
|
|
125
|
+
if keep_tail >= 1 and (best is None or keep_head + keep_tail >= sum(best)):
|
|
126
|
+
best = (keep_head, keep_tail)
|
|
127
|
+
return best
|
shortenpath/_width.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Terminal display width of text, and cuts that never split a character from its marks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import unicodedata
|
|
6
|
+
|
|
7
|
+
_ZERO_WIDTH_CATEGORIES = frozenset({"Mn", "Me", "Cf"})
|
|
8
|
+
_SOFT_HYPHEN = "\u00ad"
|
|
9
|
+
_ZERO_WIDTH_JOINER = "\u200d"
|
|
10
|
+
_EMOJI_PRESENTATION = "\ufe0f"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def char_width(char: str) -> int:
|
|
14
|
+
"""Columns taken by one code point: 0, 1 or 2."""
|
|
15
|
+
if char == _SOFT_HYPHEN:
|
|
16
|
+
# A format character, but terminals and wcwidth() show it as a visible hyphen.
|
|
17
|
+
return 1
|
|
18
|
+
if unicodedata.category(char) in _ZERO_WIDTH_CATEGORIES:
|
|
19
|
+
return 0
|
|
20
|
+
if "\u1160" <= char <= "\u11ff" or "\ud7b0" <= char <= "\ud7ff":
|
|
21
|
+
# Hangul medial vowels and final consonants (and their extension block) join the
|
|
22
|
+
# syllable before them.
|
|
23
|
+
return 0
|
|
24
|
+
if unicodedata.east_asian_width(char) in ("W", "F"):
|
|
25
|
+
return 2
|
|
26
|
+
return 1
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def display_width(text: str) -> int:
|
|
30
|
+
"""Return the number of terminal columns ``text`` takes.
|
|
31
|
+
|
|
32
|
+
East Asian Wide and Fullwidth characters take two columns; combining marks, format
|
|
33
|
+
characters and Hangul medial and final jamo take none; a character followed by the
|
|
34
|
+
emoji presentation selector takes two; everything else takes one.
|
|
35
|
+
"""
|
|
36
|
+
if not isinstance(text, str):
|
|
37
|
+
raise TypeError(f"text must be str, not {type(text).__name__}")
|
|
38
|
+
return sum(width for _, width in clusters(text))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def clusters(text: str) -> list[tuple[str, int]]:
|
|
42
|
+
"""Split ``text`` into cuttable units with their widths.
|
|
43
|
+
|
|
44
|
+
A unit is a character plus every zero width character after it; a zero width joiner also
|
|
45
|
+
pulls in the character it joins. A unit with the emoji presentation selector is drawn as
|
|
46
|
+
an emoji, two columns wide.
|
|
47
|
+
"""
|
|
48
|
+
units: list[tuple[str, int]] = []
|
|
49
|
+
glue = False
|
|
50
|
+
for char in text:
|
|
51
|
+
width = char_width(char)
|
|
52
|
+
if units and (width == 0 or glue):
|
|
53
|
+
last, last_width = units[-1]
|
|
54
|
+
if char == _EMOJI_PRESENTATION:
|
|
55
|
+
last_width = max(last_width, 2)
|
|
56
|
+
units[-1] = (last + char, last_width + width)
|
|
57
|
+
else:
|
|
58
|
+
units.append((char, width))
|
|
59
|
+
glue = char == _ZERO_WIDTH_JOINER
|
|
60
|
+
return units
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def head(units: list[tuple[str, int]], budget: int) -> tuple[str, int]:
|
|
64
|
+
"""Longest run of ``units`` from the start that fits ``budget`` columns."""
|
|
65
|
+
taken: list[str] = []
|
|
66
|
+
used = 0
|
|
67
|
+
for text, width in units:
|
|
68
|
+
if used + width > budget:
|
|
69
|
+
break
|
|
70
|
+
taken.append(text)
|
|
71
|
+
used += width
|
|
72
|
+
return "".join(taken), used
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def tail(units: list[tuple[str, int]], budget: int) -> tuple[str, int]:
|
|
76
|
+
"""Longest run of ``units`` from the end that fits ``budget`` columns."""
|
|
77
|
+
taken: list[str] = []
|
|
78
|
+
used = 0
|
|
79
|
+
for text, width in reversed(units):
|
|
80
|
+
if used + width > budget:
|
|
81
|
+
break
|
|
82
|
+
taken.append(text)
|
|
83
|
+
used += width
|
|
84
|
+
return "".join(reversed(taken)), used
|
shortenpath/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: shortenpath
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Shorten a filesystem path to fit a terminal column budget: home folds to ~, the root and file name stay, the middle goes
|
|
5
|
+
Project-URL: Homepage, https://lianes8studio.com
|
|
6
|
+
Project-URL: Repository, https://github.com/JaviLianes8/shortenpath
|
|
7
|
+
Project-URL: Issues, https://github.com/JaviLianes8/shortenpath/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/JaviLianes8/shortenpath/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: LinkedIn, https://www.linkedin.com/in/jlianes/
|
|
10
|
+
Project-URL: Funding, https://buymeacoffee.com/jlianesglrs
|
|
11
|
+
Author-email: Javier Lianes <javier@lianes8studio.com>
|
|
12
|
+
License-Expression: MIT
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Keywords: cli,display,elide,ellipsis,path,shorten,terminal,tilde,unc,width
|
|
15
|
+
Classifier: Development Status :: 4 - Beta
|
|
16
|
+
Classifier: Environment :: Console
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
26
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
27
|
+
Classifier: Topic :: System :: Filesystems
|
|
28
|
+
Classifier: Topic :: Terminals
|
|
29
|
+
Classifier: Typing :: Typed
|
|
30
|
+
Requires-Python: >=3.10
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: build; extra == 'dev'
|
|
33
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
34
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
35
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
36
|
+
Requires-Dist: twine; extra == 'dev'
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
|
|
39
|
+
# shortenpath
|
|
40
|
+
|
|
41
|
+
Shorten a filesystem path to fit a terminal column budget for prompts, status bars, tables
|
|
42
|
+
and logs. The home folder folds to `~`, the root and the file name stay, and whole folders
|
|
43
|
+
in the middle give way to `...`. Display only: the filesystem is never read.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
pip install shortenpath
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from shortenpath import shorten
|
|
55
|
+
|
|
56
|
+
path = "/home/javi/projects/shop/src/cart/checkout.py"
|
|
57
|
+
print(shorten(path, 60, flavor="posix", home="/home/javi"))
|
|
58
|
+
print(shorten(path, 30, flavor="posix", home="/home/javi"))
|
|
59
|
+
print(shorten(path, 22, flavor="posix", home="/home/javi"))
|
|
60
|
+
print(shorten(path, 14, flavor="posix", home="/home/javi"))
|
|
61
|
+
print(shorten(path, 9, flavor="posix", home="/home/javi"))
|
|
62
|
+
# ~/projects/shop/src/cart/checkout.py
|
|
63
|
+
# ~/.../src/cart/checkout.py
|
|
64
|
+
# ~/.../cart/checkout.py
|
|
65
|
+
# checkout.py
|
|
66
|
+
# ch...t.py
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Windows paths keep their drive, UNC share or device prefix whole, and both `\` and `/`
|
|
70
|
+
separate folders (each kept as written). The home folder matches regardless of case.
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from shortenpath import shorten
|
|
74
|
+
|
|
75
|
+
print(shorten(r"C:\Users\javi\AppData\Local\Temp\report.pdf", 24, flavor="windows"))
|
|
76
|
+
print(shorten(r"\\fileserver\finance\2026\q3\budget.xlsx", 36, flavor="windows"))
|
|
77
|
+
print(shorten(r"\\?\C:\very\deep\tree\of\folders\notes.txt", 26, flavor="windows"))
|
|
78
|
+
print(
|
|
79
|
+
shorten("C:/Users/javi/Downloads/tools/setup.exe", 26, flavor="windows", home=r"c:\users\javi")
|
|
80
|
+
)
|
|
81
|
+
# C:\Users\...\report.pdf
|
|
82
|
+
# \\fileserver\finance\...\budget.xlsx
|
|
83
|
+
# \\?\C:\very\...\notes.txt
|
|
84
|
+
# ~/Downloads/.../setup.exe
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The budget is in terminal columns: East Asian wide characters take two, combining accents
|
|
88
|
+
none, and a cut never splits a letter from its accent.
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from shortenpath import display_width, shorten
|
|
92
|
+
|
|
93
|
+
path = "/srv/写真/2026/夏休み/海辺の夕焼け.jpg"
|
|
94
|
+
print(display_width(path), len(path))
|
|
95
|
+
print(shorten(path, 30, flavor="posix"))
|
|
96
|
+
print(shorten(path, 24, flavor="posix"))
|
|
97
|
+
# 38 27
|
|
98
|
+
# /srv/写真/.../海辺の夕焼け.jpg
|
|
99
|
+
# /.../海辺の夕焼け.jpg
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Any separator and any placeholder:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from shortenpath import shorten
|
|
106
|
+
|
|
107
|
+
print(shorten("docs::api::v2::internal::client", 20, sep="::"))
|
|
108
|
+
print(shorten("/var/log/nginx/access.log", 16, flavor="posix", placeholder="…"))
|
|
109
|
+
# docs::...::client
|
|
110
|
+
# /…/access.log
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## The rules
|
|
114
|
+
|
|
115
|
+
`shorten(path, width, *, flavor="native", home_tilde=True, home=None, sep=None,
|
|
116
|
+
placeholder="...")` returns `path` itself when it fits, otherwise the first of these that
|
|
117
|
+
fits `width` columns:
|
|
118
|
+
|
|
119
|
+
1. The anchor, then as many whole folders as fit around the placeholder, then the last
|
|
120
|
+
name: the form keeping the most names wins, and between equals the one keeping more at
|
|
121
|
+
the start. The anchor is a POSIX root (`/`, `//`), a drive (`C:\`, `C:`), a UNC share
|
|
122
|
+
(`\\server\share\`), a device path (`\\.\C:\`, `\\?\Volume{...}\`,
|
|
123
|
+
`\\?\UNC\server\share\`) or `~`.
|
|
124
|
+
2. The placeholder, the separator and the last name: the whole name is worth more than the
|
|
125
|
+
anchor. A run of separators that does not fit shrinks to one.
|
|
126
|
+
3. The last name alone, then the anchor with the name cut in the middle (a path with a
|
|
127
|
+
single name and room for both).
|
|
128
|
+
4. The last name cut in the middle: its start, the placeholder, and its end, which keeps
|
|
129
|
+
the extension and the stem character before it when there is room for them. With no
|
|
130
|
+
room for the placeholder only the start of the name is kept, and when not even its first
|
|
131
|
+
character fits the result is empty.
|
|
132
|
+
|
|
133
|
+
| Option | Meaning |
|
|
134
|
+
| --- | --- |
|
|
135
|
+
| `flavor` | `"posix"`, `"windows"` or `"native"` (the running system). `Flavor` is its type. |
|
|
136
|
+
| `home_tilde`, `home` | Fold the home folder to `~`. `home` defaults to the current user's for native paths. A relative home, or one that is only a root, never folds. |
|
|
137
|
+
| `sep` | A custom separator instead of the flavor's rules, for URLs or keys. |
|
|
138
|
+
| `placeholder` | What stands for the removed folders, `...` by default. |
|
|
139
|
+
|
|
140
|
+
Separator runs are kept as written. On Windows a path that starts exactly with `\\?\`
|
|
141
|
+
skips normalization, so there only `\` separates and `/` is part of a name.
|
|
142
|
+
`display_width(text)` gives the columns of any text: East Asian wide and fullwidth
|
|
143
|
+
characters and characters with the emoji presentation selector take two, combining marks,
|
|
144
|
+
format characters and Hangul medial and final jamo none, everything else (control
|
|
145
|
+
characters included) one. Emoji joined into one picture are counted as their parts, which
|
|
146
|
+
can only overestimate. Wrong types raise `TypeError`; a
|
|
147
|
+
width below one or an empty separator or placeholder raises `ValueError`.
|
|
148
|
+
|
|
149
|
+
Compared with the Win32 `PathCompactPathEx` function, this never cuts inside the
|
|
150
|
+
anchor or a folder name, keeps the extension of a cut name, counts columns instead of
|
|
151
|
+
characters, and returns a path exactly as long as the budget unchanged.
|
|
152
|
+
|
|
153
|
+
## Command line
|
|
154
|
+
|
|
155
|
+
```
|
|
156
|
+
$ shortenpath -w 24 -f posix --home /home/javi /home/javi/projects/shop/src/cart/checkout.py
|
|
157
|
+
~/.../cart/checkout.py
|
|
158
|
+
$ shortenpath -w 24 -f windows 'C:\Program Files\Common Files\system\ado\msado15.dll'
|
|
159
|
+
C:\...\ado\msado15.dll
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Paths come from the arguments, or one per line from standard input, each written as soon
|
|
163
|
+
as it is read. `-w` defaults to the terminal width. Bad input exits with status 2.
|
|
164
|
+
`python -m shortenpath` works too.
|
|
165
|
+
|
|
166
|
+
## Why
|
|
167
|
+
|
|
168
|
+
Folding a path to `~` and trimming its middle to fit a column budget is a common need,
|
|
169
|
+
yet on PyPI the pieces live apart: `os.path.expanduser` only expands, and
|
|
170
|
+
`textwrap.shorten` cuts words from the end, not folders from the middle. Most snippets
|
|
171
|
+
that do it count characters, split on one separator and cut UNC shares in half. This one
|
|
172
|
+
reads Windows and POSIX path forms, counts columns, and has no dependencies.
|
|
173
|
+
|
|
174
|
+
## Development
|
|
175
|
+
|
|
176
|
+
`python scripts/fetch_sources.py` downloads the Microsoft, POSIX and Unicode texts the
|
|
177
|
+
tests check the rules against (listed with their hashes in `tests/sources/MANIFEST.txt`);
|
|
178
|
+
without them those tests skip. `SHORTENPATH_E2E=1 pytest` also builds the wheel and tests
|
|
179
|
+
the installed package. `scripts/check.ps1` runs the whole gate.
|
|
180
|
+
|
|
181
|
+
## License
|
|
182
|
+
|
|
183
|
+
MIT. See [LICENSE](LICENSE).
|
|
184
|
+
|
|
185
|
+
## Links
|
|
186
|
+
|
|
187
|
+
- Website: https://lianes8studio.com
|
|
188
|
+
- GitHub: https://github.com/JaviLianes8
|
|
189
|
+
- LinkedIn: https://www.linkedin.com/in/jlianes/
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
shortenpath/__init__.py,sha256=gDO3SBVHvPafW9Akm72uj5udJ098LBfy-fu2lvwfa7M,281
|
|
2
|
+
shortenpath/__main__.py,sha256=kOBuR4dCBqcWq5AGrg66ldy2SmoEwiWCUkpWyo-QsVk,196
|
|
3
|
+
shortenpath/_cli.py,sha256=mKDmUkPr_vFiAp-LrJ4NUIMe43NkZAPPHiwJ4Xmokrc,3811
|
|
4
|
+
shortenpath/_name.py,sha256=Va51_kRXoqKcZ53th5XqpieB08eUONWAMEgTesqad7Y,1844
|
|
5
|
+
shortenpath/_parts.py,sha256=UgK2EFqT55_BkYM2uiibsyGb2zFw4m1rH7ROnzSC-0w,5015
|
|
6
|
+
shortenpath/_shorten.py,sha256=peNBPxSZ8_I7ouK_SKwFlEddpk3EdHl3iDEB0UWQKBo,5202
|
|
7
|
+
shortenpath/_width.py,sha256=_iXcSUTg4qe17GWiRHX2vcZlJGHF88awBwP1mik2SPg,2923
|
|
8
|
+
shortenpath/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
shortenpath-0.2.0.dist-info/METADATA,sha256=XWU5-IEsAG6R6ZclNWstfdkaFnkgFzkm6aB8_KylUh8,7910
|
|
10
|
+
shortenpath-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
11
|
+
shortenpath-0.2.0.dist-info/entry_points.txt,sha256=iDlwLWQGAJbbB9IRGoaRcRBYALsSznZ817HxJ-0RiAk,54
|
|
12
|
+
shortenpath-0.2.0.dist-info/licenses/LICENSE,sha256=_WmABlQG__oqlx4Rzp-qDJ10xND5KCLScftbhXbLC_c,1070
|
|
13
|
+
shortenpath-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Javier Lianes
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|