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.
@@ -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"]
@@ -0,0 +1,10 @@
1
+ """Run the command line tool with ``python -m shortenpath``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from shortenpath._cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
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)
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ shortenpath = shortenpath._cli:main
@@ -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.