pyconvertx 1.0.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.
- pyconvert/__init__.py +110 -0
- pyconvert/bases.py +199 -0
- pyconvert/exceptions.py +139 -0
- pyconvert/py.typed +0 -0
- pyconvert/registry.py +759 -0
- pyconvert/units.py +221 -0
- pyconvertx-1.0.0.dist-info/METADATA +315 -0
- pyconvertx-1.0.0.dist-info/RECORD +11 -0
- pyconvertx-1.0.0.dist-info/WHEEL +5 -0
- pyconvertx-1.0.0.dist-info/licenses/LICENSE +21 -0
- pyconvertx-1.0.0.dist-info/top_level.txt +1 -0
pyconvert/__init__.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""PyConvert -- a lightweight unit and number-base conversion library.
|
|
2
|
+
|
|
3
|
+
Install as ``pyconvertx``, import as ``pyconvert``::
|
|
4
|
+
|
|
5
|
+
pip install pyconvertx
|
|
6
|
+
|
|
7
|
+
Unit conversion takes a value and two unit symbols. There is no category
|
|
8
|
+
argument: every symbol is unique across the registry, so the category is looked
|
|
9
|
+
up for you, and converting across categories is an error rather than a wrong
|
|
10
|
+
number.
|
|
11
|
+
|
|
12
|
+
>>> import pyconvert
|
|
13
|
+
>>> pyconvert.convert(100, "km", "mi")
|
|
14
|
+
62.13711922373339
|
|
15
|
+
>>> pyconvert.convert(100, "celsius", "fahrenheit")
|
|
16
|
+
212.0
|
|
17
|
+
>>> pyconvert.convert(1024, "MiB", "GiB")
|
|
18
|
+
1.0
|
|
19
|
+
|
|
20
|
+
Number bases go from 2 to 36, and always come back as a string:
|
|
21
|
+
|
|
22
|
+
>>> pyconvert.convert_base(100, 10, 2)
|
|
23
|
+
'1100100'
|
|
24
|
+
>>> pyconvert.convert_base("FF", 16, 10)
|
|
25
|
+
'255'
|
|
26
|
+
|
|
27
|
+
Lookup is case-sensitive, because it has to be -- ``mW`` is a milliwatt and
|
|
28
|
+
``MW`` a megawatt, ``KB`` and ``KiB`` differ by 2.4%. Alternative spellings are
|
|
29
|
+
handled by aliases instead, so ``kilometre``, ``kilometer`` and ``km`` all work,
|
|
30
|
+
as do the typeable forms of awkward symbols (``m2`` for m2, ``degC`` for degC,
|
|
31
|
+
``us`` for microseconds).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from .bases import DIGITS, MAX_BASE, MIN_BASE, convert_base
|
|
37
|
+
from .exceptions import (
|
|
38
|
+
IncompatibleUnitsError,
|
|
39
|
+
InvalidBaseError,
|
|
40
|
+
InvalidDigitError,
|
|
41
|
+
PyConvertError,
|
|
42
|
+
RegistryError,
|
|
43
|
+
UnknownCategoryError,
|
|
44
|
+
UnknownUnitError,
|
|
45
|
+
)
|
|
46
|
+
from .registry import AMBIGUOUS, BASE_UNITS, CATEGORIES
|
|
47
|
+
from .units import (
|
|
48
|
+
base_unit,
|
|
49
|
+
category_of,
|
|
50
|
+
convert,
|
|
51
|
+
convert_all,
|
|
52
|
+
is_convertible,
|
|
53
|
+
list_categories,
|
|
54
|
+
list_units,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
__author__ = "cobrapy-x"
|
|
58
|
+
__license__ = "MIT"
|
|
59
|
+
|
|
60
|
+
__all__ = [
|
|
61
|
+
# unit conversion
|
|
62
|
+
"convert",
|
|
63
|
+
"convert_all",
|
|
64
|
+
"is_convertible",
|
|
65
|
+
# number bases
|
|
66
|
+
"convert_base",
|
|
67
|
+
"MIN_BASE",
|
|
68
|
+
"MAX_BASE",
|
|
69
|
+
"DIGITS",
|
|
70
|
+
# introspection
|
|
71
|
+
"list_categories",
|
|
72
|
+
"list_units",
|
|
73
|
+
"category_of",
|
|
74
|
+
"base_unit",
|
|
75
|
+
"CATEGORIES",
|
|
76
|
+
"BASE_UNITS",
|
|
77
|
+
"AMBIGUOUS",
|
|
78
|
+
# errors
|
|
79
|
+
"PyConvertError",
|
|
80
|
+
"UnknownUnitError",
|
|
81
|
+
"UnknownCategoryError",
|
|
82
|
+
"IncompatibleUnitsError",
|
|
83
|
+
"InvalidBaseError",
|
|
84
|
+
"InvalidDigitError",
|
|
85
|
+
"RegistryError",
|
|
86
|
+
# metadata
|
|
87
|
+
"__version__",
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def __getattr__(name: str) -> str:
|
|
92
|
+
"""Resolve ``__version__`` on first access rather than at import.
|
|
93
|
+
|
|
94
|
+
``importlib.metadata`` is the only way to read the installed version, but
|
|
95
|
+
importing it costs around 12 ms -- more than half of this package's import
|
|
96
|
+
time -- because it pulls in ``email.message``. Almost nobody reads
|
|
97
|
+
``__version__``, so it is looked up lazily and then cached in the module
|
|
98
|
+
namespace. See PEP 562 for module-level ``__getattr__``.
|
|
99
|
+
"""
|
|
100
|
+
if name == "__version__":
|
|
101
|
+
try:
|
|
102
|
+
from importlib.metadata import version
|
|
103
|
+
|
|
104
|
+
resolved = version("pyconvertx")
|
|
105
|
+
except Exception:
|
|
106
|
+
# Running from a source checkout that was never pip-installed.
|
|
107
|
+
resolved = "0.0.0+source"
|
|
108
|
+
globals()["__version__"] = resolved
|
|
109
|
+
return resolved
|
|
110
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
pyconvert/bases.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Number-base conversion between any two bases from 2 to 36.
|
|
2
|
+
|
|
3
|
+
The public entry point is :func:`convert_base`::
|
|
4
|
+
|
|
5
|
+
>>> convert_base(100, 10, 2)
|
|
6
|
+
'1100100'
|
|
7
|
+
>>> convert_base("10101111", 2, 16)
|
|
8
|
+
'AF'
|
|
9
|
+
>>> convert_base("FF", 16, 10)
|
|
10
|
+
'255'
|
|
11
|
+
>>> convert_base(1000, 10, 36)
|
|
12
|
+
'RS'
|
|
13
|
+
|
|
14
|
+
The result is always a string, including when the target base is 10. A base-10
|
|
15
|
+
result is a string too because that is the only answer type that is true for
|
|
16
|
+
every base: once ``'AF'`` and ``'1100100'`` are strings, returning an ``int``
|
|
17
|
+
for base 10 alone would make the return type depend on an argument's value.
|
|
18
|
+
Call ``int(...)`` on it if you want a number back.
|
|
19
|
+
|
|
20
|
+
This module is named ``bases`` rather than ``numbers`` because Python's standard
|
|
21
|
+
library already has a ``numbers`` module -- the one holding ``numbers.Real``,
|
|
22
|
+
which :mod:`pyconvert.units` imports.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from fractions import Fraction
|
|
28
|
+
|
|
29
|
+
from .exceptions import InvalidBaseError, InvalidDigitError
|
|
30
|
+
|
|
31
|
+
__all__ = ["DIGITS", "MAX_BASE", "MIN_BASE", "convert_base"]
|
|
32
|
+
|
|
33
|
+
#: The digit alphabet: 0-9 then A-Z, which is what caps the base at 36.
|
|
34
|
+
DIGITS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
35
|
+
|
|
36
|
+
MIN_BASE = 2
|
|
37
|
+
MAX_BASE = 36
|
|
38
|
+
|
|
39
|
+
#: Digit character -> value, accepting either case on input.
|
|
40
|
+
_VALUES = {character: value for value, character in enumerate(DIGITS)}
|
|
41
|
+
_VALUES.update({character.lower(): value for value, character in enumerate(DIGITS)})
|
|
42
|
+
|
|
43
|
+
#: Default number of fractional digits produced for a non-terminating result.
|
|
44
|
+
DEFAULT_PRECISION = 16
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _check_base(base: object, argument: str) -> int:
|
|
48
|
+
"""Validate a base, rejecting bools along with everything else non-integer."""
|
|
49
|
+
if isinstance(base, bool) or not isinstance(base, int):
|
|
50
|
+
raise InvalidBaseError(base, MIN_BASE, MAX_BASE)
|
|
51
|
+
if not MIN_BASE <= base <= MAX_BASE:
|
|
52
|
+
raise InvalidBaseError(base, MIN_BASE, MAX_BASE)
|
|
53
|
+
return base
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _parse(number: object, base: int) -> Fraction:
|
|
57
|
+
"""Read *number* in *base* and return its exact value as a ``Fraction``.
|
|
58
|
+
|
|
59
|
+
Exact on purpose: a fractional part converted through ``float`` would pick
|
|
60
|
+
up binary rounding error before the output digits are even generated.
|
|
61
|
+
"""
|
|
62
|
+
if isinstance(number, str):
|
|
63
|
+
text = number.strip().replace("_", "")
|
|
64
|
+
elif isinstance(number, int) and not isinstance(number, bool):
|
|
65
|
+
# An int is read as its written digits, so convert_base(1100100, 2, 10)
|
|
66
|
+
# treats those digits as binary, matching the string form.
|
|
67
|
+
text = str(number)
|
|
68
|
+
else:
|
|
69
|
+
raise TypeError(
|
|
70
|
+
"number must be an int or a str, got "
|
|
71
|
+
f"{type(number).__name__}"
|
|
72
|
+
+ (
|
|
73
|
+
" -- pass a fractional value as a string, e.g. '10.5'"
|
|
74
|
+
if isinstance(number, float)
|
|
75
|
+
else ""
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
sign = 1
|
|
80
|
+
if text[:1] in ("+", "-"):
|
|
81
|
+
if text[0] == "-":
|
|
82
|
+
sign = -1
|
|
83
|
+
text = text[1:]
|
|
84
|
+
|
|
85
|
+
if text.count(".") > 1:
|
|
86
|
+
raise InvalidDigitError(".", base, number)
|
|
87
|
+
|
|
88
|
+
whole_text, _, fraction_text = text.partition(".")
|
|
89
|
+
if not whole_text and not fraction_text:
|
|
90
|
+
raise InvalidDigitError(text, base, number)
|
|
91
|
+
|
|
92
|
+
total = Fraction(0)
|
|
93
|
+
for character in whole_text:
|
|
94
|
+
value = _VALUES.get(character)
|
|
95
|
+
if value is None or value >= base:
|
|
96
|
+
raise InvalidDigitError(character, base, number)
|
|
97
|
+
total = total * base + value
|
|
98
|
+
|
|
99
|
+
if fraction_text:
|
|
100
|
+
place = Fraction(1)
|
|
101
|
+
for character in fraction_text:
|
|
102
|
+
value = _VALUES.get(character)
|
|
103
|
+
if value is None or value >= base:
|
|
104
|
+
raise InvalidDigitError(character, base, number)
|
|
105
|
+
place /= base
|
|
106
|
+
total += value * place
|
|
107
|
+
|
|
108
|
+
return sign * total
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _format(quantity: Fraction, base: int, precision: int) -> str:
|
|
112
|
+
"""Render an exact *quantity* in *base*, to at most *precision* fraction digits."""
|
|
113
|
+
sign = "-" if quantity < 0 else ""
|
|
114
|
+
quantity = abs(quantity)
|
|
115
|
+
|
|
116
|
+
whole = quantity.numerator // quantity.denominator
|
|
117
|
+
remainder = quantity - whole
|
|
118
|
+
|
|
119
|
+
if whole == 0:
|
|
120
|
+
digits = "0"
|
|
121
|
+
else:
|
|
122
|
+
collected = []
|
|
123
|
+
while whole:
|
|
124
|
+
whole, digit = divmod(whole, base)
|
|
125
|
+
collected.append(DIGITS[digit])
|
|
126
|
+
digits = "".join(reversed(collected))
|
|
127
|
+
|
|
128
|
+
if remainder:
|
|
129
|
+
collected = []
|
|
130
|
+
for _ in range(precision):
|
|
131
|
+
remainder *= base
|
|
132
|
+
digit = remainder.numerator // remainder.denominator
|
|
133
|
+
collected.append(DIGITS[digit])
|
|
134
|
+
remainder -= digit
|
|
135
|
+
if remainder == 0:
|
|
136
|
+
break
|
|
137
|
+
fraction_digits = "".join(collected).rstrip("0")
|
|
138
|
+
if fraction_digits:
|
|
139
|
+
digits += "." + fraction_digits
|
|
140
|
+
|
|
141
|
+
return sign + digits
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def convert_base(
|
|
145
|
+
number: int | str,
|
|
146
|
+
from_base: int,
|
|
147
|
+
to_base: int,
|
|
148
|
+
precision: int = DEFAULT_PRECISION,
|
|
149
|
+
) -> str:
|
|
150
|
+
"""Convert *number* from one base to another and return a string.
|
|
151
|
+
|
|
152
|
+
*number* is an ``int`` or a ``str``. Input digits may be either case;
|
|
153
|
+
output digits are always uppercase. Underscores are allowed as separators,
|
|
154
|
+
as in Python's own numeric literals.
|
|
155
|
+
|
|
156
|
+
>>> convert_base(100, 10, 2)
|
|
157
|
+
'1100100'
|
|
158
|
+
>>> convert_base("10101111", 2, 16)
|
|
159
|
+
'AF'
|
|
160
|
+
>>> convert_base("FF", 16, 10)
|
|
161
|
+
'255'
|
|
162
|
+
>>> convert_base(1000, 10, 36)
|
|
163
|
+
'RS'
|
|
164
|
+
|
|
165
|
+
Lowercase input and separators are fine, and so are negatives:
|
|
166
|
+
|
|
167
|
+
>>> convert_base("ff", 16, 2)
|
|
168
|
+
'11111111'
|
|
169
|
+
>>> convert_base("1111_1111", 2, 16)
|
|
170
|
+
'FF'
|
|
171
|
+
>>> convert_base(-255, 10, 16)
|
|
172
|
+
'-FF'
|
|
173
|
+
|
|
174
|
+
Fractional parts are converted too, exactly where the result terminates:
|
|
175
|
+
|
|
176
|
+
>>> convert_base("0.5", 10, 2)
|
|
177
|
+
'0.1'
|
|
178
|
+
>>> convert_base("A.8", 16, 10)
|
|
179
|
+
'10.5'
|
|
180
|
+
>>> convert_base("0.1", 10, 2)
|
|
181
|
+
'0.0001100110011001'
|
|
182
|
+
|
|
183
|
+
That last result is truncated, not wrong: one tenth has no finite binary
|
|
184
|
+
form, so it runs to *precision* digits and stops.
|
|
185
|
+
|
|
186
|
+
>>> convert_base("0.1", 10, 2, precision=4)
|
|
187
|
+
'0.0001'
|
|
188
|
+
|
|
189
|
+
:param precision: maximum fractional digits for a non-terminating result.
|
|
190
|
+
:raises InvalidBaseError: if either base is not an integer from 2 to 36.
|
|
191
|
+
:raises InvalidDigitError: if a digit is not valid in *from_base*.
|
|
192
|
+
:raises TypeError: if *number* is neither an ``int`` nor a ``str``.
|
|
193
|
+
"""
|
|
194
|
+
_check_base(from_base, "from_base")
|
|
195
|
+
_check_base(to_base, "to_base")
|
|
196
|
+
if isinstance(precision, bool) or not isinstance(precision, int) or precision < 0:
|
|
197
|
+
raise ValueError(f"precision must be a non-negative integer, got {precision!r}")
|
|
198
|
+
|
|
199
|
+
return _format(_parse(number, from_base), to_base, precision)
|
pyconvert/exceptions.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Exception hierarchy for PyConvert.
|
|
2
|
+
|
|
3
|
+
Every error raised by this library derives from :class:`PyConvertError`, so a
|
|
4
|
+
caller who does not care about the distinction can write::
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
pyconvert.convert(1, "kg", "m")
|
|
8
|
+
except pyconvert.PyConvertError as exc:
|
|
9
|
+
print(exc)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import difflib
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"IncompatibleUnitsError",
|
|
18
|
+
"InvalidBaseError",
|
|
19
|
+
"InvalidDigitError",
|
|
20
|
+
"PyConvertError",
|
|
21
|
+
"RegistryError",
|
|
22
|
+
"UnknownCategoryError",
|
|
23
|
+
"UnknownUnitError",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PyConvertError(Exception):
|
|
28
|
+
"""Base class for every error raised by PyConvert."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class UnknownUnitError(PyConvertError, KeyError):
|
|
32
|
+
"""A unit symbol is not present in the registry.
|
|
33
|
+
|
|
34
|
+
Also inherits :class:`KeyError` so that code written against a plain dict
|
|
35
|
+
lookup keeps working.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
unit: object,
|
|
41
|
+
known: list[str] | None = None,
|
|
42
|
+
hint: str | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self.unit = unit
|
|
45
|
+
self.hint = hint
|
|
46
|
+
self.suggestions: list[str] = []
|
|
47
|
+
message = f"unknown unit {unit!r}"
|
|
48
|
+
|
|
49
|
+
if hint:
|
|
50
|
+
# A spelling the registry refuses on purpose, because it is
|
|
51
|
+
# ambiguous. Say what to write instead; skip fuzzy matching, which
|
|
52
|
+
# would only offer something unrelated that happens to look close.
|
|
53
|
+
super().__init__(f"{message}: {hint}")
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
if known and isinstance(unit, str):
|
|
57
|
+
# Case-insensitive near misses first: "KM" -> "km" is the single
|
|
58
|
+
# most common mistake, and a case-sensitive ratio misses it.
|
|
59
|
+
lowered = {k.lower(): k for k in known}
|
|
60
|
+
if unit.lower() in lowered and lowered[unit.lower()] != unit:
|
|
61
|
+
self.suggestions = [lowered[unit.lower()]]
|
|
62
|
+
else:
|
|
63
|
+
self.suggestions = difflib.get_close_matches(
|
|
64
|
+
unit, known, n=3, cutoff=0.6
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if self.suggestions:
|
|
68
|
+
offered = " or ".join(repr(s) for s in self.suggestions)
|
|
69
|
+
message += f" (did you mean {offered}?)"
|
|
70
|
+
|
|
71
|
+
super().__init__(message)
|
|
72
|
+
|
|
73
|
+
def __str__(self) -> str: # KeyError.__str__ would re-quote the message
|
|
74
|
+
return self.args[0]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class UnknownCategoryError(PyConvertError, KeyError):
|
|
78
|
+
"""A category name is not one of the registry's categories."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, category: object, known: list[str] | None = None) -> None:
|
|
81
|
+
self.category = category
|
|
82
|
+
message = f"unknown category {category!r}"
|
|
83
|
+
if known:
|
|
84
|
+
message += " (available: " + ", ".join(sorted(known)) + ")"
|
|
85
|
+
super().__init__(message)
|
|
86
|
+
|
|
87
|
+
def __str__(self) -> str:
|
|
88
|
+
return self.args[0]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class IncompatibleUnitsError(PyConvertError):
|
|
92
|
+
"""Two units are both valid but measure different kinds of quantity."""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
source: str,
|
|
97
|
+
target: str,
|
|
98
|
+
source_category: str,
|
|
99
|
+
target_category: str,
|
|
100
|
+
) -> None:
|
|
101
|
+
self.source = source
|
|
102
|
+
self.target = target
|
|
103
|
+
self.source_category = source_category
|
|
104
|
+
self.target_category = target_category
|
|
105
|
+
super().__init__(
|
|
106
|
+
f"cannot convert {source!r} to {target!r}: "
|
|
107
|
+
f"{source!r} measures {source_category}, "
|
|
108
|
+
f"{target!r} measures {target_category}"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class InvalidBaseError(PyConvertError, ValueError):
|
|
113
|
+
"""A numeric base is outside the supported range."""
|
|
114
|
+
|
|
115
|
+
def __init__(self, base: object, low: int = 2, high: int = 36) -> None:
|
|
116
|
+
self.base = base
|
|
117
|
+
super().__init__(f"base must be an integer from {low} to {high}, got {base!r}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class InvalidDigitError(PyConvertError, ValueError):
|
|
121
|
+
"""A digit in the input is not valid for the base it was given in."""
|
|
122
|
+
|
|
123
|
+
def __init__(self, digit: str, base: int, number: object = None) -> None:
|
|
124
|
+
self.digit = digit
|
|
125
|
+
self.base = base
|
|
126
|
+
self.number = number
|
|
127
|
+
if not digit:
|
|
128
|
+
super().__init__(f"no digits to convert in {number!r}")
|
|
129
|
+
else:
|
|
130
|
+
super().__init__(f"{digit!r} is not a valid digit in base {base}")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class RegistryError(PyConvertError, RuntimeError):
|
|
134
|
+
"""The unit registry itself is inconsistent.
|
|
135
|
+
|
|
136
|
+
Raised at import time, not at call time: it means two categories claim the
|
|
137
|
+
same symbol, or an alias points at a unit that does not exist. This is a bug
|
|
138
|
+
in PyConvert (or in a locally edited registry), never in user input.
|
|
139
|
+
"""
|
pyconvert/py.typed
ADDED
|
File without changes
|