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 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)
@@ -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