ellipt 0.2.1__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.
ellipt/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """Truncate strings with an ellipsis at the start, middle or end.
2
+
3
+ Limits can count code points, grapheme clusters, terminal columns or UTF-8 bytes, and
4
+ the cut never splits a grapheme cluster (UAX #29, Unicode 18.0.0 tables embedded).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ._core import ELLIPSIS, Truncation, Where, truncate
10
+ from ._graphemes import iter_graphemes
11
+ from ._measure import UNITS, Unit, measure
12
+ from ._path import shorten_path
13
+ from ._props import UNICODE_VERSION
14
+ from ._shorten import shorten
15
+
16
+ __version__ = "0.2.1"
17
+
18
+ __all__ = [
19
+ "ELLIPSIS",
20
+ "UNICODE_VERSION",
21
+ "UNITS",
22
+ "Truncation",
23
+ "Unit",
24
+ "Where",
25
+ "iter_graphemes",
26
+ "measure",
27
+ "shorten",
28
+ "shorten_path",
29
+ "truncate",
30
+ ]
ellipt/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Allow ``python -m ellipt``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from ._cli import main
8
+
9
+ sys.exit(main())
ellipt/_cli.py ADDED
@@ -0,0 +1,186 @@
1
+ """Command line tool: truncate every line of standard input or of the given files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import contextlib
7
+ import errno
8
+ import os
9
+ import sys
10
+ from collections.abc import Iterator, Sequence
11
+ from typing import IO, NoReturn
12
+
13
+ from . import __version__
14
+ from ._core import ELLIPSIS, truncate
15
+ from ._measure import UNITS
16
+ from ._path import shorten_path
17
+
18
+ EXAMPLES = """\
19
+ examples:
20
+ ls | ellipt 20 end ellipsis at 20 columns
21
+ ellipt 30 --where middle notes.txt keep both ends of each line
22
+ git ls-files | ellipt 28 --path keep the root and the file name
23
+ ellipt 255 --unit bytes --marker "" < titles.txt
24
+ fit a 255-byte UTF-8 column
25
+ tail -f app.log | ellipt 80 --where start --ellipsis
26
+
27
+ Lines are read as UTF-8 (a leading byte order mark is dropped) and written as UTF-8,
28
+ keeping each LF or CRLF line ending. The limit counts the marker, and no grapheme
29
+ cluster (accent, flag, emoji sequence) is ever split. With --unit columns, tabs are
30
+ expanded to spaces first (--tabsize); ANSI escape sequences are not recognised, so
31
+ strip colours before piping. Errors go to standard error with exit status 1.
32
+ """
33
+
34
+
35
+ class _PipeClosed(Exception):
36
+ """The reader of standard output went away."""
37
+
38
+
39
+ class _Parser(argparse.ArgumentParser):
40
+ def error(self, message: str) -> NoReturn:
41
+ self.print_usage(sys.stderr)
42
+ self.exit(1, f"{self.prog}: error: {message}\n")
43
+
44
+
45
+ def _non_negative(value: str) -> int:
46
+ try:
47
+ number = int(value)
48
+ except ValueError:
49
+ raise argparse.ArgumentTypeError(f"not a whole number: {value!r}") from None
50
+ if number < 0:
51
+ raise argparse.ArgumentTypeError(f"must be non-negative: {number}")
52
+ return number
53
+
54
+
55
+ def build_parser() -> argparse.ArgumentParser:
56
+ """Return the argument parser of the ``ellipt`` command."""
57
+ parser = _Parser(
58
+ prog="ellipt",
59
+ description="Truncate each input line to LIMIT with an ellipsis at the start, "
60
+ "middle or end.",
61
+ epilog=EXAMPLES,
62
+ formatter_class=argparse.RawDescriptionHelpFormatter,
63
+ )
64
+ parser.add_argument("limit", type=_non_negative, metavar="LIMIT", help="maximum size")
65
+ parser.add_argument("files", nargs="*", metavar="FILE", help="input files (default: stdin)")
66
+ parser.add_argument(
67
+ "-w", "--where", choices=("start", "middle", "end"), help="where to cut (default: end)"
68
+ )
69
+ parser.add_argument(
70
+ "-u",
71
+ "--unit",
72
+ choices=UNITS,
73
+ default="columns",
74
+ help="what LIMIT counts (default: columns, terminal cells)",
75
+ )
76
+ marker = parser.add_mutually_exclusive_group()
77
+ marker.add_argument("-m", "--marker", default="...", help='replacement text (default: "...")')
78
+ marker.add_argument(
79
+ "-e",
80
+ "--ellipsis",
81
+ dest="marker",
82
+ action="store_const",
83
+ const=ELLIPSIS,
84
+ help="use the single character U+2026 HORIZONTAL ELLIPSIS as the marker",
85
+ )
86
+ parser.add_argument("--words", action="store_true", help="cut at whitespace when possible")
87
+ parser.add_argument(
88
+ "--path", action="store_true", help="treat lines as paths: drop middle directories"
89
+ )
90
+ parser.add_argument(
91
+ "--ambiguous",
92
+ type=int,
93
+ choices=(1, 2),
94
+ default=1,
95
+ help="columns of East Asian Ambiguous characters (default: 1)",
96
+ )
97
+ parser.add_argument(
98
+ "--tabsize",
99
+ type=_non_negative,
100
+ default=8,
101
+ help="with --unit columns, expand tabs to this tab stop first; 0 keeps them as "
102
+ "zero-width characters (default: 8)",
103
+ )
104
+ parser.add_argument("-V", "--version", action="version", version=f"ellipt {__version__}")
105
+ return parser
106
+
107
+
108
+ def _lines(names: Sequence[str]) -> Iterator[tuple[str, int, bytes]]:
109
+ """Yield ``(source name, line number, raw line)`` with any UTF-8 BOM removed."""
110
+ if not names:
111
+ yield from _numbered("<stdin>", sys.stdin.buffer)
112
+ return
113
+ for name in names:
114
+ with open(name, "rb") as handle:
115
+ yield from _numbered(name, handle)
116
+
117
+
118
+ def _numbered(name: str, handle: IO[bytes]) -> Iterator[tuple[str, int, bytes]]:
119
+ for number, raw in enumerate(handle, 1):
120
+ yield name, number, raw[3:] if number == 1 and raw.startswith(b"\xef\xbb\xbf") else raw
121
+
122
+
123
+ def _write(out: IO[bytes], data: bytes) -> None:
124
+ try:
125
+ out.write(data)
126
+ except OSError as exc:
127
+ # Windows reports a closed pipe as EINVAL rather than EPIPE.
128
+ if isinstance(exc, BrokenPipeError) or exc.errno in (errno.EPIPE, errno.EINVAL):
129
+ raise _PipeClosed from None
130
+ raise
131
+
132
+
133
+ def _split_ending(line: str) -> tuple[str, str]:
134
+ for ending in ("\r\n", "\n", "\r"):
135
+ if line.endswith(ending):
136
+ return line[: -len(ending)], ending
137
+ return line, ""
138
+
139
+
140
+ def _run(args: argparse.Namespace, out: IO[bytes]) -> None:
141
+ for name, number, raw in _lines(args.files):
142
+ try:
143
+ line = raw.decode("utf-8")
144
+ except UnicodeDecodeError as exc:
145
+ raise ValueError(f"{name}: line {number} is not valid UTF-8: {exc.reason}") from None
146
+ body, ending = _split_ending(line)
147
+ if args.unit == "columns" and args.tabsize and "\t" in body:
148
+ body = body.expandtabs(args.tabsize)
149
+ if args.path:
150
+ short = shorten_path(
151
+ body, args.limit, unit=args.unit, marker=args.marker, ambiguous=args.ambiguous
152
+ )
153
+ else:
154
+ short = truncate(
155
+ body,
156
+ args.limit,
157
+ where=args.where or "end",
158
+ unit=args.unit,
159
+ marker=args.marker,
160
+ words=args.words,
161
+ ambiguous=args.ambiguous,
162
+ ).text
163
+ _write(out, (short + ending).encode("utf-8"))
164
+
165
+
166
+ def main(argv: Sequence[str] | None = None) -> int:
167
+ """Run the command line tool and return its exit status."""
168
+ parser = build_parser()
169
+ args = parser.parse_args(argv)
170
+ if args.path and (args.where not in (None, "middle") or args.words):
171
+ parser.error("--path always cuts in the middle and does not take --words")
172
+ try:
173
+ _run(args, sys.stdout.buffer)
174
+ _write(sys.stdout.buffer, b"")
175
+ sys.stdout.flush()
176
+ except (_PipeClosed, BrokenPipeError):
177
+ # Point stdout at the null device so the flush at exit cannot fail again.
178
+ with contextlib.suppress(OSError, ValueError, AttributeError):
179
+ null = os.open(os.devnull, os.O_WRONLY)
180
+ os.dup2(null, sys.stdout.fileno())
181
+ os.close(null)
182
+ return 0
183
+ except (OSError, ValueError) as exc:
184
+ print(f"ellipt: error: {exc}", file=sys.stderr)
185
+ return 1
186
+ return 0
ellipt/_core.py ADDED
@@ -0,0 +1,127 @@
1
+ """The truncation engine and its typed result."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Literal
7
+
8
+ from ._cut import cut
9
+ from ._measure import Unit, check_unit, cluster_size, clusters, size_of
10
+ from ._width import check_ambiguous
11
+
12
+ Where = Literal["start", "middle", "end"]
13
+ ELLIPSIS = "\u2026"
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Truncation:
18
+ """The outcome of ``truncate``: the shortened text and how it was built.
19
+
20
+ ``text == head + marker + tail`` always holds. ``head`` is a prefix and ``tail`` a
21
+ suffix of ``original``, both ending on grapheme cluster boundaries. When the text
22
+ already fit, ``head`` is the whole original and ``marker`` and ``tail`` are empty.
23
+ ``size`` is the size of ``text`` in ``unit`` and never exceeds ``limit``.
24
+ """
25
+
26
+ text: str
27
+ head: str
28
+ marker: str
29
+ tail: str
30
+ original: str
31
+ unit: Unit
32
+ limit: int
33
+ size: int
34
+
35
+ @property
36
+ def truncated(self) -> bool:
37
+ """True when part of the original was removed."""
38
+ return len(self.head) + len(self.tail) < len(self.original)
39
+
40
+ @property
41
+ def removed_span(self) -> tuple[int, int]:
42
+ """``(start, stop)`` code point indices of the removed part of ``original``."""
43
+ return len(self.head), len(self.original) - len(self.tail)
44
+
45
+ @property
46
+ def removed(self) -> str:
47
+ """The part of ``original`` that the marker replaces (empty when it fit)."""
48
+ start, stop = self.removed_span
49
+ return self.original[start:stop]
50
+
51
+ def __str__(self) -> str:
52
+ return self.text
53
+
54
+
55
+ def _check(text: str, limit: int, where: str, marker: str) -> None:
56
+ if not isinstance(text, str):
57
+ raise TypeError(f"text must be str, not {type(text).__name__}")
58
+ if not isinstance(marker, str):
59
+ raise TypeError(f"marker must be str, not {type(marker).__name__}")
60
+ if isinstance(limit, bool) or not isinstance(limit, int):
61
+ raise TypeError(f"limit must be int, not {type(limit).__name__}")
62
+ if where not in ("start", "middle", "end"):
63
+ raise ValueError(f"where must be 'start', 'middle' or 'end', not {where!r}")
64
+ if limit < 0:
65
+ raise ValueError(f"limit must be non-negative, not {limit}")
66
+
67
+
68
+ def truncate(
69
+ text: str,
70
+ limit: int,
71
+ *,
72
+ where: Where = "end",
73
+ unit: Unit = "chars",
74
+ marker: str = "...",
75
+ words: bool = False,
76
+ ambiguous: int = 1,
77
+ ) -> Truncation:
78
+ """Shorten ``text`` to at most ``limit`` and report exactly what happened.
79
+
80
+ ``where`` puts the marker at the ``"end"`` (like CSS ``text-overflow: ellipsis``),
81
+ the ``"start"`` (keep the tail, for ids and log lines) or the ``"middle"`` (keep
82
+ both ends, for paths and hashes). ``unit`` is what ``limit`` counts: ``"chars"``
83
+ (code points), ``"graphemes"``, ``"columns"`` (terminal cells) or ``"bytes"``
84
+ (UTF-8); see ``measure``. The marker is measured in the same unit and counted inside
85
+ the limit. The cut always falls on an extended grapheme cluster boundary (UAX #29),
86
+ so accents, flags and emoji ZWJ sequences are kept whole or dropped whole.
87
+
88
+ ``words=True`` moves each cut to whitespace and trims the whitespace next to the
89
+ marker, unless a single word fills the budget, in which case the cut stays hard.
90
+ Tabs and other controls are zero columns; expand tabs first if they matter.
91
+ ``ambiguous`` (1 or 2) is the width of East Asian Ambiguous characters in columns.
92
+
93
+ Text that already fits is returned whole. A marker larger than ``limit``, a negative
94
+ limit or an unknown ``where`` or ``unit`` raises ``ValueError``.
95
+
96
+ >>> truncate("hello world", 8).text
97
+ 'hello...'
98
+ >>> r = truncate("hello brave new world", 15, marker=ELLIPSIS, words=True)
99
+ >>> r.text, r.removed
100
+ ('hello brave\u2026', ' new world')
101
+ >>> truncate("Espa\\U0001f1ea\\U0001f1f8a!", 7, unit="chars", marker="..").text
102
+ 'Espa..'
103
+ """
104
+ _check(text, limit, where, marker)
105
+ unit = check_unit(unit)
106
+ ambiguous = check_ambiguous(ambiguous)
107
+ size = size_of(text, unit, ambiguous)
108
+ if size <= limit:
109
+ return Truncation(text, text, "", "", text, unit, limit, size)
110
+ marker_size = size_of(marker, unit, ambiguous)
111
+ if marker_size > limit:
112
+ raise ValueError(f"marker of size {marker_size} does not fit in limit {limit} ({unit})")
113
+ # Cluster boundaries depend only on what precedes them, so an end cut counted in
114
+ # chars or bytes (every cluster at least 1) only needs the first limit + 1 code points.
115
+ window = text[: limit + 1] if where == "end" and unit in ("chars", "bytes") else text
116
+ parts = clusters(window)
117
+ sizes = [cluster_size(c, unit, ambiguous) for c in parts]
118
+ spaces = [c.isspace() for c in parts] if words else None
119
+ for budget in range(limit - marker_size, -1, -1):
120
+ left, right = cut(sizes, budget, where, spaces)
121
+ head, tail = "".join(parts[:left]), "".join(parts[right:])
122
+ result = head + marker + tail
123
+ # A marker that joins a neighbouring cluster can change its size; re-measure.
124
+ size = size_of(result, unit, ambiguous)
125
+ if size <= limit:
126
+ return Truncation(result, head, marker, tail, text, unit, limit, size)
127
+ return Truncation(marker, "", marker, "", text, unit, limit, marker_size)
ellipt/_cut.py ADDED
@@ -0,0 +1,83 @@
1
+ """Choose which grapheme clusters to keep on each side of the cut.
2
+
3
+ Every function works on a list of cluster sizes and returns ``(left, right)``: keep the
4
+ first ``left`` clusters and the clusters from index ``right`` onwards. ``left <= right``
5
+ always holds, so the kept head and tail never overlap.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ def _take_left(sizes: list[int], budget: int, start: int, stop: int) -> tuple[int, int]:
12
+ """Greedily extend a prefix from ``start`` up to ``stop``; return (end, used)."""
13
+ used = 0
14
+ end = start
15
+ while end < stop and used + sizes[end] <= budget:
16
+ used += sizes[end]
17
+ end += 1
18
+ return end, used
19
+
20
+
21
+ def _take_right(sizes: list[int], budget: int, stop: int) -> tuple[int, int]:
22
+ """Greedily extend a suffix down to index ``stop``; return (start, used)."""
23
+ used = 0
24
+ start = len(sizes)
25
+ while start > stop and used + sizes[start - 1] <= budget:
26
+ used += sizes[start - 1]
27
+ start -= 1
28
+ return start, used
29
+
30
+
31
+ def cut(
32
+ sizes: list[int], budget: int, where: str, spaces: list[bool] | None = None
33
+ ) -> tuple[int, int]:
34
+ """Return ``(left, right)`` keeping at most ``budget`` in total size.
35
+
36
+ With ``spaces`` (one flag per cluster: is it whitespace) every cut is moved to a word
37
+ boundary and the whitespace next to the marker is dropped. If a single word fills
38
+ the whole budget, the hard cut is used instead of returning nothing.
39
+ """
40
+ count = len(sizes)
41
+ if where == "end":
42
+ left = _take_left(sizes, budget, 0, count)[0]
43
+ return (snap_left(spaces, left, False) if spaces else left), count
44
+ if where == "start":
45
+ right = _take_right(sizes, budget, 0)[0]
46
+ return 0, (snap_right(spaces, right, False) if spaces else right)
47
+ left, used_left = _take_left(sizes, budget - budget // 2, 0, count)
48
+ if spaces:
49
+ left = snap_left(spaces, left, True)
50
+ used_left = sum(sizes[:left])
51
+ right, used_right = _take_right(sizes, budget - used_left, left)
52
+ if spaces:
53
+ right = snap_right(spaces, right, True)
54
+ used_right = sum(sizes[right:])
55
+ # A wide cluster or a word snap may have left budget unused: hand it back.
56
+ more, _ = _take_left(sizes, budget - used_left - used_right, left, right)
57
+ left = max(left, snap_left(spaces, more, True)) if spaces else more
58
+ if spaces and left == 0 and right == count:
59
+ return cut(sizes, budget, where)
60
+ return left, right
61
+
62
+
63
+ def snap_left(spaces: list[bool], left: int, allow_empty: bool) -> int:
64
+ """Move a head end back to a word boundary and drop the whitespace before it."""
65
+ end = left
66
+ if left < len(spaces) and left > 0 and not spaces[left] and not spaces[left - 1]:
67
+ while end > 0 and not spaces[end - 1]:
68
+ end -= 1
69
+ while end > 0 and spaces[end - 1]:
70
+ end -= 1
71
+ return end if end > 0 or allow_empty else left
72
+
73
+
74
+ def snap_right(spaces: list[bool], right: int, allow_empty: bool) -> int:
75
+ """Move a tail start forward to a word boundary and drop the whitespace after it."""
76
+ count = len(spaces)
77
+ start = right
78
+ if 0 < right < count and not spaces[right] and not spaces[right - 1]:
79
+ while start < count and not spaces[start]:
80
+ start += 1
81
+ while start < count and spaces[start]:
82
+ start += 1
83
+ return start if start < count or allow_empty else right
ellipt/_graphemes.py ADDED
@@ -0,0 +1,103 @@
1
+ """Extended grapheme cluster segmentation per UAX #29, using embedded UCD tables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+
7
+ from ._props import EXT_PICT_BIT, GCB, INCB_SHIFT, prop
8
+
9
+ _CR = GCB["CR"]
10
+ _LF = GCB["LF"]
11
+ _CONTROL = GCB["Control"]
12
+ _EXTEND = GCB["Extend"]
13
+ _ZWJ = GCB["ZWJ"]
14
+ _RI = GCB["Regional_Indicator"]
15
+ _PREPEND = GCB["Prepend"]
16
+ _SPACING = GCB["SpacingMark"]
17
+ _L = GCB["L"]
18
+ _V = GCB["V"]
19
+ _T = GCB["T"]
20
+ _LV = GCB["LV"]
21
+ _LVT = GCB["LVT"]
22
+
23
+ _BREAKERS = frozenset({_CR, _LF, _CONTROL})
24
+ _AFTER_L = frozenset({_L, _V, _LV, _LVT})
25
+ _AFTER_LV_V = frozenset({_V, _T})
26
+ _AFTER_LVT_T = frozenset({_T})
27
+ _INCB_LINKER = 1
28
+ _INCB_CONSONANT = 2
29
+ _INCB_EXTEND = 3
30
+
31
+
32
+ def grapheme_ends(s: str) -> Iterator[int]:
33
+ """Yield the end index (exclusive) of every extended grapheme cluster in ``s``.
34
+
35
+ The last value yielded is ``len(s)``; nothing is yielded for the empty string.
36
+ """
37
+ if not s:
38
+ return
39
+ code = prop(ord(s[0]))
40
+ prev = code & 0x0F
41
+ ri_run = 1 if prev == _RI else 0
42
+ incb = (code >> INCB_SHIFT) & 0x03
43
+ after_linker = incb == _INCB_LINKER
44
+ emoji = 1 if code & EXT_PICT_BIT else 0 # 1: pictograph Extend*, 2: then ZWJ
45
+ for index in range(1, len(s)):
46
+ code = prop(ord(s[index]))
47
+ cur = code & 0x0F
48
+ incb = (code >> INCB_SHIFT) & 0x03
49
+ pict = code & EXT_PICT_BIT
50
+ if not _joins(prev, cur, incb, pict, after_linker, emoji, ri_run):
51
+ yield index
52
+ after_linker = incb == _INCB_LINKER or (after_linker and incb == _INCB_EXTEND)
53
+ if pict or (emoji == 1 and cur == _EXTEND):
54
+ emoji = 1
55
+ elif emoji == 1 and cur == _ZWJ:
56
+ emoji = 2
57
+ else:
58
+ emoji = 0
59
+ ri_run = ri_run + 1 if cur == _RI else 0
60
+ prev = cur
61
+ yield len(s)
62
+
63
+
64
+ def _joins(
65
+ prev: int, cur: int, incb: int, pict: int, after_linker: bool, emoji: int, ri_run: int
66
+ ) -> bool:
67
+ """Return True when there is no boundary between ``prev`` and ``cur``."""
68
+ if prev == _CR and cur == _LF: # GB3
69
+ return True
70
+ if prev in _BREAKERS or cur in _BREAKERS: # GB4, GB5
71
+ return False
72
+ if prev == _L and cur in _AFTER_L: # GB6
73
+ return True
74
+ if prev in (_LV, _V) and cur in _AFTER_LV_V: # GB7
75
+ return True
76
+ if prev in (_LVT, _T) and cur in _AFTER_LVT_T: # GB8
77
+ return True
78
+ if cur in (_EXTEND, _ZWJ, _SPACING) or prev == _PREPEND: # GB9, GB9a, GB9b
79
+ return True
80
+ if incb == _INCB_CONSONANT and after_linker: # GB9c
81
+ return True
82
+ if pict and prev == _ZWJ and emoji == 2: # GB11
83
+ return True
84
+ return prev == _RI and cur == _RI and ri_run % 2 == 1 # GB12, GB13
85
+
86
+
87
+ def iter_graphemes(s: str) -> Iterator[str]:
88
+ """Yield the extended grapheme clusters of ``s`` (UAX #29), left to right.
89
+
90
+ A grapheme cluster is what a reader perceives as one character: a base letter with
91
+ its combining marks, a Hangul syllable built from jamo, an emoji ZWJ sequence, a
92
+ flag made of two regional indicators, an Indic conjunct, or a CR LF pair. The
93
+ clusters concatenate back to ``s`` exactly.
94
+
95
+ >>> [len(g) for g in iter_graphemes("e\\u0301\\U0001f1ea\\U0001f1f8")]
96
+ [2, 2]
97
+ """
98
+ if not isinstance(s, str):
99
+ raise TypeError(f"iter_graphemes expects str, not {type(s).__name__}")
100
+ start = 0
101
+ for end in grapheme_ends(s):
102
+ yield s[start:end]
103
+ start = end
ellipt/_measure.py ADDED
@@ -0,0 +1,69 @@
1
+ """Units of measure for a limit and the cluster sizes they assign."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ from ._graphemes import grapheme_ends
8
+ from ._width import check_ambiguous, cluster_width
9
+
10
+ Unit = Literal["chars", "graphemes", "columns", "bytes"]
11
+ UNITS: tuple[Unit, ...] = ("chars", "graphemes", "columns", "bytes")
12
+
13
+
14
+ def check_unit(unit: str) -> Unit:
15
+ """Return ``unit`` after checking it names a supported unit."""
16
+ for known in UNITS:
17
+ if unit == known:
18
+ return known
19
+ raise ValueError(f"unit must be one of {', '.join(map(repr, UNITS))}, not {unit!r}")
20
+
21
+
22
+ def clusters(text: str) -> list[str]:
23
+ """Split ``text`` into extended grapheme clusters (UAX #29)."""
24
+ found: list[str] = []
25
+ start = 0
26
+ for end in grapheme_ends(text):
27
+ found.append(text[start:end])
28
+ start = end
29
+ return found
30
+
31
+
32
+ def cluster_size(cluster: str, unit: Unit, ambiguous: int) -> int:
33
+ """Size of one grapheme cluster in ``unit``."""
34
+ if unit == "chars":
35
+ return len(cluster)
36
+ if unit == "graphemes":
37
+ return 1
38
+ if unit == "columns":
39
+ return cluster_width(cluster, ambiguous)
40
+ return len(cluster.encode("utf-8", "surrogatepass"))
41
+
42
+
43
+ def size_of(text: str, unit: Unit, ambiguous: int) -> int:
44
+ """Size of ``text`` in ``unit`` without validating the arguments."""
45
+ if unit == "chars":
46
+ return len(text)
47
+ if unit == "bytes":
48
+ return len(text.encode("utf-8", "surrogatepass"))
49
+ return sum(cluster_size(c, unit, ambiguous) for c in clusters(text))
50
+
51
+
52
+ def measure(text: str, unit: Unit = "chars", *, ambiguous: int = 1) -> int:
53
+ """Return the size of ``text`` in ``unit``, the way ``truncate`` measures it.
54
+
55
+ ``"chars"`` counts code points (``len``), ``"graphemes"`` counts extended grapheme
56
+ clusters (UAX #29, what a reader sees as one character), ``"columns"`` counts
57
+ terminal display cells (UAX #11 East Asian Width, emoji sequences as one wide
58
+ glyph, East Asian Ambiguous characters as ``ambiguous`` cells) and ``"bytes"``
59
+ counts UTF-8 bytes.
60
+
61
+ >>> flag = "\\U0001f1ea\\U0001f1f8"
62
+ >>> [measure(flag, u) for u in ("chars", "graphemes", "columns", "bytes")]
63
+ [2, 1, 2, 8]
64
+ >>> measure("\\u2026", "bytes"), measure("...", "bytes")
65
+ (3, 3)
66
+ """
67
+ if not isinstance(text, str):
68
+ raise TypeError(f"text must be str, not {type(text).__name__}")
69
+ return size_of(text, check_unit(unit), check_ambiguous(ambiguous))
ellipt/_path.py ADDED
@@ -0,0 +1,90 @@
1
+ """Middle truncation of file system paths that keeps the root and the file name."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._core import truncate
6
+ from ._graphemes import grapheme_ends
7
+ from ._measure import Unit, check_unit, size_of
8
+ from ._width import check_ambiguous
9
+
10
+
11
+ def _separators(path: str, sep: str | None) -> list[int]:
12
+ """Indices of separators that sit on grapheme cluster boundaries on both sides."""
13
+ chars = "/\\" if sep is None else sep
14
+ bounds = {0, *grapheme_ends(path)}
15
+ return [i for i, ch in enumerate(path) if ch in chars and i in bounds and i + 1 in bounds]
16
+
17
+
18
+ def _shorten_name(name: str, limit: int, unit: Unit, marker: str, ambiguous: int) -> str:
19
+ """Shorten a file name in the middle of its stem, keeping the extension if it can."""
20
+ dot = name.rfind(".")
21
+ if name[:dot].lower().endswith(".tar"): # keep .tar.gz, .tar.xz and friends together
22
+ dot -= 4
23
+ if 0 < dot < len(name) - 1 and dot in {0, *grapheme_ends(name)}:
24
+ stem, ext = name[:dot], name[dot:]
25
+ room = limit - size_of(ext, unit, ambiguous)
26
+ if room > size_of(marker, unit, ambiguous):
27
+ short = truncate(
28
+ stem, room, where="middle", unit=unit, marker=marker, ambiguous=ambiguous
29
+ )
30
+ candidate = short.text + ext
31
+ if size_of(candidate, unit, ambiguous) <= limit:
32
+ return candidate
33
+ return truncate(name, limit, where="middle", unit=unit, marker=marker, ambiguous=ambiguous).text
34
+
35
+
36
+ def shorten_path(
37
+ path: str,
38
+ limit: int,
39
+ *,
40
+ unit: Unit = "chars",
41
+ marker: str = "...",
42
+ sep: str | None = None,
43
+ ambiguous: int = 1,
44
+ ) -> str:
45
+ r"""Shorten a path to at most ``limit`` by dropping whole directories from the middle.
46
+
47
+ The first component (``/``, ``C:\``, ``~/``, ``\\server\share\`` or a relative
48
+ top directory) and the file name are kept, and as many directories next to the file
49
+ name as fit, like the middle truncation of macOS Finder and editor tab titles. When
50
+ only ``marker/name`` fits the root goes too, and when even that is too long the file
51
+ name alone is shown, shortened in the middle of its stem with the extension (``.gz``
52
+ or a compound ``.tar.gz``) kept. ``sep`` is the
53
+ separator; by default both ``/`` and ``\`` count. ``unit``, ``marker`` and
54
+ ``ambiguous`` mean the same as in ``truncate``; an empty marker is rejected because
55
+ the result would read as a different, real path, and the result never exceeds
56
+ ``limit`` in ``unit``.
57
+
58
+ >>> shorten_path("/home/me/projects/app/src/main.py", 24)
59
+ '/.../app/src/main.py'
60
+ >>> shorten_path(r"C:\Users\me\Documents\report.pdf", 27)
61
+ 'C:\\...\\Documents\\report.pdf'
62
+ >>> shorten_path("photos/2026/an_extremely_long_holiday_name.jpeg", 20)
63
+ 'an_ext...y_name.jpeg'
64
+ """
65
+ if sep is not None and (not isinstance(sep, str) or len(sep) != 1):
66
+ raise ValueError(f"sep must be a single character, not {sep!r}")
67
+ if marker == "":
68
+ raise ValueError("marker must not be empty: the result would look like a real path")
69
+ result = truncate(path, limit, where="middle", unit=unit, marker=marker, ambiguous=ambiguous)
70
+ if not result.truncated:
71
+ return path
72
+ unit = check_unit(unit)
73
+ ambiguous = check_ambiguous(ambiguous)
74
+ stops = _separators(path.rstrip("/\\" if sep is None else sep), sep)
75
+ name_start = stops[-1] + 1 if stops else 0
76
+ # The root is the first component; a UNC or //host root also keeps host and share.
77
+ first = 3 if len(stops) > 4 and stops[:2] == [0, 1] else 0
78
+ candidates: list[str] = []
79
+ if len(stops) > first + 1:
80
+ head = path[: stops[first] + 1]
81
+ candidates += [head + marker + path[i:] for i in stops[first + 1 :] if i > stops[first] + 1]
82
+ if stops:
83
+ candidates.append(marker + path[name_start - 1 :])
84
+ for candidate in candidates:
85
+ if size_of(candidate, unit, ambiguous) <= limit:
86
+ return candidate
87
+ name = path[name_start:]
88
+ if size_of(name, unit, ambiguous) <= limit:
89
+ return name
90
+ return _shorten_name(name, limit, unit, marker, ambiguous)
ellipt/_props.py ADDED
@@ -0,0 +1,61 @@
1
+ """Lookup of the embedded Unicode properties: width class, grapheme and emoji data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from bisect import bisect_right
6
+
7
+ from ._ucd_data import (
8
+ EMOJI_BIT,
9
+ EXT_PICT_BIT,
10
+ GCB_NAMES,
11
+ INCB_SHIFT,
12
+ MODIFIER_BIT,
13
+ RUNS,
14
+ UNICODE_VERSION,
15
+ WIDTH_SHIFT,
16
+ )
17
+
18
+ __all__ = [
19
+ "AMBIGUOUS",
20
+ "EMOJI_BIT",
21
+ "EXT_PICT_BIT",
22
+ "GCB",
23
+ "INCB_SHIFT",
24
+ "MODIFIER_BIT",
25
+ "UNICODE_VERSION",
26
+ "WIDTH_SHIFT",
27
+ "prop",
28
+ ]
29
+
30
+ GCB = {name: index for index, name in enumerate(GCB_NAMES)}
31
+ AMBIGUOUS = 3
32
+
33
+
34
+ def _decode(runs: str) -> tuple[list[int], list[int]]:
35
+ """Expand the run tokens into parallel lists of run starts and property codes."""
36
+ starts: list[int] = []
37
+ codes: list[int] = []
38
+ position = 0
39
+ for token in runs.split():
40
+ starts.append(position)
41
+ codes.append(int(token[:3], 36))
42
+ position += int(token[3:], 36)
43
+ if position != 0x110000:
44
+ raise RuntimeError("embedded Unicode tables do not cover the code space")
45
+ return starts, codes
46
+
47
+
48
+ _STARTS, _CODES = _decode(RUNS)
49
+ _LATIN = tuple(_CODES[bisect_right(_STARTS, cp) - 1] for cp in range(0x300))
50
+
51
+
52
+ def prop(cp: int) -> int:
53
+ """Return the packed property code of code point ``cp``.
54
+
55
+ Bits 0-3 hold Grapheme_Cluster_Break (an index into ``GCB_NAMES``), bit 4 is
56
+ Extended_Pictographic, bits 5-6 Indic_Conjunct_Break, bit 7 Emoji, bit 8
57
+ Emoji_Modifier and bits 9-10 the width class (0 zero, 1 narrow, 2 wide, 3 ambiguous).
58
+ """
59
+ if cp < 0x300:
60
+ return _LATIN[cp]
61
+ return _CODES[bisect_right(_STARTS, cp) - 1]
ellipt/_shorten.py ADDED
@@ -0,0 +1,46 @@
1
+ """Truncate strings with an ellipsis at the start, middle or end."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._core import Where, truncate
6
+ from ._measure import Unit
7
+
8
+ __all__ = ["Where", "shorten"]
9
+
10
+
11
+ def shorten(
12
+ text: str,
13
+ width: int,
14
+ *,
15
+ where: Where = "end",
16
+ marker: str = "...",
17
+ width_aware: bool = False,
18
+ unit: Unit | None = None,
19
+ words: bool = False,
20
+ ambiguous: int = 1,
21
+ ) -> str:
22
+ """Return text clipped to width, with marker marking where it was cut.
23
+
24
+ The result never exceeds width, measured in ``unit``: ``"chars"`` (code points, the
25
+ default), ``"graphemes"``, ``"columns"`` (display cells) or ``"bytes"`` (UTF-8).
26
+ ``width_aware=True`` is a shorthand for ``unit="columns"``. The marker counts inside
27
+ the width, and the cut never splits a grapheme cluster. ``words=True`` cuts at
28
+ whitespace when it can. Text that already fits is returned unchanged. A width that
29
+ cannot even hold the marker, or an unknown position or unit, raises ValueError.
30
+ Use ``truncate`` for a typed result that also says what was removed.
31
+
32
+ >>> shorten("hello world", 8)
33
+ 'hello...'
34
+ >>> shorten("/var/log/app/service.log", 16, where="middle")
35
+ '/var/lo...ce.log'
36
+ >>> shorten("\\u5e7f\\u5e7f\\u5e7fx", 5, marker="..", width_aware=True)
37
+ '\u5e7f..'
38
+ """
39
+ if unit is None:
40
+ unit = "columns" if width_aware else "chars"
41
+ elif width_aware and unit != "columns":
42
+ raise ValueError(f"width_aware=True conflicts with unit={unit!r}")
43
+ result = truncate(
44
+ text, width, where=where, unit=unit, marker=marker, words=words, ambiguous=ambiguous
45
+ )
46
+ return result.text
ellipt/_ucd_data.py ADDED
@@ -0,0 +1,31 @@
1
+ """Index of the generated Unicode property data."""
2
+
3
+ # Generated by scripts/gen_ucd_tables.py from data/ucd/. Do not edit.
4
+
5
+ from __future__ import annotations
6
+
7
+ from ._ucd_part1 import RUNS as _R0
8
+
9
+ UNICODE_VERSION = "18.0.0"
10
+ GCB_NAMES = (
11
+ "Other",
12
+ "CR",
13
+ "LF",
14
+ "Control",
15
+ "Extend",
16
+ "ZWJ",
17
+ "Regional_Indicator",
18
+ "Prepend",
19
+ "SpacingMark",
20
+ "L",
21
+ "V",
22
+ "T",
23
+ "LV",
24
+ "LVT",
25
+ )
26
+ EXT_PICT_BIT = 16
27
+ INCB_SHIFT = 5
28
+ EMOJI_BIT = 128
29
+ MODIFIER_BIT = 256
30
+ WIDTH_SHIFT = 9
31
+ RUNS = _R0
ellipt/_ucd_part1.py ADDED
@@ -0,0 +1,166 @@
1
+ """Run-length property data, part 1."""
2
+
3
+ # Generated by scripts/gen_ucd_tables.py from data/ucd/. Do not edit.
4
+
5
+ RUNS = (
6
+ "003a 0021 0032 0011 003i 0e83 0hs1 0e86 0hs1 0e85 0hsa 0e81x 003x 0e81 16o1 0e82 16o1 "
7
+ "0e82 16o2 0i81 16o1 0e82 16r1 1ao1 0e81 16o5 0e81 16o5 0e81 16o4 0e86 16o1 0e89 16o1 "
8
+ "0e86 16o2 0e85 16o4 0e84 16o1 0e81 16o3 0e81 16o2 0e82 16o1 0e81 16o2 0e83 16o4 0e81 "
9
+ "16o1 0e81 16o1 0e82 16o1 0e8f 16o1 0e81 16o1 0e87 16o1 0e8a 16o2 0e83 16o1 0e85 16o3 "
10
+ "0e84 16o1 0e86 16o4 0e81 16o1 0e83 16o4 0e81 16o1 0e84 16o2 0e8i 16o2 0e83 16o1 0e82q "
11
+ "16o1 0e81 16o1 0e81 16o1 0e81 16o1 0e81 16o1 0e81 16o1 0e81 16o1 0e81 16o1 0e838 16o1 "
12
+ "0e8f 16o1 0e82q 16o1 0e82 16o1 0e81 16o3 0e81 16o1 0e82 16o1 0e87 16o4 0e81 16o1 0e81 "
13
+ "16o1 0e8w 02s34 0e8x 16oh 0e81 16o7 0e87 16oh 0e81 16o7 0e81j 16o1 0e8e 16o1s 0e81 16o1 "
14
+ "0e81d 02s7 0e87b 02s19 0e81 02s1 0e81 02s2 0e81 02s2 0e81 02s3 0e81i 0076 0e8a 02sb "
15
+ "0e81 0031 0e81a 02sl 0e8g 02s1 0e82t 02s7 0071 0e81 02s6 0e82 02s2 0e81 02s4 0e8x 0071 "
16
+ "0e81 02s1 0e8u 02sr 0e82j 02sb 0e81m 02s9 0e89 02s1 0e8o 02s4 0e81 02s9 0e81 02s3 0e81 "
17
+ "02s5 0e817 02s3 0e81g 0072 0e85 02s9 0e816 02so 0071 02sw 0eg1 0e8h 0g011 02s1 0eg1 "
18
+ "02s1 0e81 0eg3 02s8 0eg4 0101 0eg2 0e81 02s7 0g08 0e82 02s2 0e8k 0g08 0e81 02s1 0eg2 "
19
+ "0e8h 0g0k 0e81 0g07 0e81 0g01 0e83 0g04 0e82 02s1 0e81 0h01 0eg2 02s4 0e82 0eg2 0e82 "
20
+ "0eg2 0101 0e89 0h01 0e84 0g02 0e81 0g01 0e82 02s2 0e8c 0g02 0e8c 02s1 0e82 02s2 0eg1 "
21
+ "0e81k 02s1 0e81 0eg3 02s2 0e84 02s2 0e82 02s3 0e83 02s1 0e8u 02s2 0e83 02s1 0e8b 02s2 "
22
+ "0eg1 0e8h 0g0k 0e81 0g07 0e81 0g02 0e81 0g05 0e82 02s1 0e81 0eg3 02s5 0e81 02s2 0eg1 "
23
+ "0e81 0eg2 0101 0e8k 02s2 0e8l 0g01 02s6 0e81 02s1 0eg2 0e8h 0g0k 0e81 0g07 0e81 0g02 "
24
+ "0e81 0g05 0e82 02s1 0e81 0h01 02s1 0eg1 02s4 0e82 0eg2 0e82 0eg2 0101 0e85 02s4 0h01 "
25
+ "0e84 0g02 0e81 0g01 0e82 02s2 0e8d 0g01 0e8g 02s1 0e81n 0h01 0eg1 02s1 0eg2 0e83 0eg3 "
26
+ "0e81 0eg3 02s1 0e89 0h01 0e814 02s1 0eg3 02s1 0e8g 0g0k 0e81 0g0g 0e82 02s1 0e81 02s3 "
27
+ "0eg4 0e81 02s3 0e81 02s3 0101 0e87 02s2 0e81 0g03 0e87 02s2 0e8t 02s1 0eg2 0e81k 02s1 "
28
+ "0e81 0eg1 02s1 0h01 0eg1 0h01 0eg2 0e81 02s1 0h02 0e81 0h02 02s2 0e87 0h02 0e8b 02s2 "
29
+ "0e8f 0eg1 0e8c 02s2 0eg2 0e8h 0g012 02s2 0e81 0h01 0eg2 02s4 0e81 0eg3 0e81 0eg3 0101 "
30
+ "0ef1 0e88 0h01 0e8a 02s2 0e8t 02s1 0eg2 0e81y 02s1 0e84 0h01 0eg2 02s3 0e81 02s1 0e81 "
31
+ "0eg7 0h01 0e8i 0eg2 0e81p 02s1 0e81 0eg1 02s7 0e8c 02s8 0e82q 02s1 0e81 0eg1 02s9 0e8b "
32
+ "02s7 0e821 02s2 0e8r 02s1 0e81 02s1 0e81 02s1 0e84 0eg2 0e81d 02se 0eg1 02s5 0e81 02s2 "
33
+ "0e85 02sb 0e81 02s10 0e89 02s1 0e81l 0g017 0e82 02s4 0eg1 02s6 0e81 0101 02s1 0eg2 02s2 "
34
+ "0g01 0e8g 0g06 0eg2 02s2 0g04 02s3 0g01 0e83 0g02 0e87 0g03 02s4 0g0d 02s1 0e81 0eg1 "
35
+ "02s2 0e86 02s1 0g01 0e8e 02s1 0e82q 0sp2o 00a20 00b2g 0e89p 02s3 0e8qa 02s3 0h01 0e8s "
36
+ "02s2 0h01 0e8t 02s2 0e8u 02s2 0e8c 0g01g 02s2 0eg1 02s7 0eg8 02s1 0eg2 02s9 0101 02s1 "
37
+ "0e89 02s1 0e819 02s3 0031 02s1 0e839 02s2 0e8y 02s1 0e83a 02s3 0eg4 02s2 0eg3 0e84 0eg2 "
38
+ "02s1 0eg6 02s3 0e863 02s2 0eg2 02s1 0e84 0g01h 0eg1 02s1 0eg1 02s7 0e81 0101 0e81 02s1 "
39
+ "0e82 02s8 0eg6 02sa 0e82 02s1 0e81c 02s1t 0e8f 02s4 0eg1 0e86 0g02 0e86 0g0x 02s1 0h01 "
40
+ "02s5 0h01 02s1 0h01 0eg4 02s1 0h01 0f81 0g08 0e8u 02s9 0e8c 02s2 0eg1 0g0u 0eg1 02s4 "
41
+ "0eg2 02s2 0h01 0101 02s2 0g02 0e8b 0g03 0e814 02s1 0eg1 02s2 0eg3 02s1 0eg1 02s3 0h02 "
42
+ "0e81c 0eg8 02s8 0eg2 02s2 0e848 02s3 0e81 02sd 0eg1 02s7 0e84 02s1 0e86 02s1 0f42 0eg1 "
43
+ "02s2 0e85i 02s1s 0e8ej 0031 0041 02t1 0032 16o1 0e82 16o4 0e81 16o2 0e82 16o2 0e82 16o3 "
44
+ "0e81 16o4 0eb2 0035 0e81 16o1 0e81 16o2 0e81 16o1 0e85 16o1 0i81 0e81 16o1 0e8a 0i81 "
45
+ "0e8m 0035 0eb1 003a 0e84 16o1 0e8a 16o1 0e81 16o4 0e813 16o1 0e8z 02sx 0e8i 16o1 0e81 "
46
+ "16o1 0e83 16o1 0e89 16o1 0e82 16o1 0e8a 16o1 1ao1 0e83 16o1 0e84 16o1 0e8d 0i81 0e8p "
47
+ "16o2 0e86 16o4 0e81 16oc 0e84 16oa 0e8f 16o1 0e86 16o4 1ao6 0e8f 0i82 0e8d 16o2 0e8o "
48
+ "16o1 0e81 16o1 0e8i 16o1 0e8o 16o1 0e81 16o2 0e83 16o2 0e82 16o1 0e83 16o1 0e81 16o1 "
49
+ "0e83 16o1 0e84 16o1 0e82 16o4 0e82 16o1 0e81 16o1 0e81 16o6 0e81 16o1 0e85 16o4 0e84 "
50
+ "16o2 0e8a 16o1 0e83 16o1 0e85 16o1 0e8d 16o2 0e82 16o4 0e82 16o2 0e82 16o2 0e8i 16o2 "
51
+ "0e82 16o2 0e8d 16o1 0e83 16o1 0e8b 16o1 0e8p 16o1 0e82a 16o1 0e87 0wg2 0e8c 0i81 0sg2 "
52
+ "0e84k 0i81 0e8p 0wg4 0i83 0wg1 0i82 0wg1 0e84 0i83 0e82t 16o2q 1ao1 16o13 0e81 16o2p "
53
+ "0e84 16o10 0e8c 16og 0e82 16o4 0e8a 16o2 0e81 16o7 0i82 0e86 16o2 0e82 1ao1 16o1 0e84 "
54
+ "16o2 0e82 1ao1 16o1 0e84 16o3 0e82 16o1 0e82 16o4 0e8g 16o4 0e89 16o1 0e8b 0i82 0wg2 "
55
+ "0e81 0i85 16o2 0e82 16o1 0e84 1ao1 16o1 0e81 0i81 0e82 0wg2 0e82 0i81 0e83 16o1 0i81 "
56
+ "16o1 0e81 0i81 0e81 0i82 0e82 0i81 0e83 0i81 0e83 0i82 0sg8 0i83 0e85 1ao1 0e81 1ao1 "
57
+ "0e85 0wgc 0e8b 0i81 1ao1 16o1 0e81 1ao1 16o1 1ao1 0i81 16o1 1ao1 16o2 0e81 16o2 0e81 "
58
+ "16o1 0e8b 0i81 0e82 0i81 0wg1 0e8a 0sg6 0e82 0i81 0wg1 0i84 0e81 0i81 0e81 0i82 0e81 "
59
+ "16o2 0i81 0wg1 0e85 0i81 0e82 0wg2 0e84 0i82 0e8b 0wg2 16o1 0e84 0wg2 16o2 1ao1 16o5 "
60
+ "0wg1 1ao1 16o1 1ao1 16o1 1ao1 0wg1 16od 0e81 16o1 0e84 16o1 1ao1 0wg1 16o5 1ao2 0wg2 "
61
+ "1ao1 0wg1 16o1 1ao3 0wg1 16o2 0wg1 16o2 0e82 0i81 0e82 0wg1 0e82 0i82 0wg2 0i82 0e81 "
62
+ "0i81 0e82 0i81 0e81 0i81 0e81 0i81 0e86 0i81 0e83 0i81 0e86 0wg1 0e8a 0i82 0e88 16o1 "
63
+ "0e86 0i81 0e82 0i81 0e84 0wg1 0e81 0wg1 0e84 0wg3 0e81 0wg1 0e8b 0i82 0e8h 16oa 0e8l "
64
+ "0wg3 0e89 0i81 0e8e 0wg1 0e8e 0wg1 0e8ac 0i82 0e8cv 0i83 0e8j 0wg2 0e81f 0wg1 0e84 0wg1 "
65
+ "16o4 0e8b9 02s3 0e83x 02s1 0e82o 02sw 0e83k 0sgq 0e81 0sg2h 0e8c 0sg5y 0e8q 0sg1m 02s4 "
66
+ "0v82 0wg1 0sgc 0wg1 0sg1 0e82 0sg2e 0e82 02s2 0sg2t 0e85 0sg17 0e81 0sg2m 0e81 0sg2e "
67
+ "0e89 0sg1c 0e81 0sg14 16o8 0sg1z 0wg1 0sg1 0wg1 0sgmib 0e83 0sg1j 0e8bs 02s4 0e81 02sa "
68
+ "0e8w 02s2 0e828 02s2 0e87k 02s1 0e83 02s1 0e84 02s1 0e8n 0eg2 02s2 0eg1 0e84 02s1 0e82b "
69
+ "0eg2 0e81e 0egg 02s2 0e8q 02si 0e8d 02s1 0e812 02s8 0e8p 02sb 0eg1 0h01 0e8c 0spt 0e83 "
70
+ "02s3 0eg1 0e85 0g03 0e83 0g010 02s1 0eg2 02s4 0eg2 02s2 0eg2 0f81 0e8v 0g05 02s1 0e81 "
71
+ "0g09 0e8a 0g05 0e816 02s6 0eg2 02s2 0eg2 02s2 0e8c 02s1 0e88 02s1 0eg1 0e8i 0g0g 0e81 "
72
+ "0g03 0e86 0g01 0e81 02s1 0e81 0g02 0e81c 02s1 0e81 02s3 0e82 02s2 0e85 02s2 0e81 02s1 "
73
+ "0e8u 0g0b 0eg1 02s2 0eg2 0e85 0eg1 0101 0e85l 0g0r 0e88 0eg2 02s1 0eg2 02s1 0eg2 0e81 "
74
+ "0eg1 02s1 0e8i 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
75
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
76
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
77
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
78
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
79
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
80
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
81
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
82
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
83
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
84
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
85
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
86
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
87
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
88
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
89
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
90
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
91
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
92
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
93
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
94
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
95
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
96
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
97
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
98
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
99
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
100
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
101
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
102
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
103
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
104
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
105
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
106
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
107
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
108
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
109
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
110
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
111
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
112
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
113
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
114
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
115
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
116
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
117
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
118
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
119
+ "0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 "
120
+ "0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str 0ss1 0str "
121
+ "0ss1 0str 0e8c 00an 0004 00b1d 0004 0e81kw 16o4xs 0sge8 0e8u 02s1 0e8kh 02sg 0sga 0e86 "
122
+ "02sg 0sgz 0e81 0sgj 0e81 0sg4 0e843 0031 0e81 0sg2o 0e81p 0h02 0e81s 0sg7 0e89 0eb9 "
123
+ "0033 0e81 16o1 0e8e7 02s1 0e86a 02s1 0e845 02s5 0e81ad 0g01 02s3 0e81 02s2 0e85 02s4 "
124
+ "0g04 0e81 0g03 0e81 0g0t 0e82 02s3 0e84 0101 0e84l 02s2 0e8fx 02s4 0e81t 02s5 0e88t "
125
+ "02s2 0e8u 02s5 0e8w 02sg 0e81y 02sb 0e81d 02s4 0e83e 0eg1 02s1 0eg1 0e81h 02sf 0e815 "
126
+ "02s1 0e82 02s2 0e8a 02s3 0eg1 0e819 0eg3 02s4 0eg2 02s2 0e82 0071 0e84 02s1 0e8a 0071 "
127
+ "0e81e 02s3 0g010 02s5 0eg1 02s6 0101 02s1 0e8f 0g01 0eg2 0g01 0e817 02s1 0e8c 02s2 0eg1 "
128
+ "0e81c 0eg3 02s9 0eg1 0h01 0e81 0ef2 0e85 02s4 0e81 0eg1 02s1 0e82k 0eg3 02s3 0eg2 02s1 "
129
+ "0h01 02s2 0e86 02s1 0e82 02s1 0e84d 02s1 0eg3 02s8 0e8l 02s2 0eg2 0e81j 02s2 0e81 0h01 "
130
+ "0eg1 02s1 0eg4 0e82 0eg2 0e82 0eg2 0h01 0e89 0h01 0e8a 0eg2 0e82 02s7 0e83 02s5 0e8b "
131
+ "0g0a 0e81 0g01 0e82 0g01 0e81 0g012 0e82 0h01 0eg2 02s6 0e81 0h01 0e82 0h01 0e81 0h03 "
132
+ "0eg1 0e81 0eg2 02s1 0h01 0101 0ef1 02s1 0e8e 02s2 0e82a 0eg3 02s8 0eg2 02s3 0eg1 02s1 "
133
+ "0e8n 02s1 0e829 0h01 0eg2 02s6 0eg1 02s1 0eg2 0h01 0eg1 02s2 0eg1 02s2 0e86j 0h01 0eg2 "
134
+ "02s4 0e82 0eg4 02s2 0eg1 02s2 0e8r 02s2 0e82a 0eg3 02s8 0eg2 02s1 0eg1 02s2 0e82y 02s1 "
135
+ "0eg1 02s1 0eg2 02s6 0h01 02s1 0e82t 02s1 0eg1 02s1 0e82 02s4 0eg1 02s5 0e874 0eg3 02s9 "
136
+ "0eg1 02s2 0e85h 0g07 0e82 0g01 0e82 0g08 0e81 0g02 0e81 0g0o 0h01 0eg5 0e81 0eg2 0e82 "
137
+ "02s2 0h01 0101 0ef1 0eg1 0ef1 0eg1 02s1 0e83x 0eg3 02s4 0e82 02s2 0eg4 02s1 0e83 0eg1 "
138
+ "0e8r 0g01 02sa 0g014 02s6 0eg1 0f41 02s4 0e88 0101 0e88 0g01 02s6 0eg2 02s3 0g014 0ef6 "
139
+ "02sd 0eg1 02s1 0101 0e834 0g01 0e82d 02s1 0eg1 02s3 0eg1 02s1 0eg1 0e85j 0eg1 02s7 0e81 "
140
+ "02s6 0eg1 02s1 0e82a 02sm 0e81 0eg1 02s7 0eg1 02s2 0eg1 02s2 0e83e 02s6 0e83 02s1 0e81 "
141
+ "02s2 0e81 02s7 0ef1 02s1 0e81u 0eg5 0e81 02s2 0e81 0eg2 02s1 0eg1 02s1 0e82g 02s1 0g01 "
142
+ "0e875 02s2 0eg2 0e89 02s2 0ef1 0eg1 0g0d 0e81 0g0y 0eg2 02s5 0e83 0eg2 02s1 0h01 0101 "
143
+ "0e8n 02s1 0e8445 003g 02s1 0e86 02sf 0e88ug 02sc 0eg3 02s3 0e81xc 02s5 0e81n 02s7 0e8fg "
144
+ "0ei1 0e83 0ei4 0e8dg 02s1 0e81 0eg1j 0e87 02s4 0e825 0sg4 02s1 0e8b 0v82 0sg5 0e89 "
145
+ "0sg5p7 0e810 0sgy 0e82n 0sg37 0e8d 0sgpe 0e8e 0sg1f 0e85y5 0sg4 0e81 0sg7 0e81 0sg2 "
146
+ "0e81 0sg89 0e89 0sg1 0e8t 0sg3 0e82 0sg1 0e8e 0sg5 0e87 0sgb0 0e81wh 02s2 0e81 0034 "
147
+ "0e83mk 02s1a 0e82 02sn 0e8dc 02s2 0e81o 0h02 02s3 0e83 0h06 0038 02s8 0e82 02s7 0e8u "
148
+ "02s4 0e844 02s3 0e8b 0h03 0e88 02s2 0e82 0h01 0e8w 0h02 0e83i 0sg2f 0e89 0sgn 0e81ah "
149
+ "02s1j 0e84 02s1e 0e88 02s1 0e8e 02s1 0e8m 02s5 0e81 02sf 0e811s 02s7 0e81 02sh 0e82 "
150
+ "02s7 0e81 02s2 0e81 02s5 0e82s 02s1 0e84g 02s7 0e8af 02s1 0e81p 02s4 0e8e4 02s4 0e872 "
151
+ "02s2 0e86r 02s1 0e82 02s1 0e87 02s2 0e85 02s1 0e8d6 02s7 0e831 02s7 0e81bt 0wg1 0e813 "
152
+ "0eo4 0e82s 0eoc 0e8f 0eo2 0e8f 0eo1 0e8e 0wg1 0eo1 0e811 0eoa 16ob 0e85 16ou 0e82 16o1m "
153
+ "0e86 1ao2 16oc 1ao2 16oe 0wg1 16o2 0wga 16oi 0e81 0sg1 0eo1j 0hyq 0sg1 0wg2 0eod 0sga "
154
+ "0wg1 0sgk 0wg1 0sg2 0wg9 0sg1 0eo4 0sg9 0eo7 0wg2 0eoe 0sg6 0eo4a 0wgx 0i81 0e82 0i89 "
155
+ "0wg9 0i81 0wg1y 0i81 0wgm 0e82 0i82 0e81 0i83 0e82 0i82 0wg17 0i84 0wg5 0i8c 0wgh 0e82 "
156
+ "0i81 0wg1 0i81 0e81 0i81 0wg3 15w5 0wg1r 0i81 0wg1 0i81 0wg57 0i81 0e81 0wg1r 0e8b 0i82 "
157
+ "0wg4 0e81 0wgo 0e87 0i82 0e82 0i87 0wg1 0e8c 0i81 0e82 0i84 0e82 0i81 0e84 0wg2 0e8d "
158
+ "0wg1 0i81 0e82 0i81 0e88 0i82 0e89 0i81 0e85 0i83 0e8c 0i83 0e88 0i83 0e82 0i81 0e81 "
159
+ "0i81 0e84 0i81 0e86 0i81 0e83 0i81 0e86 0i81 0wg2d 0e81c 0wg1y 0e85 0i81 0wg1 0i83 0wg3 "
160
+ "0e82 0wg5 0eo2 0wg4 0i86 0e83 0i81 0e81 0wg2 0eo3 0i81 0e82 0i81 0wg9 0eo3 0e862 0sg1 "
161
+ "0e81 0eo4 0wgc 0eo4 0wg1 0e8r 0eo4 0e81k 0eo8 0e8a 0eo6 0e814 0eo8 0e8u 0eo2 0e8c 0eo4 "
162
+ "0e82 0eoe 0e89 0eo13 0e8c 0wg1b 0e81 0wga 0e81 0wg55 0e82g 0eo8 0e8e 0eo2 0wgd 0eo3 "
163
+ "0wg1z 0eo1 0wg1 0eo3 0wgi 0eo1 0wgd 0eo3 0wgc 0eo5 0e874 0eose 0e82 0sg1eke 0e82 "
164
+ "0sg1eke 0e8e1oi 0eb1 0031 0ebu 02s2o 0eb3k 02s6o 0eb2s0 0e81beo 16o1eke 0e82 16o1eke "
165
+ "0e82 "
166
+ )
ellipt/_width.py ADDED
@@ -0,0 +1,50 @@
1
+ """Display width (UAX #11 East Asian Width plus emoji presentation) of clusters and text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._props import AMBIGUOUS, EMOJI_BIT, EXT_PICT_BIT, GCB, MODIFIER_BIT, WIDTH_SHIFT, prop
6
+
7
+ _RI = GCB["Regional_Indicator"]
8
+ _VS15 = "\ufe0e"
9
+ _VS16 = "\ufe0f"
10
+ _ZWJ = "\u200d"
11
+ _TAGS = range(0xE0020, 0xE0080)
12
+
13
+
14
+ def check_ambiguous(ambiguous: int) -> int:
15
+ """Return ``ambiguous`` after checking it is 1 or 2."""
16
+ if isinstance(ambiguous, bool) or ambiguous not in (1, 2):
17
+ raise ValueError(f"ambiguous must be 1 or 2, not {ambiguous!r}")
18
+ return ambiguous
19
+
20
+
21
+ def _code_width(code: int, ambiguous: int) -> int:
22
+ width = code >> WIDTH_SHIFT
23
+ return ambiguous if width == AMBIGUOUS else width
24
+
25
+
26
+ def cluster_width(cluster: str, ambiguous: int = 1) -> int:
27
+ """Return the display width of one extended grapheme cluster.
28
+
29
+ Emoji sequences draw as one glyph: an emoji with VS16, a skin tone modifier, a ZWJ
30
+ sequence, a tag sequence or a regional indicator pair (a flag) is two cells and an
31
+ emoji with VS15 is one. Any other cluster is the sum of its code points.
32
+ """
33
+ code = prop(ord(cluster[0]))
34
+ if len(cluster) == 1:
35
+ return _code_width(code, ambiguous)
36
+ rest = cluster[1:]
37
+ if code & EMOJI_BIT and not code & MODIFIER_BIT:
38
+ if _VS15 in rest:
39
+ return 1
40
+ if _VS16 in rest:
41
+ return 2
42
+ if (
43
+ code & (EMOJI_BIT | EXT_PICT_BIT)
44
+ and not code & MODIFIER_BIT
45
+ and (_ZWJ in rest or any(prop(ord(ch)) & MODIFIER_BIT or ord(ch) in _TAGS for ch in rest))
46
+ ):
47
+ return 2
48
+ if code & 0x0F == _RI and prop(ord(rest[0])) & 0x0F == _RI:
49
+ return 2
50
+ return sum(_code_width(prop(ord(ch)), ambiguous) for ch in cluster)
ellipt/py.typed ADDED
File without changes
@@ -0,0 +1,134 @@
1
+ Metadata-Version: 2.5
2
+ Name: ellipt
3
+ Version: 0.2.1
4
+ Summary: Truncate strings with an ellipsis at the start, middle or end, by characters, grapheme clusters, display columns or UTF-8 bytes, without splitting emoji or accents
5
+ Project-URL: Homepage, https://lianes8studio.com
6
+ Project-URL: Repository, https://github.com/JaviLianes8/ellipt
7
+ Project-URL: Issues, https://github.com/JaviLianes8/ellipt/issues
8
+ Project-URL: Changelog, https://github.com/JaviLianes8/ellipt/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
+ License-File: LICENSE-UNICODE
15
+ Keywords: cli,display-width,ellipsis,grapheme,path,shorten,string,truncate,unicode
16
+ Classifier: Development Status :: 4 - Beta
17
+ Classifier: Environment :: Console
18
+ Classifier: Intended Audience :: Developers
19
+ Classifier: Operating System :: OS Independent
20
+ Classifier: Programming Language :: Python :: 3
21
+ Classifier: Programming Language :: Python :: 3 :: Only
22
+ Classifier: Topic :: Software Development :: Libraries
23
+ Classifier: Topic :: Text Processing
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.10
26
+ Provides-Extra: dev
27
+ Requires-Dist: build; extra == 'dev'
28
+ Requires-Dist: mypy; extra == 'dev'
29
+ Requires-Dist: pytest; extra == 'dev'
30
+ Requires-Dist: ruff; extra == 'dev'
31
+ Requires-Dist: twine; extra == 'dev'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # ellipt
35
+
36
+ Truncate strings with an ellipsis at the start, middle or end. The limit can count code
37
+ points, grapheme clusters, terminal columns or UTF-8 bytes, the marker is counted inside
38
+ the limit, and the cut never splits an accent, a flag or an emoji sequence. Typed, zero
39
+ dependencies, Unicode 18.0.0 tables embedded.
40
+
41
+ ## Install
42
+
43
+ ```
44
+ pip install ellipt
45
+ ```
46
+
47
+ ## Usage
48
+
49
+ ```python
50
+ from ellipt import ELLIPSIS, measure, shorten, shorten_path, truncate
51
+
52
+ shorten("hello world", 8) # -> 'hello...'
53
+ shorten("hello world", 8, where="start") # -> '...world'
54
+ shorten("hello world", 9, where="middle") # -> 'hel...rld'
55
+ shorten("hello world", 6, marker=ELLIPSIS) # -> 'hello…'
56
+
57
+ # Text that already fits is returned unchanged.
58
+ shorten("short", 20) # -> 'short'
59
+ ```
60
+
61
+ ### Pick the unit of the limit
62
+
63
+ ```python
64
+ family = "\U0001f468\u200d\U0001f469\u200d\U0001f467" # one emoji, five code points
65
+ text = "ab" + family + "cd"
66
+
67
+ measure(text, "chars") # -> 9
68
+ measure(text, "graphemes") # -> 5
69
+ measure(text, "columns") # -> 6
70
+ measure(text, "bytes") # -> 22
71
+
72
+ # The family is kept whole or dropped whole, never cut into pieces.
73
+ shorten(text, 6, marker=ELLIPSIS) # -> 'ab…'
74
+ shorten(text, 4, unit="graphemes", marker=ELLIPSIS) # -> 'ab👨\u200d👩\u200d👧…'
75
+ shorten("你好世界", 5, unit="columns", marker=ELLIPSIS) # -> '你好…'
76
+ shorten("café crème", 8, unit="bytes", marker="") # -> 'café cr'
77
+ ```
78
+
79
+ `width_aware=True` from 0.1.0 still works and means `unit="columns"`.
80
+
81
+ ### Know what was removed
82
+
83
+ ```python
84
+ r = truncate("The quick brown fox jumps", 14, words=True, marker=ELLIPSIS)
85
+ r.text # -> 'The quick…'
86
+ r.removed # -> ' brown fox jumps'
87
+ r.truncated # -> True
88
+ r.size <= r.limit # -> True
89
+ ```
90
+
91
+ `words=True` backs the cut off to whitespace; with one long word it cuts hard instead.
92
+
93
+ ### Shorten paths the way file browsers do
94
+
95
+ ```python
96
+ shorten_path("/home/me/projects/app/src/main.py", 20) # -> '/.../app/src/main.py'
97
+ shorten_path("/home/me/projects/app/src/main.py", 11) # -> '.../main.py'
98
+ shorten_path("photos/an_extremely_long_holiday_name.jpeg", 20) # -> 'an_ext...y_name.jpeg'
99
+ ```
100
+
101
+ ## Command line
102
+
103
+ ```
104
+ $ printf 'hello world\n' | ellipt 8
105
+ hello...
106
+ $ git ls-files | ellipt 28 --path
107
+ $ ellipt 30 --where middle --ellipsis notes.txt
108
+ $ ellipt 255 --unit bytes --marker "" < titles.txt
109
+ ```
110
+
111
+ The command reads standard input (or the files given) as UTF-8, keeps each LF or CRLF
112
+ line ending, and counts terminal columns by default, expanding tabs to 8-column stops
113
+ first (`--tabsize`). `--ellipsis` uses U+2026 as the marker without having to type it.
114
+ `ellipt --help` lists every option. Errors go to standard error with exit status 1, and
115
+ a reader that stops early (`| head`) is not an error.
116
+
117
+ ## Why
118
+
119
+ `textwrap.shorten` only cuts at the end, collapses whitespace and always cuts on words.
120
+ Plain slicing (`s[:n] + "..."`) splits emoji and accents, overflows a terminal with wide
121
+ characters and can exceed a byte limit. `ellipt` cuts where you ask, in the unit you
122
+ need, and guarantees the result is never longer than the limit. See [GUIDE.md](GUIDE.md)
123
+ for the exact rules, the Unicode sources and the edge cases.
124
+
125
+ ## License
126
+
127
+ MIT. See [LICENSE](LICENSE). The embedded Unicode data is covered by the Unicode
128
+ License v3, see [LICENSE-UNICODE](LICENSE-UNICODE).
129
+
130
+ ## Links
131
+
132
+ - Website: https://lianes8studio.com
133
+ - GitHub: https://github.com/JaviLianes8
134
+ - LinkedIn: https://www.linkedin.com/in/jlianes/
@@ -0,0 +1,20 @@
1
+ ellipt/__init__.py,sha256=n7BOVntpg0yfH2JisRQjrUoQHe6XUb4OYXGwJCmXtkI,735
2
+ ellipt/__main__.py,sha256=K2Z-jKrpRnWHwy828VLCc_qzA1AG_3dSyXHNsGkHiWg,124
3
+ ellipt/_cli.py,sha256=ONO44Q0TYjOk2HV9uRi4Bv8kjNLyU71NDy4sJyqXVyc,6775
4
+ ellipt/_core.py,sha256=eW16PucnoQAtWo9xoLuHJaVMs9Vf4vaEYpEuYV7oqJg,5284
5
+ ellipt/_cut.py,sha256=WApqSK9HhiIIJ9bcr8_jyJDPNJuuj58AG_RKoGYdSEY,3362
6
+ ellipt/_graphemes.py,sha256=ZQgxcpf3iOGwopjEflpX8SzZyECokjRvT-di45XzLWA,3409
7
+ ellipt/_measure.py,sha256=hO376PMBXY1ae3FwmRyKCEgskoyVY69QvbxUhqpFYZo,2480
8
+ ellipt/_path.py,sha256=oMH-Y8CgCGv8DJAHCZ6EDbsQMnqzfQaAMQDX3ceFeWQ,4126
9
+ ellipt/_props.py,sha256=NrdqRp33hAv7Escf7APSSoWUb0ZEkWyRyNBAu3PkCEY,1604
10
+ ellipt/_shorten.py,sha256=dQ-jOfbBnPVTvgPXNI3H4TC3dczDEtZo_soAa27jEis,1663
11
+ ellipt/_ucd_data.py,sha256=dSWD1BP9fsV9zYR-fehbMt4WBzUQWTGLmEDLZGFBNi0,516
12
+ ellipt/_ucd_part1.py,sha256=CpJg26U_HgS3_dMtQrBeRzZrgDNd58WZ8cvhE3BziYg,14858
13
+ ellipt/_width.py,sha256=PPqlKtbm-TKD67WSnGXQMj_ydzv7-EmG2r7xCHtrros,1764
14
+ ellipt/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ ellipt-0.2.1.dist-info/METADATA,sha256=JHfIrRAIPVcd-9U9yoOmaF7St_sfeds8OhvIrGmi5Hs,4899
16
+ ellipt-0.2.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
17
+ ellipt-0.2.1.dist-info/entry_points.txt,sha256=10m7FYfoLaGt9SPlgisutoFwLOPLnK_BnsfSZW6QPm4,44
18
+ ellipt-0.2.1.dist-info/licenses/LICENSE,sha256=_WmABlQG__oqlx4Rzp-qDJ10xND5KCLScftbhXbLC_c,1070
19
+ ellipt-0.2.1.dist-info/licenses/LICENSE-UNICODE,sha256=56k7AJVlz85VkZo4FDesTbiD6dohJvoouR0ScyvFPZY,1995
20
+ ellipt-0.2.1.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
+ ellipt = ellipt._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.
@@ -0,0 +1,39 @@
1
+ UNICODE LICENSE V3
2
+
3
+ COPYRIGHT AND PERMISSION NOTICE
4
+
5
+ Copyright © 1991-2026 Unicode, Inc.
6
+
7
+ NOTICE TO USER: Carefully read the following legal agreement. BY
8
+ DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR
9
+ SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE
10
+ TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT
11
+ DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a
14
+ copy of data files and any associated documentation (the "Data Files") or
15
+ software and any associated documentation (the "Software") to deal in the
16
+ Data Files or Software without restriction, including without limitation
17
+ the rights to use, copy, modify, merge, publish, distribute, and/or sell
18
+ copies of the Data Files or Software, and to permit persons to whom the
19
+ Data Files or Software are furnished to do so, provided that either (a)
20
+ this copyright and permission notice appear with all copies of the Data
21
+ Files or Software, or (b) this copyright and permission notice appear in
22
+ associated Documentation.
23
+
24
+ THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
25
+ KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
26
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
27
+ THIRD PARTY RIGHTS.
28
+
29
+ IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
30
+ BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,
31
+ OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
32
+ WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
33
+ ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA
34
+ FILES OR SOFTWARE.
35
+
36
+ Except as contained in this notice, the name of a copyright holder shall
37
+ not be used in advertising or otherwise to promote the sale, use or other
38
+ dealings in these Data Files or Software without prior written
39
+ authorization of the copyright holder.