unitful 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.
unitful/__init__.py ADDED
@@ -0,0 +1,167 @@
1
+ """Public API"""
2
+
3
+ # Import numpy_support to install the ndarray patch (no-op if numpy absent)
4
+ from . import numpy_support # noqa: F401
5
+ from .decorators import Dim, requires, returns
6
+ from .dimension import Dimension, dimensionless
7
+ from .exceptions import DimensionError
8
+ from .numpy_support import QuantityArray
9
+ from .quantity import Quantity
10
+ from .registry import Unit, registry
11
+ from .serialization import from_json, to_json
12
+
13
+ # All built-in unit constants are imported from units.py
14
+ from .units import (
15
+ GB,
16
+ KB,
17
+ MB,
18
+ MJ,
19
+ MN,
20
+ MW,
21
+ TB,
22
+ B,
23
+ GiB,
24
+ # Energy
25
+ J,
26
+ # Temperature
27
+ K,
28
+ KiB,
29
+ MiB,
30
+ MPa,
31
+ # Force
32
+ N_unit,
33
+ # Pressure
34
+ Pa,
35
+ # Power
36
+ W,
37
+ arcmin,
38
+ arcsec,
39
+ atm,
40
+ bar,
41
+ # Data
42
+ bit,
43
+ cal,
44
+ cm,
45
+ day,
46
+ deg,
47
+ degC,
48
+ degF,
49
+ eV,
50
+ ft,
51
+ g,
52
+ h,
53
+ hp,
54
+ inch,
55
+ kcal,
56
+ # Mass
57
+ kg,
58
+ kJ,
59
+ km,
60
+ kN,
61
+ knot,
62
+ kPa,
63
+ kW,
64
+ kWh,
65
+ lb,
66
+ lbf,
67
+ ly,
68
+ # Length
69
+ m,
70
+ mach,
71
+ mg,
72
+ mile,
73
+ min,
74
+ mm,
75
+ # Speed
76
+ mph,
77
+ ms,
78
+ nm,
79
+ nmi,
80
+ ns,
81
+ oz,
82
+ psi,
83
+ # Angle
84
+ rad,
85
+ # Time
86
+ s,
87
+ stone,
88
+ t,
89
+ ug,
90
+ um,
91
+ us,
92
+ week,
93
+ yd,
94
+ year,
95
+ μg,
96
+ μm,
97
+ μs,
98
+ )
99
+
100
+ # Newton exported as N (N_unit avoids clash with the Amount dimension symbol)
101
+ N = N_unit
102
+
103
+
104
+ def new_dimension(name: str, symbol: str = "") -> Dimension:
105
+ """Register and return a new base dimension
106
+
107
+ Example::
108
+
109
+ px = new_dimension("pixel", symbol="px")
110
+ """
111
+ return registry.new_dimension(name, symbol)
112
+
113
+
114
+ def define_unit(name: str, quantity: Quantity, symbol: str = "") -> Quantity:
115
+ """Register a new unit derived from an existing Quantity
116
+
117
+ Example::
118
+
119
+ define_unit("furlong", 201.168 * m)
120
+ define_unit("fortnight", 14 * day)
121
+ """
122
+ unit = registry.define_unit(name, quantity, symbol)
123
+ return Quantity(1.0, unit)
124
+
125
+
126
+ __all__ = [
127
+ # Core types
128
+ "Quantity",
129
+ "QuantityArray",
130
+ "Dimension",
131
+ "dimensionless",
132
+ "DimensionError",
133
+ "Unit",
134
+ # Registry helpers
135
+ "new_dimension",
136
+ "define_unit",
137
+ # Decorators
138
+ "requires",
139
+ "returns",
140
+ "Dim",
141
+ # Serialization
142
+ "to_json",
143
+ "from_json",
144
+ # Length
145
+ "m", "km", "cm", "mm", "μm", "um", "nm",
146
+ "inch", "ft", "yd", "mile", "nmi", "ly",
147
+ # Mass
148
+ "kg", "g", "mg", "μg", "ug", "t", "lb", "oz", "stone",
149
+ # Time
150
+ "s", "ms", "μs", "us", "ns", "min", "h", "day", "week", "year",
151
+ # Temperature
152
+ "K", "degC", "degF",
153
+ # Force
154
+ "N", "kN", "MN", "lbf",
155
+ # Energy
156
+ "J", "kJ", "MJ", "cal", "kcal", "eV", "kWh",
157
+ # Power
158
+ "W", "kW", "MW", "hp",
159
+ # Pressure
160
+ "Pa", "kPa", "MPa", "bar", "atm", "psi",
161
+ # Speed
162
+ "mph", "knot", "mach",
163
+ # Data
164
+ "bit", "B", "KB", "MB", "GB", "TB", "KiB", "MiB", "GiB",
165
+ # Angle
166
+ "rad", "deg", "arcmin", "arcsec",
167
+ ]
unitful/decorators.py ADDED
@@ -0,0 +1,142 @@
1
+ """@requires and @returns decorators for dimension-safe function signatures"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ from .dimension import Dimension
10
+ from .exceptions import DimensionError
11
+ from .quantity import Quantity
12
+ from .registry import registry
13
+
14
+
15
+ class Dim:
16
+ """Declare an expected dimension from a unit expression string
17
+
18
+ Usage::
19
+
20
+ @requires(speed=Dim("m/s"), time=Dim("s"))
21
+ def distance_traveled(speed, time):
22
+ ...
23
+
24
+ The string is parsed by dividing the named units from the registry
25
+ """
26
+
27
+ def __init__(self, expr: str) -> None:
28
+ self._expr = expr
29
+ self._dim = _parse_dim_expr(expr)
30
+
31
+ @property
32
+ def dimension(self) -> Dimension:
33
+ return self._dim
34
+
35
+ def __repr__(self) -> str:
36
+ return f"Dim({self._expr!r})"
37
+
38
+
39
+ def _parse_dim_expr(expr: str) -> Dimension:
40
+ """Parse an expression like 'm/s', 'm/s^2', 'kg*m/s^2' into a Dimension"""
41
+ # Split on '/' once: numerator * denominator^-1
42
+ parts = expr.split("/", 1)
43
+ num_dim = _parse_product(parts[0])
44
+ if len(parts) == 2:
45
+ den_dim = _parse_product(parts[1])
46
+ return num_dim / den_dim
47
+ return num_dim
48
+
49
+
50
+ def _parse_product(expr: str) -> Dimension:
51
+ """Parse 'kg*m' or 'kg' or 'm^2' into a Dimension"""
52
+ dim = Dimension()
53
+ for token in expr.split("*"):
54
+ token = token.strip()
55
+ if not token:
56
+ continue
57
+ if "^" in token:
58
+ base, exp_str = token.split("^", 1)
59
+ exp = float(exp_str)
60
+ else:
61
+ base, exp = token, 1.0
62
+ base = base.strip()
63
+ try:
64
+ unit = registry.get(base)
65
+ except KeyError:
66
+ raise ValueError(f"Unknown unit in dimension expression: {base!r}") from None
67
+ dim = dim * (unit.dimension ** exp)
68
+ return dim
69
+
70
+
71
+ def requires(**expected_dims: Dim) -> Callable[..., Any]:
72
+ """Validate that keyword arguments have the expected physical dimensions
73
+
74
+ If an argument has compatible dimensions but a different unit, it is
75
+ automatically converted before being passed to the function
76
+ """
77
+
78
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
79
+ @functools.wraps(func)
80
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
81
+ import inspect
82
+ sig = inspect.signature(func)
83
+ bound = sig.bind(*args, **kwargs)
84
+ bound.apply_defaults()
85
+
86
+ for param_name, dim_spec in expected_dims.items():
87
+ if param_name not in bound.arguments:
88
+ continue
89
+ val = bound.arguments[param_name]
90
+ expected = dim_spec.dimension
91
+
92
+ if not isinstance(val, Quantity):
93
+ raise DimensionError.bare_value(
94
+ func.__name__, param_name, expected.label(), val
95
+ )
96
+
97
+ got_dim = val.dimension
98
+ if got_dim != expected:
99
+ raise DimensionError.wrong_argument(
100
+ func.__name__, param_name, expected.label(), val, got_dim.label()
101
+ )
102
+
103
+ # Auto-convert to the canonical SI unit for that dimension so
104
+ # the function receives a consistent unit. We keep the original
105
+ # unit if no mismatch was detected above.
106
+ # (Conversion already succeeded in the dimension check.)
107
+ bound.arguments[param_name] = val
108
+
109
+ return func(*bound.args, **bound.kwargs)
110
+
111
+ return wrapper
112
+
113
+ return decorator
114
+
115
+
116
+ def returns(dim_spec: Dim) -> Callable[..., Any]:
117
+ """Validate that the return value has the expected physical dimensions"""
118
+
119
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
120
+ @functools.wraps(func)
121
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
122
+ result = func(*args, **kwargs)
123
+ expected = dim_spec.dimension
124
+
125
+ if not isinstance(result, Quantity):
126
+ # Treat bare numbers as dimensionless.
127
+ from .dimension import dimensionless
128
+ if expected != dimensionless:
129
+ raise DimensionError.wrong_return(
130
+ func.__name__, expected.label(), result, "dimensionless"
131
+ )
132
+ return result
133
+
134
+ if result.dimension != expected:
135
+ raise DimensionError.wrong_return(
136
+ func.__name__, expected.label(), result, result.dimension.label()
137
+ )
138
+ return result
139
+
140
+ return wrapper
141
+
142
+ return decorator
unitful/dimension.py ADDED
@@ -0,0 +1,160 @@
1
+ """Immutable exponent vector over the 7 SI base quantities"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from fractions import Fraction
7
+
8
+ # Ordered base dimension symbols used throughout the library
9
+ BASE_DIMS = ("L", "M", "T", "I", "Theta", "N", "J")
10
+
11
+ # Human-readable names for error messages
12
+ _DIM_NAMES: dict[str, str] = {
13
+ "L": "Length",
14
+ "M": "Mass",
15
+ "T": "Time",
16
+ "I": "Current",
17
+ "Theta": "Temperature",
18
+ "N": "Amount",
19
+ "J": "Luminosity",
20
+ }
21
+
22
+
23
+ class Dimension:
24
+ """Immutable vector of rational exponents over the SI base dimensions
25
+
26
+ Each position corresponds to L, M, T, I, Theta, N, J in that order
27
+ Arithmetic on Dimension objects implements the algebra of physical dimensions
28
+ """
29
+
30
+ __slots__ = ("_exponents",)
31
+
32
+ def __init__(self, **exponents: int | float | Fraction) -> None:
33
+ """Create from keyword arguments, e.g. Dimension(L=1, T=-1)"""
34
+ exp: dict[str, Fraction] = {}
35
+ for dim in BASE_DIMS:
36
+ val = exponents.get(dim, 0)
37
+ exp[dim] = Fraction(val).limit_denominator(1000)
38
+ self._exponents: dict[str, Fraction] = exp
39
+
40
+ @classmethod
41
+ def _from_dict(cls, d: dict[str, Fraction]) -> Dimension:
42
+ obj = object.__new__(cls)
43
+ obj._exponents = dict(d)
44
+ return obj
45
+
46
+ # --- custom dimension support ---
47
+
48
+ @classmethod
49
+ def custom(cls, name: str) -> Dimension:
50
+ """Create a dimension with a single non-standard base dimension"""
51
+ obj = object.__new__(cls)
52
+ obj._exponents = {dim: Fraction(0) for dim in BASE_DIMS}
53
+ obj._exponents[name] = Fraction(1)
54
+ return obj
55
+
56
+ # --- arithmetic ---
57
+
58
+ def __mul__(self, other: Dimension) -> Dimension:
59
+ keys = set(self._exponents) | set(other._exponents)
60
+ result = {k: self._exponents.get(k, Fraction(0)) + other._exponents.get(k, Fraction(0)) for k in keys}
61
+ return Dimension._from_dict(result)
62
+
63
+ def __truediv__(self, other: Dimension) -> Dimension:
64
+ keys = set(self._exponents) | set(other._exponents)
65
+ result = {k: self._exponents.get(k, Fraction(0)) - other._exponents.get(k, Fraction(0)) for k in keys}
66
+ return Dimension._from_dict(result)
67
+
68
+ def __pow__(self, exp: int | float | Fraction) -> Dimension:
69
+ f = Fraction(exp).limit_denominator(1000)
70
+ result = {k: v * f for k, v in self._exponents.items()}
71
+ return Dimension._from_dict(result)
72
+
73
+ def __eq__(self, other: object) -> bool:
74
+ if not isinstance(other, Dimension):
75
+ return NotImplemented
76
+ keys = set(self._exponents) | set(other._exponents)
77
+ return all(
78
+ self._exponents.get(k, Fraction(0)) == other._exponents.get(k, Fraction(0))
79
+ for k in keys
80
+ )
81
+
82
+ def __hash__(self) -> int:
83
+ return hash(tuple(sorted((k, v) for k, v in self._exponents.items() if v != 0)))
84
+
85
+ def is_dimensionless(self) -> bool:
86
+ return all(v == 0 for v in self._exponents.values())
87
+
88
+ def keys(self) -> Iterator[str]:
89
+ return iter(self._exponents)
90
+
91
+ def __getitem__(self, key: str) -> Fraction:
92
+ return self._exponents.get(key, Fraction(0))
93
+
94
+ # --- display ---
95
+
96
+ def __str__(self) -> str:
97
+ parts = []
98
+ for k, v in self._exponents.items():
99
+ if v == 0:
100
+ continue
101
+ if v == 1:
102
+ parts.append(k)
103
+ else:
104
+ # Use integer display when possible
105
+ exp_str = str(int(v)) if v.denominator == 1 else str(v)
106
+ parts.append(f"{k}^{exp_str}")
107
+ return "*".join(parts) if parts else "dimensionless"
108
+
109
+ def __repr__(self) -> str:
110
+ parts = {k: v for k, v in self._exponents.items() if v != 0}
111
+ return f"Dimension({parts!r})"
112
+
113
+ def label(self) -> str:
114
+ """Human-readable label for error messages, e.g. 'Length/Time'"""
115
+ pos = []
116
+ neg = []
117
+ for k, v in self._exponents.items():
118
+ if v == 0:
119
+ continue
120
+ name = _DIM_NAMES.get(k, k)
121
+ if v > 0:
122
+ if v == 1:
123
+ pos.append(name)
124
+ else:
125
+ exp_str = str(int(v)) if v.denominator == 1 else str(v)
126
+ pos.append(f"{name}^{exp_str}")
127
+ else:
128
+ abs_v = -v
129
+ if abs_v == 1:
130
+ neg.append(name)
131
+ else:
132
+ exp_str = str(int(abs_v)) if abs_v.denominator == 1 else str(abs_v)
133
+ neg.append(f"{name}^{exp_str}")
134
+ if not pos and not neg:
135
+ return "dimensionless"
136
+ result = "*".join(pos) if pos else "1"
137
+ if neg:
138
+ result += "/" + "*".join(neg)
139
+ return result
140
+
141
+ def si_str(self) -> str:
142
+ """Exponent notation used in error messages, e.g. 'L^1*T^-1'"""
143
+ parts = []
144
+ for k, v in self._exponents.items():
145
+ if v == 0:
146
+ continue
147
+ exp_str = str(int(v)) if v.denominator == 1 else str(v)
148
+ parts.append(f"{k}^{exp_str}")
149
+ return "*".join(parts) if parts else "1"
150
+
151
+
152
+ # Convenience singletons for the 7 SI base dimensions.
153
+ dimensionless = Dimension()
154
+ Length = Dimension(L=1)
155
+ Mass = Dimension(M=1)
156
+ Time = Dimension(T=1)
157
+ Current = Dimension(I=1)
158
+ Temperature = Dimension(Theta=1)
159
+ Amount = Dimension(N=1)
160
+ Luminosity = Dimension(J=1)
unitful/exceptions.py ADDED
@@ -0,0 +1,106 @@
1
+ """Exceptions raised by unitful"""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class DimensionError(TypeError):
7
+ """Raised when a dimensional operation is invalid
8
+
9
+ Provides a human-readable message describing the mismatch, including the
10
+ values involved and their dimensions
11
+ """
12
+
13
+ @classmethod
14
+ def incompatible(
15
+ cls,
16
+ op: str,
17
+ left: object,
18
+ right: object,
19
+ left_dim: str,
20
+ right_dim: str,
21
+ ) -> DimensionError:
22
+ """Mismatch between two operands (e.g. add/subtract)"""
23
+ msg = (
24
+ f"Cannot {op} [{left_dim}] and [{right_dim}]\n"
25
+ f" left: {left!r} -> dimensions: {left_dim}\n"
26
+ f" right: {right!r} -> dimensions: {right_dim}"
27
+ )
28
+ return cls(msg)
29
+
30
+ @classmethod
31
+ def wrong_unit(
32
+ cls,
33
+ expected_dim: str,
34
+ got_dim: str,
35
+ got: object,
36
+ ) -> DimensionError:
37
+ """Conversion to an incompatible unit"""
38
+ msg = (
39
+ f"Cannot convert [{got_dim}] to [{expected_dim}]\n"
40
+ f" value: {got!r} -> dimensions: {got_dim}\n"
41
+ f" expected: dimensions: {expected_dim}"
42
+ )
43
+ return cls(msg)
44
+
45
+ @classmethod
46
+ def wrong_argument(
47
+ cls,
48
+ func_name: str,
49
+ param: str,
50
+ expected_dim: str,
51
+ got: object,
52
+ got_dim: str,
53
+ ) -> DimensionError:
54
+ """Argument passed to a @requires-decorated function has wrong dimensions"""
55
+ msg = (
56
+ f"Expected [{expected_dim}], got [{got_dim}]\n"
57
+ f" function: {func_name}({param}, ...)\n"
58
+ f" parameter: {param}\n"
59
+ f" got: {got!r} -> dimensions: {got_dim}\n"
60
+ f" expected: dimensions: {expected_dim}"
61
+ )
62
+ return cls(msg)
63
+
64
+ @classmethod
65
+ def bare_value(
66
+ cls,
67
+ func_name: str,
68
+ param: str,
69
+ expected_dim: str,
70
+ got: object,
71
+ ) -> DimensionError:
72
+ """A bare number was passed where a Quantity is required"""
73
+ msg = (
74
+ f"Expected [{expected_dim}], got a bare number\n"
75
+ f" function: {func_name}({param}, ...)\n"
76
+ f" parameter: {param}\n"
77
+ f" got: {got!r} (no unit)\n"
78
+ f" expected: dimensions: {expected_dim}"
79
+ )
80
+ return cls(msg)
81
+
82
+ @classmethod
83
+ def wrong_return(
84
+ cls,
85
+ func_name: str,
86
+ expected_dim: str,
87
+ got: object,
88
+ got_dim: str,
89
+ ) -> DimensionError:
90
+ """Return value of a @returns-decorated function has wrong dimensions"""
91
+ msg = (
92
+ f"Return value has wrong dimensions: expected [{expected_dim}], got [{got_dim}]\n"
93
+ f" function: {func_name}\n"
94
+ f" got: {got!r} -> dimensions: {got_dim}\n"
95
+ f" expected: dimensions: {expected_dim}"
96
+ )
97
+ return cls(msg)
98
+
99
+ @classmethod
100
+ def temperature_arithmetic(cls, op: str) -> DimensionError:
101
+ """Arithmetic on offset temperature units is ambiguous"""
102
+ msg = (
103
+ f"Cannot {op} offset temperature quantities (degC / degF) directly.\n"
104
+ " Convert to Kelvin first: q.to(K)"
105
+ )
106
+ return cls(msg)
unitful/formatting.py ADDED
@@ -0,0 +1,114 @@
1
+ """Formatting helpers for Quantity.__format__"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from re import Match
7
+
8
+
9
+ def _split_spec(format_spec: str) -> tuple[str, str]:
10
+ """Split '0.2f~P' into ('0.2f', 'P'). Returns ('', '') for empty spec"""
11
+ match = re.match(r"^([^~]*)(?:~([A-Za-z]+))?$", format_spec)
12
+ if not match:
13
+ return format_spec, ""
14
+ num_spec, mode = match.group(1) or "", match.group(2) or ""
15
+ return num_spec, mode.upper()
16
+
17
+
18
+ def _format_number(value: float, num_spec: str) -> str:
19
+ """Format a bare float with a numeric format spec"""
20
+ if num_spec:
21
+ return format(value, num_spec)
22
+ return str(value)
23
+
24
+
25
+ # Unicode superscript digits/signs
26
+ _SUPERSCRIPTS = str.maketrans("0123456789+-", "\u2070\u00b9\u00b2\u00b3\u2074\u2075\u2076\u2077\u2078\u2079\u207a\u207b")
27
+
28
+
29
+ def _to_superscript(s: str) -> str:
30
+ return s.translate(_SUPERSCRIPTS)
31
+
32
+
33
+ def _unit_to_unicode(unit_str: str) -> str:
34
+ """Convert 'm/s^2' style unit string to Unicode superscript form"""
35
+ # Replace ^ followed by digits/sign with superscript chars
36
+ def replace_exp(m: Match[str]) -> str:
37
+ return _to_superscript(m.group(1))
38
+
39
+ result = re.sub(r"\^([-+]?\d+(?:/\d+)?)", replace_exp, unit_str)
40
+ # Replace * with middle dot (Unicode U+00B7)
41
+ result = result.replace("*", "\u00b7")
42
+ return result
43
+
44
+
45
+ def _unit_to_latex(unit_str: str) -> str:
46
+ """Convert unit string to LaTeX \\mathrm notation"""
47
+ # Split numerator / denominator on '/'
48
+ if "/" in unit_str:
49
+ parts = unit_str.split("/", 1)
50
+ num = _part_to_latex(parts[0])
51
+ den = _part_to_latex(parts[1])
52
+ return rf"\frac{{{num}}}{{{den}}}"
53
+ return _part_to_latex(unit_str)
54
+
55
+
56
+ def _part_to_latex(s: str) -> str:
57
+ tokens = s.split("*")
58
+ result = []
59
+ for tok in tokens:
60
+ if "^" in tok:
61
+ base, exp = tok.split("^", 1)
62
+ result.append(rf"\mathrm{{{base}}}^{{{exp}}}")
63
+ else:
64
+ result.append(rf"\mathrm{{{tok}}}")
65
+ return r"\," .join(result)
66
+
67
+
68
+ def _sci_to_unicode(value: float, num_spec: str) -> str:
69
+ """Format value with possible scientific notation in Unicode style"""
70
+ formatted = _format_number(value, num_spec)
71
+ # Check for e-notation
72
+ if "e" in formatted or "E" in formatted:
73
+ mantissa, exp_part = re.split(r"[eE]", formatted, maxsplit=1)
74
+ exp_int = int(exp_part)
75
+ return f"{mantissa} \u00d7 10{_to_superscript(str(exp_int))}"
76
+ return formatted
77
+
78
+
79
+ def format_plain(value: float, unit_str: str, num_spec: str) -> str:
80
+ return f"{_format_number(value, num_spec)} {unit_str}"
81
+
82
+
83
+ def format_unicode(value: float, unit_str: str, num_spec: str) -> str:
84
+ num_part = _sci_to_unicode(value, num_spec)
85
+ unit_part = _unit_to_unicode(unit_str)
86
+ return f"{num_part} {unit_part}"
87
+
88
+
89
+ def format_latex(value: float, unit_str: str, num_spec: str) -> str:
90
+ formatted = _format_number(value, num_spec)
91
+ if "e" in formatted or "E" in formatted:
92
+ mantissa, exp_part = re.split(r"[eE]", formatted, maxsplit=1)
93
+ exp_int = int(exp_part)
94
+ num_part = rf"{mantissa} \times 10^{{{exp_int}}}"
95
+ else:
96
+ num_part = formatted
97
+ unit_part = _unit_to_latex(unit_str)
98
+ return rf"{num_part}\,{unit_part}"
99
+
100
+
101
+ def format_html(value: float, unit_str: str, num_spec: str) -> str:
102
+ inner = format_unicode(value, unit_str, num_spec)
103
+ return f"<span>{inner}</span>"
104
+
105
+
106
+ def apply_format(value: float, unit_str: str, format_spec: str) -> str:
107
+ num_spec, mode = _split_spec(format_spec)
108
+ if mode == "P":
109
+ return format_unicode(value, unit_str, num_spec)
110
+ if mode == "L":
111
+ return format_latex(value, unit_str, num_spec)
112
+ if mode == "H":
113
+ return format_html(value, unit_str, num_spec)
114
+ return format_plain(value, unit_str, num_spec)