bytesz 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.
- bytesz/__init__.py +44 -0
- bytesz/__main__.py +10 -0
- bytesz/_bits.py +81 -0
- bytesz/_cli.py +163 -0
- bytesz/_cli_support.py +74 -0
- bytesz/_convert.py +50 -0
- bytesz/_errors.py +42 -0
- bytesz/_format.py +159 -0
- bytesz/_hints.py +49 -0
- bytesz/_parse.py +182 -0
- bytesz/_types.py +70 -0
- bytesz/_units.py +71 -0
- bytesz/py.typed +0 -0
- bytesz-0.2.0.dist-info/METADATA +147 -0
- bytesz-0.2.0.dist-info/RECORD +18 -0
- bytesz-0.2.0.dist-info/WHEEL +4 -0
- bytesz-0.2.0.dist-info/entry_points.txt +2 -0
- bytesz-0.2.0.dist-info/licenses/LICENSE +21 -0
bytesz/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Strict two-way conversion between byte counts and human sizes with explicit IEC and SI units.
|
|
2
|
+
|
|
3
|
+
``parse`` turns "1.5 MiB" into 1572864 and ``format`` turns 1572864 back into
|
|
4
|
+
"1.5 MiB". The unit alone decides the system: IEC units (KiB, MiB) are powers of
|
|
5
|
+
1024 and SI units (kB, MB) are powers of 1000. Ambiguous spellings such as "KB"
|
|
6
|
+
are rejected unless the caller opts into the legacy reading.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from ._bits import format_bits, parse_bits
|
|
12
|
+
from ._convert import convert
|
|
13
|
+
from ._errors import (
|
|
14
|
+
AmbiguousUnitError,
|
|
15
|
+
ByteszError,
|
|
16
|
+
FractionalBytesError,
|
|
17
|
+
InvalidSizeError,
|
|
18
|
+
SystemMismatchError,
|
|
19
|
+
UnknownUnitError,
|
|
20
|
+
)
|
|
21
|
+
from ._format import format
|
|
22
|
+
from ._parse import parse
|
|
23
|
+
from ._types import Rounding, System
|
|
24
|
+
from ._units import IEC_SUFFIXES, SI_SUFFIXES
|
|
25
|
+
|
|
26
|
+
__version__ = "0.2.0"
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"IEC_SUFFIXES",
|
|
30
|
+
"SI_SUFFIXES",
|
|
31
|
+
"AmbiguousUnitError",
|
|
32
|
+
"ByteszError",
|
|
33
|
+
"FractionalBytesError",
|
|
34
|
+
"InvalidSizeError",
|
|
35
|
+
"Rounding",
|
|
36
|
+
"System",
|
|
37
|
+
"SystemMismatchError",
|
|
38
|
+
"UnknownUnitError",
|
|
39
|
+
"convert",
|
|
40
|
+
"format",
|
|
41
|
+
"format_bits",
|
|
42
|
+
"parse",
|
|
43
|
+
"parse_bits",
|
|
44
|
+
]
|
bytesz/__main__.py
ADDED
bytesz/_bits.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Bit counts: the same two systems with the "bit" unit symbol."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._format import _check_count, _render
|
|
6
|
+
from ._hints import BYTE_SYMBOLS, bit_hint
|
|
7
|
+
from ._parse import _parse_with
|
|
8
|
+
from ._types import Rounding, System
|
|
9
|
+
from ._units import IEC_BIT_UNITS, SI_BIT_UNITS, UPPER_BIT_UNITS
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def format_bits(
|
|
13
|
+
count: int,
|
|
14
|
+
*,
|
|
15
|
+
system: System | None = None,
|
|
16
|
+
precision: int = 2,
|
|
17
|
+
rounding: Rounding = "half-up",
|
|
18
|
+
unit: str | None = None,
|
|
19
|
+
signed: bool = False,
|
|
20
|
+
trailing_zeros: bool = False,
|
|
21
|
+
decimal_point: str = ".",
|
|
22
|
+
separator: str = " ",
|
|
23
|
+
) -> str:
|
|
24
|
+
"""Convert a bit count into a human size such as "1.5 Mibit" or "1.5 Mbit".
|
|
25
|
+
|
|
26
|
+
Takes the same options as :func:`bytesz.format`. Units are written with the
|
|
27
|
+
unambiguous "bit" symbol (bit, Kibit, Mibit ... or bit, kbit, Mbit ...).
|
|
28
|
+
|
|
29
|
+
>>> format_bits(1_500_000, system="si")
|
|
30
|
+
'1.5 Mbit'
|
|
31
|
+
>>> format_bits(1024)
|
|
32
|
+
'1 Kibit'
|
|
33
|
+
"""
|
|
34
|
+
return _render(
|
|
35
|
+
_check_count(count, signed),
|
|
36
|
+
bits=True,
|
|
37
|
+
system=system,
|
|
38
|
+
precision=precision,
|
|
39
|
+
rounding=rounding,
|
|
40
|
+
unit=unit,
|
|
41
|
+
trailing_zeros=trailing_zeros,
|
|
42
|
+
decimal_point=decimal_point,
|
|
43
|
+
separator=separator,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parse_bits(
|
|
48
|
+
text: str,
|
|
49
|
+
*,
|
|
50
|
+
system: System | None = None,
|
|
51
|
+
rounding: Rounding | None = None,
|
|
52
|
+
signed: bool = False,
|
|
53
|
+
decimal_point: str = ".",
|
|
54
|
+
) -> int:
|
|
55
|
+
"""Convert a human bit size such as "100 Mbit" or "1.5 Mib" into a bit count.
|
|
56
|
+
|
|
57
|
+
Accepts the "bit" symbol (kbit, Mibit) and the short "b" symbol (kb, Mb, Kib).
|
|
58
|
+
A bare number, "b" or "bit" is a count of bits. Upper case "Kb" is rejected as
|
|
59
|
+
ambiguous, just like "KB" in :func:`bytesz.parse`. The other options behave
|
|
60
|
+
exactly as in :func:`bytesz.parse`.
|
|
61
|
+
|
|
62
|
+
>>> parse_bits("100 Mbit")
|
|
63
|
+
100000000
|
|
64
|
+
>>> parse_bits("1 Kib")
|
|
65
|
+
1024
|
|
66
|
+
"""
|
|
67
|
+
return _parse_with(
|
|
68
|
+
text,
|
|
69
|
+
iec=IEC_BIT_UNITS,
|
|
70
|
+
si=SI_BIT_UNITS,
|
|
71
|
+
extra={},
|
|
72
|
+
si_pinned={},
|
|
73
|
+
upper=UPPER_BIT_UNITS,
|
|
74
|
+
foreign=BYTE_SYMBOLS,
|
|
75
|
+
hint=bit_hint,
|
|
76
|
+
what="bits",
|
|
77
|
+
system=system,
|
|
78
|
+
rounding=rounding,
|
|
79
|
+
signed=signed,
|
|
80
|
+
decimal_point=decimal_point,
|
|
81
|
+
)
|
bytesz/_cli.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Command line interface: ``bytesz format`` and ``bytesz parse``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Callable, Sequence
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from ._bits import format_bits, parse_bits
|
|
12
|
+
from ._cli_support import INTEGER, check_usage, cli_words, inputs, stop_on_closed_pipe
|
|
13
|
+
from ._errors import ByteszError
|
|
14
|
+
from ._format import format as format_size
|
|
15
|
+
from ._parse import parse
|
|
16
|
+
from ._types import ROUNDING_MODES
|
|
17
|
+
|
|
18
|
+
_EPILOG = """\
|
|
19
|
+
examples:
|
|
20
|
+
bytesz format 1572864 # 1.5 MiB
|
|
21
|
+
bytesz format --system si 1234567 # 1.23 MB
|
|
22
|
+
bytesz format --unit MiB 1000000 # 0.95 MiB
|
|
23
|
+
bytesz parse "1.5 MiB" 2GB # 1572864 and 2000000000
|
|
24
|
+
bytesz parse --legacy 4KB # 4096
|
|
25
|
+
bytesz parse --bits 100Mbit # 100000000
|
|
26
|
+
du -b * | bytesz format --field 1 # rewrite the first column, like numfmt
|
|
27
|
+
|
|
28
|
+
Values come from the arguments, or one per line from stdin when there are none.
|
|
29
|
+
With --field N only the Nth whitespace separated field of each line is converted
|
|
30
|
+
and the rest of the line is kept as it is. Errors go to stderr with exit status 1.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _common(sub: argparse.ArgumentParser) -> None:
|
|
35
|
+
sub.add_argument("values", nargs="*", help="values to convert (default: read stdin)")
|
|
36
|
+
sub.add_argument("--rounding", choices=sorted(ROUNDING_MODES), help="rounding mode")
|
|
37
|
+
sub.add_argument("--signed", action="store_true", help="allow negative values")
|
|
38
|
+
sub.add_argument(
|
|
39
|
+
"--decimal-point",
|
|
40
|
+
default=".",
|
|
41
|
+
choices=[".", ","],
|
|
42
|
+
metavar="{.|,}",
|
|
43
|
+
help="decimal separator: . (default) or ,",
|
|
44
|
+
)
|
|
45
|
+
sub.add_argument("--bits", action="store_true", help="count bits instead of bytes")
|
|
46
|
+
sub.add_argument("--field", type=int, metavar="N", help="convert only field N (1 based)")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
50
|
+
"""Return the argument parser for the ``bytesz`` command."""
|
|
51
|
+
parser = argparse.ArgumentParser(
|
|
52
|
+
prog="bytesz",
|
|
53
|
+
description="Convert byte counts to and from human sizes (IEC KiB or SI kB).",
|
|
54
|
+
epilog=_EPILOG,
|
|
55
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument("--version", action="version", version=f"bytesz {__version__}")
|
|
58
|
+
subs = parser.add_subparsers(dest="command", required=True, metavar="{format,parse}")
|
|
59
|
+
fmt = subs.add_parser(
|
|
60
|
+
"format",
|
|
61
|
+
help="byte count to human size",
|
|
62
|
+
epilog=_EPILOG,
|
|
63
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
64
|
+
)
|
|
65
|
+
fmt.add_argument("--system", choices=["iec", "si"], help="default: from --unit, else iec")
|
|
66
|
+
fmt.add_argument("--precision", type=int, default=2, help="decimals (default: 2)")
|
|
67
|
+
fmt.add_argument("--unit", help="force one unit, such as MiB")
|
|
68
|
+
fmt.add_argument("--trailing-zeros", action="store_true", help="keep zeros, as in 1.50")
|
|
69
|
+
fmt.add_argument("--separator", default=" ", help="text between number and unit")
|
|
70
|
+
_common(fmt)
|
|
71
|
+
par = subs.add_parser(
|
|
72
|
+
"parse",
|
|
73
|
+
help="human size to byte count",
|
|
74
|
+
epilog=_EPILOG,
|
|
75
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
76
|
+
)
|
|
77
|
+
par.add_argument("--system", choices=["iec", "si"], help="accept only this system")
|
|
78
|
+
par.add_argument("--legacy", action="store_true", help="read KB, MB, GB as powers of 1024")
|
|
79
|
+
_common(par)
|
|
80
|
+
return parser
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _converter(args: argparse.Namespace) -> Callable[[str], str]:
|
|
84
|
+
if args.command == "format":
|
|
85
|
+
render = format_bits if args.bits else format_size
|
|
86
|
+
|
|
87
|
+
def convert_format(token: str) -> str:
|
|
88
|
+
token = token.strip()
|
|
89
|
+
if not INTEGER.fullmatch(token):
|
|
90
|
+
raise ByteszError(f"not an integer count: {token!r}")
|
|
91
|
+
if token.startswith("+") and not args.signed:
|
|
92
|
+
raise ByteszError(f"{token!r} has a sign; pass --signed to allow it")
|
|
93
|
+
return render(
|
|
94
|
+
int(token),
|
|
95
|
+
system=args.system,
|
|
96
|
+
precision=args.precision,
|
|
97
|
+
rounding=args.rounding or "half-up",
|
|
98
|
+
unit=args.unit,
|
|
99
|
+
signed=args.signed,
|
|
100
|
+
trailing_zeros=args.trailing_zeros,
|
|
101
|
+
decimal_point=args.decimal_point,
|
|
102
|
+
separator=args.separator,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return convert_format
|
|
106
|
+
|
|
107
|
+
def convert_parse(token: str) -> str:
|
|
108
|
+
if args.bits:
|
|
109
|
+
value = parse_bits(
|
|
110
|
+
token,
|
|
111
|
+
system=args.system,
|
|
112
|
+
rounding=args.rounding,
|
|
113
|
+
signed=args.signed,
|
|
114
|
+
decimal_point=args.decimal_point,
|
|
115
|
+
)
|
|
116
|
+
else:
|
|
117
|
+
value = parse(
|
|
118
|
+
token,
|
|
119
|
+
system=args.system,
|
|
120
|
+
rounding=args.rounding,
|
|
121
|
+
signed=args.signed,
|
|
122
|
+
decimal_point=args.decimal_point,
|
|
123
|
+
legacy=args.legacy,
|
|
124
|
+
)
|
|
125
|
+
return str(value)
|
|
126
|
+
|
|
127
|
+
return convert_parse
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _replace_field(line: str, field: int, convert: Callable[[str], str]) -> str:
|
|
131
|
+
if not line.strip():
|
|
132
|
+
return line
|
|
133
|
+
spans = [m.span() for m in re.finditer(r"\S+", line)]
|
|
134
|
+
if field > len(spans):
|
|
135
|
+
raise ByteszError(f"line has no field {field}: {line!r}")
|
|
136
|
+
start, end = spans[field - 1]
|
|
137
|
+
return line[:start] + convert(line[start:end]) + line[end:]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
141
|
+
"""Run the command line interface and return the exit status."""
|
|
142
|
+
parser = build_parser()
|
|
143
|
+
args = parser.parse_args(argv)
|
|
144
|
+
check_usage(parser, args)
|
|
145
|
+
convert = _converter(args)
|
|
146
|
+
try:
|
|
147
|
+
for item in inputs(args.values, sys.stdin):
|
|
148
|
+
field = args.field
|
|
149
|
+
if not item.strip():
|
|
150
|
+
out = item # blank lines pass through
|
|
151
|
+
elif field is None:
|
|
152
|
+
out = convert(item)
|
|
153
|
+
else:
|
|
154
|
+
out = _replace_field(item, field, convert)
|
|
155
|
+
sys.stdout.write(out + "\n")
|
|
156
|
+
sys.stdout.flush()
|
|
157
|
+
except (ByteszError, ValueError) as error:
|
|
158
|
+
sys.stdout.flush()
|
|
159
|
+
sys.stderr.write(f"bytesz: error: {cli_words(str(error))}\n")
|
|
160
|
+
return 1
|
|
161
|
+
except OSError as error:
|
|
162
|
+
return stop_on_closed_pipe(error)
|
|
163
|
+
return 0
|
bytesz/_cli_support.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Helpers for the command line: input lines, usage checks, wording, closed pipes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import errno
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Iterator, Sequence
|
|
11
|
+
from typing import Final, TextIO
|
|
12
|
+
|
|
13
|
+
from ._units import IEC_BIT_SUFFIXES, IEC_SUFFIXES, SI_BIT_SUFFIXES, SI_SUFFIXES
|
|
14
|
+
|
|
15
|
+
INTEGER: Final = re.compile(r"[+-]?[0-9]+")
|
|
16
|
+
|
|
17
|
+
_UNITS: Final[dict[tuple[bool, str | None], tuple[str, ...]]] = {
|
|
18
|
+
(False, "iec"): IEC_SUFFIXES,
|
|
19
|
+
(False, "si"): SI_SUFFIXES,
|
|
20
|
+
(False, None): IEC_SUFFIXES + SI_SUFFIXES[1:],
|
|
21
|
+
(True, "iec"): IEC_BIT_SUFFIXES,
|
|
22
|
+
(True, "si"): SI_BIT_SUFFIXES,
|
|
23
|
+
(True, None): IEC_BIT_SUFFIXES + SI_BIT_SUFFIXES[1:],
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# Library messages name keyword arguments; the command line names flags.
|
|
27
|
+
_CLI_WORDS: Final = (
|
|
28
|
+
("signed=True", "--signed"),
|
|
29
|
+
("legacy=True", "--legacy"),
|
|
30
|
+
("system='si'", "--system si"),
|
|
31
|
+
("system='iec'", "--system iec"),
|
|
32
|
+
("system is 'si'", "--system is si"),
|
|
33
|
+
("system is 'iec'", "--system is iec"),
|
|
34
|
+
("use parse_bits for bits", "add --bits for bits"),
|
|
35
|
+
("see parse_bits", "see --bits"),
|
|
36
|
+
("use parse for bytes", "drop --bits for bytes"),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def inputs(values: Sequence[str], stdin: TextIO) -> Iterator[str]:
|
|
41
|
+
"""Yield the argument values, or each stdin line without its line end or a BOM."""
|
|
42
|
+
if values:
|
|
43
|
+
yield from values
|
|
44
|
+
return
|
|
45
|
+
for number, line in enumerate(stdin):
|
|
46
|
+
yield (line.removeprefix("\ufeff") if number == 0 else line).rstrip("\r\n")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def cli_words(message: str) -> str:
|
|
50
|
+
"""Say option names the way the command line spells them."""
|
|
51
|
+
for api, flag in _CLI_WORDS:
|
|
52
|
+
message = message.replace(api, flag)
|
|
53
|
+
return message
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def check_usage(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None:
|
|
57
|
+
"""Reject bad option values as usage errors (exit status 2)."""
|
|
58
|
+
if args.field is not None and args.field < 1:
|
|
59
|
+
parser.error("--field must be 1 or more")
|
|
60
|
+
if args.command != "format":
|
|
61
|
+
return
|
|
62
|
+
if args.precision < 0:
|
|
63
|
+
parser.error("--precision must be 0 or more")
|
|
64
|
+
units = _UNITS[(args.bits, args.system)]
|
|
65
|
+
if args.unit is not None and args.unit not in units:
|
|
66
|
+
parser.error(f"--unit {args.unit!r} is not one of: {', '.join(units)}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def stop_on_closed_pipe(error: OSError) -> int:
|
|
70
|
+
"""Stop quietly when the reader went away (``| head``), as Python's docs advise."""
|
|
71
|
+
if not isinstance(error, BrokenPipeError) and error.errno not in (errno.EPIPE, errno.EINVAL):
|
|
72
|
+
raise error
|
|
73
|
+
os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
|
|
74
|
+
return 1
|
bytesz/_convert.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Exact conversion of a byte or bit count into one named unit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
|
|
7
|
+
from ._errors import InvalidSizeError, UnknownUnitError
|
|
8
|
+
from ._format import _exact
|
|
9
|
+
from ._units import (
|
|
10
|
+
IEC_BASE,
|
|
11
|
+
IEC_BIT_SUFFIXES,
|
|
12
|
+
IEC_SUFFIXES,
|
|
13
|
+
SI_BASE,
|
|
14
|
+
SI_BIT_SUFFIXES,
|
|
15
|
+
SI_SUFFIXES,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
_UNITS: dict[str, tuple[int, int]] = {
|
|
19
|
+
**{unit: (IEC_BASE, exp) for exp, unit in enumerate(IEC_SUFFIXES)},
|
|
20
|
+
**{unit: (SI_BASE, exp) for exp, unit in enumerate(SI_SUFFIXES)},
|
|
21
|
+
**{unit: (IEC_BASE, exp) for exp, unit in enumerate(IEC_BIT_SUFFIXES)},
|
|
22
|
+
**{unit: (SI_BASE, exp) for exp, unit in enumerate(SI_BIT_SUFFIXES)},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def convert(count: int, unit: str) -> Decimal:
|
|
27
|
+
"""Return ``count`` expressed in ``unit`` as an exact :class:`~decimal.Decimal`.
|
|
28
|
+
|
|
29
|
+
``unit`` is any IEC or SI byte unit ("KiB", "MB", "QiB") or bit unit ("Mibit",
|
|
30
|
+
"kbit"); for a bit unit, ``count`` is a number of bits. Nothing is rounded:
|
|
31
|
+
every power of 1000 or 1024 divides into a terminating decimal. Negative counts
|
|
32
|
+
are allowed because the result is a plain number, not a size label.
|
|
33
|
+
|
|
34
|
+
>>> convert(1536, "KiB")
|
|
35
|
+
Decimal('1.5')
|
|
36
|
+
>>> convert(1, "KiB")
|
|
37
|
+
Decimal('0.0009765625')
|
|
38
|
+
"""
|
|
39
|
+
if isinstance(count, bool) or not isinstance(count, int):
|
|
40
|
+
raise InvalidSizeError("count must be an int")
|
|
41
|
+
if unit not in _UNITS:
|
|
42
|
+
raise UnknownUnitError(f"unknown size unit {unit!r}", unit=unit)
|
|
43
|
+
base, exponent = _UNITS[unit]
|
|
44
|
+
if count == 0:
|
|
45
|
+
return Decimal(0)
|
|
46
|
+
# Plain notation: keep integer digits, drop only zeros after the point.
|
|
47
|
+
text = f"{_exact(count, base, exponent):f}"
|
|
48
|
+
if "." in text:
|
|
49
|
+
text = text.rstrip("0").rstrip(".")
|
|
50
|
+
return Decimal(text)
|
bytesz/_errors.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Exceptions raised by bytesz.
|
|
2
|
+
|
|
3
|
+
Every input error is a ``ValueError`` through :class:`ByteszError`, so callers
|
|
4
|
+
that only care about "bad input" can catch one class. Bad option values raise a
|
|
5
|
+
plain ``ValueError`` and a non-string input raises ``TypeError``. The narrower classes carry the
|
|
6
|
+
offending ``text`` and ``unit`` for messages and logs.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ByteszError(ValueError):
|
|
13
|
+
"""Base class for every error raised by bytesz.
|
|
14
|
+
|
|
15
|
+
``text`` is the input that failed, when there was one, and ``unit`` the unit
|
|
16
|
+
involved, when there was one. Both default to ``None``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, message: str, *, text: str | None = None, unit: str | None = None) -> None:
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.text = text
|
|
22
|
+
self.unit = unit
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class InvalidSizeError(ByteszError):
|
|
26
|
+
"""The input is malformed, negative, or not a whole number of bytes."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AmbiguousUnitError(ByteszError):
|
|
30
|
+
"""The unit does not name one system unambiguously, for example 'KB'."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class UnknownUnitError(InvalidSizeError):
|
|
34
|
+
"""The unit is not a size unit at all, for example 'furlongs'."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SystemMismatchError(InvalidSizeError):
|
|
38
|
+
"""The unit belongs to the other system than the one the caller pinned."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class FractionalBytesError(InvalidSizeError):
|
|
42
|
+
"""The value does not land on a whole byte (or bit) and no rounding was asked for."""
|
bytesz/_format.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Render an exact integer number of bytes (or bits) as a human readable size."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from decimal import Decimal, localcontext
|
|
6
|
+
|
|
7
|
+
from ._errors import InvalidSizeError, SystemMismatchError, UnknownUnitError
|
|
8
|
+
from ._types import (
|
|
9
|
+
Rounding,
|
|
10
|
+
System,
|
|
11
|
+
check_decimal_point,
|
|
12
|
+
check_system,
|
|
13
|
+
digit_count,
|
|
14
|
+
exact_context,
|
|
15
|
+
rounding_mode,
|
|
16
|
+
)
|
|
17
|
+
from ._units import (
|
|
18
|
+
IEC_BASE,
|
|
19
|
+
IEC_BIT_SUFFIXES,
|
|
20
|
+
IEC_SUFFIXES,
|
|
21
|
+
SI_BASE,
|
|
22
|
+
SI_BIT_SUFFIXES,
|
|
23
|
+
SI_SUFFIXES,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _table(system: str | None, bits: bool, unit: str | None) -> tuple[str, int, tuple[str, ...]]:
|
|
28
|
+
"""Pick the system (inferred from ``unit`` when not given), its base and suffixes."""
|
|
29
|
+
iec = IEC_BIT_SUFFIXES if bits else IEC_SUFFIXES
|
|
30
|
+
si = SI_BIT_SUFFIXES if bits else SI_SUFFIXES
|
|
31
|
+
if system is not None:
|
|
32
|
+
check_system(system)
|
|
33
|
+
if unit is not None:
|
|
34
|
+
if unit not in iec and unit not in si:
|
|
35
|
+
kind = "bit" if bits else "size"
|
|
36
|
+
raise UnknownUnitError(f"unknown {kind} unit {unit!r}", unit=unit)
|
|
37
|
+
owner = "iec" if unit in iec else "si"
|
|
38
|
+
if system is None or unit in (iec if system == "iec" else si):
|
|
39
|
+
system = system or owner
|
|
40
|
+
else:
|
|
41
|
+
name = "an IEC" if owner == "iec" else "an SI"
|
|
42
|
+
raise SystemMismatchError(
|
|
43
|
+
f"{unit!r} is {name} unit but system is {system!r}", unit=unit
|
|
44
|
+
)
|
|
45
|
+
if system == "si":
|
|
46
|
+
return "si", SI_BASE, si
|
|
47
|
+
return "iec", IEC_BASE, iec
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _exact(count: int, base: int, exponent: int) -> Decimal:
|
|
51
|
+
"""Return count / base**exponent exactly; every such quotient terminates."""
|
|
52
|
+
with localcontext(exact_context(digit_count(count) + 10 * exponent + 2)):
|
|
53
|
+
return Decimal(count) / (Decimal(base) ** exponent)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _check_count(count: object, signed: bool) -> int:
|
|
57
|
+
if isinstance(count, bool) or not isinstance(count, int):
|
|
58
|
+
raise InvalidSizeError("byte count must be an int")
|
|
59
|
+
if count < 0 and not signed:
|
|
60
|
+
raise InvalidSizeError("byte count must not be negative; pass signed=True to allow it")
|
|
61
|
+
return count
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _text(value: Decimal, trailing_zeros: bool, decimal_point: str) -> str:
|
|
65
|
+
"""Render a decimal without exponent notation, optionally trimming zeros."""
|
|
66
|
+
text = f"{value:f}"
|
|
67
|
+
if not trailing_zeros and "." in text:
|
|
68
|
+
text = text.rstrip("0").rstrip(".")
|
|
69
|
+
if text.lstrip("-").strip("0.") == "":
|
|
70
|
+
text = text.lstrip("-") # never print "-0"
|
|
71
|
+
return text.replace(".", decimal_point)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _render(
|
|
75
|
+
count: int,
|
|
76
|
+
*,
|
|
77
|
+
bits: bool,
|
|
78
|
+
system: str | None,
|
|
79
|
+
precision: int,
|
|
80
|
+
rounding: str,
|
|
81
|
+
unit: str | None,
|
|
82
|
+
trailing_zeros: bool,
|
|
83
|
+
decimal_point: str,
|
|
84
|
+
separator: str,
|
|
85
|
+
) -> str:
|
|
86
|
+
system, base, suffixes = _table(system, bits, unit)
|
|
87
|
+
if isinstance(precision, bool) or not isinstance(precision, int) or precision < 0:
|
|
88
|
+
raise ValueError("precision must be a non-negative int")
|
|
89
|
+
mode = rounding_mode(rounding)
|
|
90
|
+
check_decimal_point(decimal_point)
|
|
91
|
+
step = Decimal((0, (1,), -precision))
|
|
92
|
+
|
|
93
|
+
if unit is not None:
|
|
94
|
+
exponent = suffixes.index(unit)
|
|
95
|
+
else:
|
|
96
|
+
exponent = 0
|
|
97
|
+
magnitude = abs(count)
|
|
98
|
+
while exponent < len(suffixes) - 1 and base ** (exponent + 1) <= magnitude:
|
|
99
|
+
exponent += 1
|
|
100
|
+
|
|
101
|
+
while True:
|
|
102
|
+
scaled = _exact(count, base, exponent)
|
|
103
|
+
with localcontext(exact_context(digit_count(count) + precision + 2)):
|
|
104
|
+
rounded = scaled.quantize(step, rounding=mode)
|
|
105
|
+
reached = rounded.copy_abs() >= base
|
|
106
|
+
# Carry: 1023.999 KiB rounds to 1024.00 KiB, which is 1 MiB.
|
|
107
|
+
if unit is None and reached and exponent < len(suffixes) - 1:
|
|
108
|
+
exponent += 1
|
|
109
|
+
continue
|
|
110
|
+
break
|
|
111
|
+
return f"{_text(rounded, trailing_zeros, decimal_point)}{separator}{suffixes[exponent]}"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def format(
|
|
115
|
+
count: int,
|
|
116
|
+
*,
|
|
117
|
+
system: System | None = None,
|
|
118
|
+
precision: int = 2,
|
|
119
|
+
rounding: Rounding = "half-up",
|
|
120
|
+
unit: str | None = None,
|
|
121
|
+
signed: bool = False,
|
|
122
|
+
trailing_zeros: bool = False,
|
|
123
|
+
decimal_point: str = ".",
|
|
124
|
+
separator: str = " ",
|
|
125
|
+
) -> str:
|
|
126
|
+
"""Convert a byte count into a human size such as "1.5 KiB".
|
|
127
|
+
|
|
128
|
+
``system`` is "iec" (1024 based: KiB, MiB ... QiB) or "si" (1000 based: kB,
|
|
129
|
+
MB ... QB); when omitted it follows ``unit`` if one is given, else "iec".
|
|
130
|
+
The largest unit that keeps the number at or above one is chosen, the value
|
|
131
|
+
is rounded to ``precision`` decimals with ``rounding``, and if the rounded
|
|
132
|
+
value reaches the next unit it carries over (1048575 is "1 MiB", not
|
|
133
|
+
"1024 KiB"). Trailing zeros are dropped unless ``trailing_zeros`` is true.
|
|
134
|
+
|
|
135
|
+
``unit`` forces one unit, such as "MiB" or "kB", with no carry; a unit from
|
|
136
|
+
the other system than an explicit ``system`` raises SystemMismatchError.
|
|
137
|
+
``signed`` allows negative counts. ``decimal_point`` is "." or ",".
|
|
138
|
+
``separator`` goes between number and unit (" " by default, "" for "1.5KiB").
|
|
139
|
+
|
|
140
|
+
>>> format(1572864)
|
|
141
|
+
'1.5 MiB'
|
|
142
|
+
>>> format(1234567, system="si")
|
|
143
|
+
'1.23 MB'
|
|
144
|
+
>>> format(1048575)
|
|
145
|
+
'1 MiB'
|
|
146
|
+
>>> format(1536, unit="B")
|
|
147
|
+
'1536 B'
|
|
148
|
+
"""
|
|
149
|
+
return _render(
|
|
150
|
+
_check_count(count, signed),
|
|
151
|
+
bits=False,
|
|
152
|
+
system=system,
|
|
153
|
+
precision=precision,
|
|
154
|
+
rounding=rounding,
|
|
155
|
+
unit=unit,
|
|
156
|
+
trailing_zeros=trailing_zeros,
|
|
157
|
+
decimal_point=decimal_point,
|
|
158
|
+
separator=separator,
|
|
159
|
+
)
|
bytesz/_hints.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Error hints and cross-family checks for parse and parse_bits."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
from ._units import (
|
|
8
|
+
IEC_BARE,
|
|
9
|
+
IEC_BIT_SUFFIXES,
|
|
10
|
+
IEC_SUFFIXES,
|
|
11
|
+
LEGACY_UNITS,
|
|
12
|
+
SI_BARE_PINNED,
|
|
13
|
+
SI_BIT_SUFFIXES,
|
|
14
|
+
SI_SUFFIXES,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# Units of the other family: a bit unit given to parse, a byte unit to parse_bits.
|
|
18
|
+
BIT_SYMBOLS: Final = {s: "a bit unit; use parse_bits for bits" for s in IEC_BIT_SUFFIXES[1:]}
|
|
19
|
+
BIT_SYMBOLS.update({s: "a bit unit; use parse_bits for bits" for s in SI_BIT_SUFFIXES})
|
|
20
|
+
BYTE_SYMBOLS: Final = {
|
|
21
|
+
s: "a byte unit; use parse for bytes"
|
|
22
|
+
for s in (*IEC_SUFFIXES, *SI_SUFFIXES, *IEC_BARE, *(u for u in LEGACY_UNITS if u.endswith("B")))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def byte_hint(suffix: str, system: str | None, legacy: bool) -> str:
|
|
27
|
+
"""Explain an ambiguous byte unit, suggesting only options that would work."""
|
|
28
|
+
if suffix.endswith("b"):
|
|
29
|
+
return "a lower case 'b' usually means bits (see parse_bits); for bytes use MB or MiB"
|
|
30
|
+
can_legacy = suffix in LEGACY_UNITS and system != "si" and not legacy
|
|
31
|
+
if not suffix.endswith("B"):
|
|
32
|
+
options = []
|
|
33
|
+
if suffix in SI_BARE_PINNED and system is None and not legacy:
|
|
34
|
+
options.append("system='si' reads it as a power of 1000")
|
|
35
|
+
if can_legacy:
|
|
36
|
+
options.append("legacy=True reads it as a power of 1024")
|
|
37
|
+
options.append("write a full unit such as kB or KiB")
|
|
38
|
+
return "it is a bare prefix; " + (
|
|
39
|
+
", or ".join(options)
|
|
40
|
+
if len(options) < 3
|
|
41
|
+
else f"{options[0]}, {options[1]}, or {options[2]}"
|
|
42
|
+
)
|
|
43
|
+
text = "use kB/MB for 1000 or KiB/MiB for 1024"
|
|
44
|
+
return text + (", or pass legacy=True for the JEDEC reading" if can_legacy else "")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def bit_hint(suffix: str, system: str | None, legacy: bool) -> str:
|
|
48
|
+
"""Explain an ambiguous bit unit."""
|
|
49
|
+
return "use kbit/Mbit (or kb/Mb) for 1000, Kibit/Mibit (or Kib/Mib) for 1024"
|
bytesz/_parse.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Parse a human readable size into an exact integer number of bytes (or bits)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Callable, Mapping
|
|
7
|
+
from decimal import Decimal, localcontext
|
|
8
|
+
|
|
9
|
+
from ._errors import (
|
|
10
|
+
AmbiguousUnitError,
|
|
11
|
+
ByteszError,
|
|
12
|
+
FractionalBytesError,
|
|
13
|
+
InvalidSizeError,
|
|
14
|
+
SystemMismatchError,
|
|
15
|
+
UnknownUnitError,
|
|
16
|
+
)
|
|
17
|
+
from ._hints import BIT_SYMBOLS, byte_hint
|
|
18
|
+
from ._types import Rounding, System, check_decimal_point, digit_count, exact_context, rounding_mode
|
|
19
|
+
from ._units import (
|
|
20
|
+
IEC_BARE,
|
|
21
|
+
IEC_BASE,
|
|
22
|
+
IEC_UNITS,
|
|
23
|
+
LEGACY_UNITS,
|
|
24
|
+
SI_BARE_PINNED,
|
|
25
|
+
SI_BASE,
|
|
26
|
+
SI_UNITS,
|
|
27
|
+
UPPER_UNITS,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
_PATTERNS = {
|
|
31
|
+
point: re.compile(
|
|
32
|
+
rf"^\s*([+-]?)([0-9]+(?:{re.escape(point)}[0-9]+)?)"
|
|
33
|
+
r"\s*([A-Za-z]*)\s*$"
|
|
34
|
+
)
|
|
35
|
+
for point in (".", ",")
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _factor(
|
|
40
|
+
suffix: str,
|
|
41
|
+
*,
|
|
42
|
+
iec: Mapping[str, int],
|
|
43
|
+
si: Mapping[str, int],
|
|
44
|
+
extra: Mapping[str, int],
|
|
45
|
+
si_pinned: Mapping[str, int],
|
|
46
|
+
upper: set[str] | frozenset[str],
|
|
47
|
+
foreign: Mapping[str, str],
|
|
48
|
+
system: str | None,
|
|
49
|
+
hint: Callable[[str, str | None, bool], str],
|
|
50
|
+
) -> int:
|
|
51
|
+
"""Return the multiplier for a suffix, honouring an optional pinned system."""
|
|
52
|
+
if suffix == "" or iec.get(suffix) == 0 or si.get(suffix) == 0:
|
|
53
|
+
return 1
|
|
54
|
+
if suffix in extra:
|
|
55
|
+
return int(IEC_BASE ** extra[suffix])
|
|
56
|
+
if suffix in iec:
|
|
57
|
+
if system == "si":
|
|
58
|
+
raise SystemMismatchError(f"{suffix!r} is an IEC unit but system is 'si'", unit=suffix)
|
|
59
|
+
return int(IEC_BASE ** iec[suffix])
|
|
60
|
+
if system == "si" and suffix in si_pinned:
|
|
61
|
+
return int(SI_BASE ** si_pinned[suffix])
|
|
62
|
+
if suffix in si:
|
|
63
|
+
if system == "iec":
|
|
64
|
+
raise SystemMismatchError(f"{suffix!r} is an SI unit but system is 'iec'", unit=suffix)
|
|
65
|
+
return int(SI_BASE ** si[suffix])
|
|
66
|
+
if suffix in foreign:
|
|
67
|
+
raise UnknownUnitError(f"{suffix!r} is {foreign[suffix]}", unit=suffix)
|
|
68
|
+
if suffix.upper() in upper:
|
|
69
|
+
reason = hint(suffix, system, bool(extra))
|
|
70
|
+
raise AmbiguousUnitError(f"{suffix!r} is ambiguous; {reason}", unit=suffix)
|
|
71
|
+
raise UnknownUnitError(f"unknown size unit {suffix!r}", unit=suffix)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _parse_with(
|
|
75
|
+
text: str,
|
|
76
|
+
*,
|
|
77
|
+
iec: Mapping[str, int],
|
|
78
|
+
si: Mapping[str, int],
|
|
79
|
+
extra: Mapping[str, int],
|
|
80
|
+
si_pinned: Mapping[str, int],
|
|
81
|
+
upper: set[str] | frozenset[str],
|
|
82
|
+
foreign: Mapping[str, str],
|
|
83
|
+
hint: Callable[[str, str | None, bool], str],
|
|
84
|
+
what: str,
|
|
85
|
+
system: str | None,
|
|
86
|
+
rounding: str | None,
|
|
87
|
+
signed: bool,
|
|
88
|
+
decimal_point: str,
|
|
89
|
+
) -> int:
|
|
90
|
+
if system not in (None, "iec", "si"):
|
|
91
|
+
raise ValueError(f"unknown system {system!r}; use 'iec', 'si' or None")
|
|
92
|
+
check_decimal_point(decimal_point)
|
|
93
|
+
mode = None if rounding is None else rounding_mode(rounding)
|
|
94
|
+
if not isinstance(text, str):
|
|
95
|
+
raise TypeError(f"size must be a str, not {type(text).__name__}")
|
|
96
|
+
|
|
97
|
+
match = _PATTERNS[decimal_point].match(text)
|
|
98
|
+
if match is None:
|
|
99
|
+
raise InvalidSizeError(f"cannot parse size {text!r}", text=text)
|
|
100
|
+
sign, number, suffix = match.groups()
|
|
101
|
+
if sign and not signed:
|
|
102
|
+
raise InvalidSizeError(f"{text!r} has a sign; pass signed=True to allow it", text=text)
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
factor = _factor(
|
|
106
|
+
suffix,
|
|
107
|
+
iec=iec,
|
|
108
|
+
si=si,
|
|
109
|
+
extra=extra,
|
|
110
|
+
si_pinned=si_pinned,
|
|
111
|
+
upper=upper,
|
|
112
|
+
foreign=foreign,
|
|
113
|
+
system=system,
|
|
114
|
+
hint=hint,
|
|
115
|
+
)
|
|
116
|
+
except ByteszError as error:
|
|
117
|
+
error.text = text
|
|
118
|
+
raise
|
|
119
|
+
|
|
120
|
+
digits = number.replace(decimal_point, ".")
|
|
121
|
+
with localcontext(exact_context(len(digits) + digit_count(factor) + 2)):
|
|
122
|
+
total = Decimal(digits) * factor
|
|
123
|
+
if sign == "-":
|
|
124
|
+
total = -total
|
|
125
|
+
if total != total.to_integral_value():
|
|
126
|
+
if mode is None:
|
|
127
|
+
raise FractionalBytesError(
|
|
128
|
+
f"{text!r} is not a whole number of {what}", text=text, unit=suffix
|
|
129
|
+
)
|
|
130
|
+
total = total.quantize(Decimal(1), rounding=mode)
|
|
131
|
+
return int(total)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def parse(
|
|
135
|
+
text: str,
|
|
136
|
+
*,
|
|
137
|
+
system: System | None = None,
|
|
138
|
+
rounding: Rounding | None = None,
|
|
139
|
+
signed: bool = False,
|
|
140
|
+
decimal_point: str = ".",
|
|
141
|
+
legacy: bool = False,
|
|
142
|
+
) -> int:
|
|
143
|
+
"""Convert a human size such as "1.5 MiB" into an exact number of bytes.
|
|
144
|
+
|
|
145
|
+
The unit decides the system: IEC suffixes (KiB, MiB ... QiB, or the bare
|
|
146
|
+
Ki, Mi written by ``numfmt --to=iec-i``) use 1024 and SI suffixes (kB, MB ...
|
|
147
|
+
QB) use 1000. A bare number, or "B", is a count of bytes. Units are case
|
|
148
|
+
sensitive: "KB", "Mb" and "kib" raise :class:`AmbiguousUnitError`.
|
|
149
|
+
|
|
150
|
+
``system`` accepts one system only; the other raises
|
|
151
|
+
:class:`SystemMismatchError`. Pinning "si" also accepts bare prefixes such as
|
|
152
|
+
"1.5M" or "2K", as ``numfmt --from=si`` does.
|
|
153
|
+
|
|
154
|
+
``rounding`` rounds a value that is not a whole byte instead of raising
|
|
155
|
+
:class:`FractionalBytesError`. ``signed`` allows a leading "+" or "-".
|
|
156
|
+
``decimal_point`` is "." or ",". ``legacy`` reads the JEDEC style upper case
|
|
157
|
+
units (KB, MB, GB ... and bare K, M, G) as powers of 1024; "kB" stays 1000.
|
|
158
|
+
|
|
159
|
+
>>> parse("1.5 MiB")
|
|
160
|
+
1572864
|
|
161
|
+
>>> parse("1.5 MB")
|
|
162
|
+
1500000
|
|
163
|
+
>>> parse("1 MB", legacy=True)
|
|
164
|
+
1048576
|
|
165
|
+
"""
|
|
166
|
+
if legacy and system == "si":
|
|
167
|
+
raise ValueError("legacy=True reads units as powers of 1024; it cannot pin system='si'")
|
|
168
|
+
return _parse_with(
|
|
169
|
+
text,
|
|
170
|
+
iec={**IEC_UNITS, **IEC_BARE},
|
|
171
|
+
si=SI_UNITS,
|
|
172
|
+
extra=LEGACY_UNITS if legacy else {},
|
|
173
|
+
si_pinned=SI_BARE_PINNED,
|
|
174
|
+
upper=UPPER_UNITS,
|
|
175
|
+
foreign=BIT_SYMBOLS,
|
|
176
|
+
hint=byte_hint,
|
|
177
|
+
what="bytes",
|
|
178
|
+
system=system,
|
|
179
|
+
rounding=rounding,
|
|
180
|
+
signed=signed,
|
|
181
|
+
decimal_point=decimal_point,
|
|
182
|
+
)
|
bytesz/_types.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Public type aliases and the rounding mode table."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import decimal
|
|
6
|
+
from typing import Final, Literal
|
|
7
|
+
|
|
8
|
+
System = Literal["iec", "si"]
|
|
9
|
+
"""A size system: "iec" (powers of 1024, KiB) or "si" (powers of 1000, kB)."""
|
|
10
|
+
|
|
11
|
+
Rounding = Literal["half-up", "half-even", "half-down", "up", "down", "ceiling", "floor"]
|
|
12
|
+
"""How a value is rounded to the requested precision.
|
|
13
|
+
|
|
14
|
+
"half-up" rounds ties away from zero (numfmt ``nearest``), "half-even" rounds ties
|
|
15
|
+
to the even digit, "half-down" rounds ties toward zero, "up" rounds away from
|
|
16
|
+
zero (numfmt ``from-zero``), "down" truncates toward zero (numfmt
|
|
17
|
+
``towards-zero``), "ceiling" rounds toward positive infinity (numfmt ``up``) and
|
|
18
|
+
"floor" toward negative infinity (numfmt ``down``).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
ROUNDING_MODES: Final[dict[str, str]] = {
|
|
22
|
+
"half-up": decimal.ROUND_HALF_UP,
|
|
23
|
+
"half-even": decimal.ROUND_HALF_EVEN,
|
|
24
|
+
"half-down": decimal.ROUND_HALF_DOWN,
|
|
25
|
+
"up": decimal.ROUND_UP,
|
|
26
|
+
"down": decimal.ROUND_DOWN,
|
|
27
|
+
"ceiling": decimal.ROUND_CEILING,
|
|
28
|
+
"floor": decimal.ROUND_FLOOR,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def rounding_mode(rounding: str) -> str:
|
|
33
|
+
"""Return the ``decimal`` rounding constant for a public rounding name."""
|
|
34
|
+
try:
|
|
35
|
+
return ROUNDING_MODES[rounding]
|
|
36
|
+
except KeyError:
|
|
37
|
+
names = ", ".join(repr(name) for name in ROUNDING_MODES)
|
|
38
|
+
raise ValueError(f"unknown rounding {rounding!r}; use one of {names}") from None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def check_system(system: object) -> None:
|
|
42
|
+
"""Reject anything that is not "iec" or "si"."""
|
|
43
|
+
if system not in ("iec", "si"):
|
|
44
|
+
raise ValueError(f"unknown system {system!r}; use 'iec' or 'si'")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def check_decimal_point(decimal_point: object) -> None:
|
|
48
|
+
"""Accept only "." or "," as the decimal separator."""
|
|
49
|
+
if decimal_point not in (".", ","):
|
|
50
|
+
raise ValueError(f"decimal_point must be '.' or ',', not {decimal_point!r}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def exact_context(prec: int) -> decimal.Context:
|
|
54
|
+
"""Return a private context that ignores the caller's global decimal settings.
|
|
55
|
+
|
|
56
|
+
Precision is set by the caller to be large enough for an exact result, the
|
|
57
|
+
exponent range is the widest the platform allows, and only real errors trap.
|
|
58
|
+
"""
|
|
59
|
+
return decimal.Context(
|
|
60
|
+
prec=max(prec, 1),
|
|
61
|
+
rounding=decimal.ROUND_HALF_EVEN,
|
|
62
|
+
Emax=decimal.MAX_EMAX,
|
|
63
|
+
Emin=decimal.MIN_EMIN,
|
|
64
|
+
traps=[decimal.InvalidOperation, decimal.DivisionByZero, decimal.Overflow],
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def digit_count(number: int) -> int:
|
|
69
|
+
"""Return an upper bound on the decimal digits of ``number``, without str()."""
|
|
70
|
+
return abs(number).bit_length() * 30103 // 100000 + 2
|
bytesz/_units.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Unit tables for the IEC (1024) and SI (1000) size systems.
|
|
2
|
+
|
|
3
|
+
Each table is exact and case sensitive. The byte suffix "B" is shared by both
|
|
4
|
+
systems and always sits at exponent zero. The IEC suffixes carry the "i" marker
|
|
5
|
+
(KiB, MiB); the SI suffixes do not (kB, MB). Any other spelling is either an
|
|
6
|
+
unknown unit or an ambiguous one, never a silent guess.
|
|
7
|
+
|
|
8
|
+
The tables reach the prefixes adopted in 2022 by the CGPM (ronna R = 1000**9,
|
|
9
|
+
quetta Q = 1000**10) and their binary partners from IEC 80000-13:2025 (robi
|
|
10
|
+
Ri = 1024**9, quebi Qi = 1024**10). Sources: https://www.bipm.org/en/measurement-units/si-prefixes
|
|
11
|
+
and https://physics.nist.gov/cuu/Units/binary.html.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Final
|
|
17
|
+
|
|
18
|
+
IEC_BASE: Final = 1024
|
|
19
|
+
SI_BASE: Final = 1000
|
|
20
|
+
|
|
21
|
+
IEC_PREFIXES: Final = ("", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi", "Yi", "Ri", "Qi")
|
|
22
|
+
SI_PREFIXES: Final = ("", "k", "M", "G", "T", "P", "E", "Z", "Y", "R", "Q")
|
|
23
|
+
|
|
24
|
+
IEC_SUFFIXES: Final = tuple(prefix + "B" for prefix in IEC_PREFIXES)
|
|
25
|
+
SI_SUFFIXES: Final = tuple(prefix + "B" for prefix in SI_PREFIXES)
|
|
26
|
+
IEC_BIT_SUFFIXES: Final = tuple(prefix + "bit" for prefix in IEC_PREFIXES)
|
|
27
|
+
SI_BIT_SUFFIXES: Final = tuple(prefix + "bit" for prefix in SI_PREFIXES)
|
|
28
|
+
|
|
29
|
+
IEC_UNITS: Final[dict[str, int]] = {suffix: exp for exp, suffix in enumerate(IEC_SUFFIXES)}
|
|
30
|
+
SI_UNITS: Final[dict[str, int]] = {suffix: exp for exp, suffix in enumerate(SI_SUFFIXES)}
|
|
31
|
+
|
|
32
|
+
# Bare IEC prefixes ("1.5Mi", as written by `numfmt --to=iec-i`) only ever mean
|
|
33
|
+
# a binary multiple, so they are accepted wherever IEC units are.
|
|
34
|
+
IEC_BARE: Final[dict[str, int]] = {p: exp for exp, p in enumerate(IEC_PREFIXES) if p}
|
|
35
|
+
|
|
36
|
+
# Bare SI prefixes ("1.5M", as written by `numfmt --to=si`). "M" alone is read
|
|
37
|
+
# as 1024**2 by `numfmt --from=iec`, so these are accepted only when the caller
|
|
38
|
+
# pins system="si". Upper case "K" is accepted there too, as numfmt does.
|
|
39
|
+
SI_BARE: Final[dict[str, int]] = {p: exp for exp, p in enumerate(SI_PREFIXES) if p}
|
|
40
|
+
SI_BARE_PINNED: Final[dict[str, int]] = {**SI_BARE, "K": 1}
|
|
41
|
+
|
|
42
|
+
# Legacy binary spellings: upper case prefix plus "B", or the bare upper case
|
|
43
|
+
# prefix, read as powers of 1024. JEDEC defines only K, M and G this way "to
|
|
44
|
+
# reflect common usage"; operating systems extend the habit upwards. Used only
|
|
45
|
+
# when the caller opts in with legacy=True.
|
|
46
|
+
_LEGACY_PREFIXES: Final = ("K", "M", "G", "T", "P", "E", "Z", "Y", "R", "Q")
|
|
47
|
+
LEGACY_UNITS: Final[dict[str, int]] = {
|
|
48
|
+
**{p + "B": exp for exp, p in enumerate(_LEGACY_PREFIXES, start=1)},
|
|
49
|
+
**{p: exp for exp, p in enumerate(_LEGACY_PREFIXES, start=1)},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# Upper cased spellings, used only to tell an ambiguous casing (KB, Kib) apart
|
|
53
|
+
# from an entirely unknown unit.
|
|
54
|
+
UPPER_UNITS: Final = (
|
|
55
|
+
{s.upper() for s in IEC_SUFFIXES}
|
|
56
|
+
| {s.upper() for s in SI_SUFFIXES}
|
|
57
|
+
| {s.upper() for s in LEGACY_UNITS}
|
|
58
|
+
| {s.upper() for s in IEC_BARE}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Bit units for parse_bits: the long "bit" symbol and the short "b" symbol.
|
|
62
|
+
# "Kb" and bare upper case prefixes stay ambiguous, exactly as "KB" does.
|
|
63
|
+
IEC_BIT_UNITS: Final[dict[str, int]] = {
|
|
64
|
+
**{s: exp for exp, s in enumerate(IEC_BIT_SUFFIXES)},
|
|
65
|
+
**{p + "b": exp for exp, p in enumerate(IEC_PREFIXES)},
|
|
66
|
+
}
|
|
67
|
+
SI_BIT_UNITS: Final[dict[str, int]] = {
|
|
68
|
+
**{s: exp for exp, s in enumerate(SI_BIT_SUFFIXES)},
|
|
69
|
+
**{p + "b": exp for exp, p in enumerate(SI_PREFIXES)},
|
|
70
|
+
}
|
|
71
|
+
UPPER_BIT_UNITS: Final = {s.upper() for s in IEC_BIT_UNITS} | {s.upper() for s in SI_BIT_UNITS}
|
bytesz/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: bytesz
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Strict two-way conversion between byte counts and human sizes with explicit IEC and SI units
|
|
5
|
+
Project-URL: Homepage, https://lianes8studio.com
|
|
6
|
+
Project-URL: Repository, https://github.com/JaviLianes8/bytesz
|
|
7
|
+
Project-URL: Issues, https://github.com/JaviLianes8/bytesz/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/JaviLianes8/bytesz/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: bytes,format,human,iec,kib,mib,numfmt,parse,si,size
|
|
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: Topic :: Utilities
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: build; extra == 'dev'
|
|
26
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
29
|
+
Requires-Dist: twine; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# bytesz
|
|
33
|
+
|
|
34
|
+
Strict two-way conversion between byte counts and human sizes with explicit IEC and SI units.
|
|
35
|
+
Typed, exact (integer and `Decimal` arithmetic only), zero dependencies, with a small CLI.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
pip install bytesz
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from bytesz import format, parse
|
|
47
|
+
|
|
48
|
+
# Human size to an exact integer number of bytes. The unit picks the system.
|
|
49
|
+
parse("1.5 MiB") # 1572864 (IEC, 1024 based)
|
|
50
|
+
parse("1.5 MB") # 1500000 (SI, 1000 based)
|
|
51
|
+
parse("512 B") # 512
|
|
52
|
+
parse("2048") # 2048 (a bare number is bytes)
|
|
53
|
+
|
|
54
|
+
# Bytes back to a human size, for whichever system you ask for.
|
|
55
|
+
format(1572864) # "1.5 MiB" (IEC is the default)
|
|
56
|
+
format(1500000, system="si") # "1.5 MB"
|
|
57
|
+
format(1234567, system="si", precision=2) # "1.23 MB"
|
|
58
|
+
format(1048575) # "1 MiB" (rounding carries into the next unit)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
It refuses to guess:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from bytesz import AmbiguousUnitError, FractionalBytesError, SystemMismatchError, parse
|
|
65
|
+
|
|
66
|
+
parse("1 KB") # AmbiguousUnitError: KB could mean kB (1000) or KiB (1024)
|
|
67
|
+
parse("100 Mb") # AmbiguousUnitError: a lower case b means bits, see parse_bits
|
|
68
|
+
parse("1.5 B") # FractionalBytesError: not a whole number of bytes
|
|
69
|
+
parse("1 kB", system="iec") # SystemMismatchError: an SI unit under a pinned IEC system
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Every input error is a `ByteszError` (a `ValueError`) carrying `.text` and `.unit`. Bad
|
|
73
|
+
option values raise a plain `ValueError`, and a non-string input to `parse` a `TypeError`.
|
|
74
|
+
|
|
75
|
+
## Options
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from bytesz import convert, format, format_bits, parse, parse_bits
|
|
79
|
+
|
|
80
|
+
# Formatting
|
|
81
|
+
format(1250, system="si", precision=1, rounding="half-even") # "1.2 kB"
|
|
82
|
+
format(1_000_000, unit="MiB") # "0.95 MiB"
|
|
83
|
+
format(5000, unit="kB") # "5 kB" (the unit picks the system)
|
|
84
|
+
format(-1536, signed=True) # "-1.5 KiB"
|
|
85
|
+
format(1536, precision=3, trailing_zeros=True) # "1.500 KiB"
|
|
86
|
+
format(1536, decimal_point=",", separator="") # "1,5KiB"
|
|
87
|
+
|
|
88
|
+
# Parsing
|
|
89
|
+
parse("1.0005 kB", rounding="half-up") # 1001
|
|
90
|
+
parse("1,5 MiB", decimal_point=",") # 1572864
|
|
91
|
+
parse("-2 KiB", signed=True) # -2048
|
|
92
|
+
parse("1.5M", system="si") # 1500000 (bare prefixes, as numfmt writes them)
|
|
93
|
+
parse("1.5Mi") # 1572864 (bare IEC prefix)
|
|
94
|
+
parse("4 GB", legacy=True) # 4294967296 (JEDEC style KB/MB/GB = 1024, opt-in)
|
|
95
|
+
|
|
96
|
+
# Bits, with the unambiguous "bit" symbol
|
|
97
|
+
parse_bits("100 Mbit") # 100000000
|
|
98
|
+
format_bits(1_500_000, system="si") # "1.5 Mbit"
|
|
99
|
+
|
|
100
|
+
# Exact value in one unit, no rounding at all
|
|
101
|
+
convert(1, "KiB") # Decimal("0.0009765625")
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Rounding modes: `half-up` (default, ties away from zero), `half-even`, `half-down`,
|
|
105
|
+
`up`, `down`, `ceiling`, `floor`. Units reach the 2022 SI prefixes `RB` and `QB` and
|
|
106
|
+
the IEC 80000-13:2025 prefixes `RiB` and `QiB`.
|
|
107
|
+
|
|
108
|
+
## Command line
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
bytesz format 1572864 # 1.5 MiB
|
|
112
|
+
bytesz format --system si 1234567 # 1.23 MB
|
|
113
|
+
bytesz parse "1.5 MiB" 2GB # 1572864, 2000000000
|
|
114
|
+
bytesz parse --legacy 4KB # 4096
|
|
115
|
+
du -b * | bytesz format --field 1 # rewrite one column, like numfmt --field
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Values come from the arguments or, when there are none, one per line from stdin.
|
|
119
|
+
Errors go to stderr with exit status 1. `bytesz --help` lists every option.
|
|
120
|
+
|
|
121
|
+
## Why
|
|
122
|
+
|
|
123
|
+
Human byte sizes are a well known trap: `KB` means 1000 bytes to a disk vendor and
|
|
124
|
+
1024 bytes to an operating system, and most libraries quietly pick one. The popular
|
|
125
|
+
options lean on guessing or only go one way: `humanize` and `hurry.filesize` format
|
|
126
|
+
numbers but do not parse them back, `humanfriendly` parses but treats `KB` and `KiB`
|
|
127
|
+
as the same 1024 based value, and `bitmath` mixes bits and bytes with a broad API.
|
|
128
|
+
|
|
129
|
+
`bytesz` is deliberately narrow and strict. The unit alone decides the system, both
|
|
130
|
+
directions are exact, and anything ambiguous or fractional is rejected with a clear
|
|
131
|
+
error instead of being rounded, unless you ask for rounding. Reach for it when a
|
|
132
|
+
wrong factor of 1024 would be a real bug, such as quotas, billing, or on-disk
|
|
133
|
+
accounting. With matching `--round` and `--format=%.Nf`, its formatting agrees with
|
|
134
|
+
GNU `numfmt` (checked in the test suite on seeded values), except where `numfmt` itself
|
|
135
|
+
drifts because it divides in floating point.
|
|
136
|
+
|
|
137
|
+
See [GUIDE.md](GUIDE.md) for the rules in depth.
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
MIT. See [LICENSE](LICENSE).
|
|
142
|
+
|
|
143
|
+
## Links
|
|
144
|
+
|
|
145
|
+
- Website: https://lianes8studio.com
|
|
146
|
+
- GitHub: https://github.com/JaviLianes8
|
|
147
|
+
- LinkedIn: https://www.linkedin.com/in/jlianes/
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
bytesz/__init__.py,sha256=oZln1aoae8mg8JTPJUafPnl8_V4T6I2hoOoO17r7fdI,1144
|
|
2
|
+
bytesz/__main__.py,sha256=hNCKQrZYNdzffrHvF9XPqvUd8HYnLCmhz6OlC45_f84,155
|
|
3
|
+
bytesz/_bits.py,sha256=3jZvTDWQj10_ffo0RJhg0Qoue-gXSETsUypie7XypKc,2235
|
|
4
|
+
bytesz/_cli.py,sha256=nnzJYt4KnXsiiHOJY8KX1ndGgHquz86h-Z6nb9UmIDk,6215
|
|
5
|
+
bytesz/_cli_support.py,sha256=U9j7Rsq4NWyOnqTGZ_Ed4E5O_IRnz-n5cYQ5lMlv7l8,2617
|
|
6
|
+
bytesz/_convert.py,sha256=xmTh5ojb7oYvt23a6SWxmJGiNAYKuwDe_goIoZilmks,1754
|
|
7
|
+
bytesz/_errors.py,sha256=QcZ9SJVP0EFL4RbKWoNoRnUMU4MfjRzE-nK4loNuFYo,1414
|
|
8
|
+
bytesz/_format.py,sha256=QTYyqXcqFd-iFKiS8ALbsstNE9GhUI1X7i-kkkAKukA,5445
|
|
9
|
+
bytesz/_hints.py,sha256=iY8sif4xmY1MK_VoZ3jrMnZjCllcDIbdyahhTvyUiS4,1938
|
|
10
|
+
bytesz/_parse.py,sha256=2pU4Mrxz25SKSnpk0b8VWywyorczTFN3d7YPuGl73Yc,5868
|
|
11
|
+
bytesz/_types.py,sha256=JSZK0kwk954YwyrIiqqKZF5nl-pS6rf5EV1LCWy-DvI,2588
|
|
12
|
+
bytesz/_units.py,sha256=Serl8u3q2PT_kh6GKBliq5VrCxcnF2faheuZNXhavdc,3410
|
|
13
|
+
bytesz/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
bytesz-0.2.0.dist-info/METADATA,sha256=-s3oQVgZYcom4PuIBMD7PQrKJzVWVk_9WJOHIpqEBEI,5642
|
|
15
|
+
bytesz-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
16
|
+
bytesz-0.2.0.dist-info/entry_points.txt,sha256=aa3fzZ1Dq_xSnXI9DdkNY1gMIqdVCneKJF-l9n5Wd4A,44
|
|
17
|
+
bytesz-0.2.0.dist-info/licenses/LICENSE,sha256=_WmABlQG__oqlx4Rzp-qDJ10xND5KCLScftbhXbLC_c,1070
|
|
18
|
+
bytesz-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.
|