python-units 0.3.0__py3-none-any.whl → 0.4.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.
api/public.py CHANGED
@@ -5,20 +5,40 @@ from api.si import (
5
5
  ampere,
6
6
  becquerel,
7
7
  candela,
8
+ centimetre,
8
9
  coulomb,
9
10
  degree_celcius,
10
11
  farad,
12
+ gram,
11
13
  gray,
12
14
  henry,
13
15
  hertz,
16
+ hour,
14
17
  joule,
15
18
  katal,
16
19
  kelvin,
17
20
  kilogram,
21
+ kiloampere,
22
+ kilometre,
23
+ kilovolt,
24
+ kilowatt,
18
25
  lumen,
19
26
  lux,
27
+ megawatt,
20
28
  metre,
29
+ microgram,
30
+ micrometre,
31
+ microsecond,
32
+ milliampere,
33
+ milligram,
34
+ millimetre,
35
+ millisecond,
36
+ millivolt,
37
+ milliwatt,
38
+ minute,
21
39
  mole,
40
+ nanometre,
41
+ nanosecond,
22
42
  newton,
23
43
  ohm,
24
44
  pascal,
@@ -28,6 +48,7 @@ from api.si import (
28
48
  sievert,
29
49
  steradian,
30
50
  tesla,
51
+ tonne,
31
52
  volt,
32
53
  watt,
33
54
  weber,
@@ -43,12 +64,16 @@ from core.quantity import (
43
64
  Quantity,
44
65
  complex_quantity,
45
66
  complex_unit,
67
+ convert,
46
68
  float_quantity,
47
69
  float_unit,
48
70
  int_quantity,
49
71
  int_unit,
50
72
  long_quantity,
51
73
  long_unit,
74
+ multiplier,
75
+ unit,
76
+ value,
52
77
  )
53
78
  from core.unit_definitions import BaseUnit, CustomUnitBase, DerivedUnit, SIUnit
54
79
  from models.dimension import Dimension, DimensionSystem
@@ -73,28 +98,50 @@ __all__ = [
73
98
  "ampere",
74
99
  "becquerel",
75
100
  "candela",
101
+ "centimetre",
76
102
  "complex_quantity",
77
103
  "complex_unit",
104
+ "convert",
78
105
  "coulomb",
79
106
  "degree_celcius",
80
107
  "farad",
81
108
  "float_quantity",
82
109
  "float_unit",
110
+ "gram",
83
111
  "gray",
84
112
  "henry",
85
113
  "hertz",
114
+ "hour",
86
115
  "int_quantity",
87
116
  "int_unit",
88
117
  "joule",
89
118
  "katal",
90
119
  "kelvin",
91
120
  "kilogram",
121
+ "kiloampere",
122
+ "kilometre",
123
+ "kilovolt",
124
+ "kilowatt",
92
125
  "long_quantity",
93
126
  "long_unit",
94
127
  "lumen",
95
128
  "lux",
129
+ "megawatt",
96
130
  "metre",
131
+ "microgram",
132
+ "micrometre",
133
+ "microsecond",
134
+ "milliampere",
135
+ "milligram",
136
+ "millimetre",
137
+ "millisecond",
138
+ "millivolt",
139
+ "milliwatt",
140
+ "minute",
141
+ "multiplier",
97
142
  "mole",
143
+ "nanometre",
144
+ "nanosecond",
98
145
  "newton",
99
146
  "ohm",
100
147
  "pascal",
@@ -104,6 +151,9 @@ __all__ = [
104
151
  "sievert",
105
152
  "steradian",
106
153
  "tesla",
154
+ "tonne",
155
+ "unit",
156
+ "value",
107
157
  "volt",
108
158
  "watt",
109
159
  "weber",
api/si.py CHANGED
@@ -29,7 +29,11 @@ siemens = DerivedUnit.define("S", ampere / volt)
29
29
  weber = DerivedUnit.define("Wb", volt * second)
30
30
  tesla = DerivedUnit.define("T", weber / metre / metre)
31
31
  henry = DerivedUnit.define("H", weber / ampere)
32
- degree_celcius = DerivedUnit.define("°C", kelvin)
32
+ degree_celcius = DerivedUnit.define(
33
+ "°C",
34
+ kelvin,
35
+ supports_multiplicative_conversion=False,
36
+ )
33
37
  lumen = DerivedUnit.define("lm", candela * steradian)
34
38
  lux = DerivedUnit.define("lx", lumen / metre / metre)
35
39
  becquerel = DerivedUnit.define("Bq", SIUnit() / second)
@@ -37,6 +41,31 @@ gray = DerivedUnit.define("Gy", joule / kilogram)
37
41
  sievert = DerivedUnit.define("Sv", joule / kilogram)
38
42
  katal = DerivedUnit.define("kat", mole / second)
39
43
 
44
+ kilometre = DerivedUnit.define("km", metre, conversion_factor=1000.0)
45
+ centimetre = DerivedUnit.define("cm", metre, conversion_factor=0.01)
46
+ millimetre = DerivedUnit.define("mm", metre, conversion_factor=0.001)
47
+ micrometre = DerivedUnit.define("µm", metre, conversion_factor=0.000001)
48
+ nanometre = DerivedUnit.define("nm", metre, conversion_factor=0.000000001)
49
+
50
+ gram = DerivedUnit.define("g", kilogram, conversion_factor=0.001)
51
+ milligram = DerivedUnit.define("mg", kilogram, conversion_factor=0.000001)
52
+ microgram = DerivedUnit.define("µg", kilogram, conversion_factor=0.000000001)
53
+ tonne = DerivedUnit.define("t", kilogram, conversion_factor=1000.0)
54
+
55
+ minute = DerivedUnit.define("min", second, conversion_factor=60.0)
56
+ hour = DerivedUnit.define("h", second, conversion_factor=3600.0)
57
+ millisecond = DerivedUnit.define("ms", second, conversion_factor=0.001)
58
+ microsecond = DerivedUnit.define("µs", second, conversion_factor=0.000001)
59
+ nanosecond = DerivedUnit.define("ns", second, conversion_factor=0.000000001)
60
+
61
+ milliampere = DerivedUnit.define("mA", ampere, conversion_factor=0.001)
62
+ kiloampere = DerivedUnit.define("kA", ampere, conversion_factor=1000.0)
63
+ millivolt = DerivedUnit.define("mV", volt, conversion_factor=0.001)
64
+ kilovolt = DerivedUnit.define("kV", volt, conversion_factor=1000.0)
65
+ milliwatt = DerivedUnit.define("mW", watt, conversion_factor=0.001)
66
+ kilowatt = DerivedUnit.define("kW", watt, conversion_factor=1000.0)
67
+ megawatt = DerivedUnit.define("MW", watt, conversion_factor=1000000.0)
68
+
40
69
  for unit in (
41
70
  newton,
42
71
  pascal,
@@ -59,20 +88,40 @@ __all__ = [
59
88
  "ampere",
60
89
  "becquerel",
61
90
  "candela",
91
+ "centimetre",
62
92
  "coulomb",
63
93
  "degree_celcius",
64
94
  "farad",
95
+ "gram",
65
96
  "gray",
66
97
  "henry",
67
98
  "hertz",
99
+ "hour",
68
100
  "joule",
69
101
  "katal",
70
102
  "kelvin",
71
103
  "kilogram",
104
+ "kiloampere",
105
+ "kilometre",
106
+ "kilovolt",
107
+ "kilowatt",
72
108
  "lumen",
73
109
  "lux",
110
+ "megawatt",
74
111
  "metre",
112
+ "microgram",
113
+ "micrometre",
114
+ "microsecond",
115
+ "milliampere",
116
+ "milligram",
117
+ "millimetre",
118
+ "millisecond",
119
+ "millivolt",
120
+ "milliwatt",
121
+ "minute",
75
122
  "mole",
123
+ "nanometre",
124
+ "nanosecond",
76
125
  "newton",
77
126
  "ohm",
78
127
  "pascal",
@@ -82,6 +131,7 @@ __all__ = [
82
131
  "sievert",
83
132
  "steradian",
84
133
  "tesla",
134
+ "tonne",
85
135
  "volt",
86
136
  "watt",
87
137
  "weber",
core/__init__.py CHANGED
@@ -11,12 +11,16 @@ from .quantity import (
11
11
  Quantity,
12
12
  complex_quantity,
13
13
  complex_unit,
14
+ convert,
14
15
  float_quantity,
15
16
  float_unit,
16
17
  int_quantity,
17
18
  int_unit,
18
19
  long_quantity,
19
20
  long_unit,
21
+ multiplier,
22
+ unit,
23
+ value,
20
24
  )
21
25
  from .unit_definitions import BaseUnit, CustomUnitBase, DerivedUnit, SIUnit
22
26
 
@@ -33,10 +37,14 @@ __all__ = [
33
37
  "UnitsError",
34
38
  "complex_quantity",
35
39
  "complex_unit",
40
+ "convert",
36
41
  "float_quantity",
37
42
  "float_unit",
38
43
  "int_quantity",
39
44
  "int_unit",
40
45
  "long_quantity",
41
46
  "long_unit",
47
+ "multiplier",
48
+ "unit",
49
+ "value",
42
50
  ]
core/quantity.py CHANGED
@@ -5,7 +5,13 @@ from __future__ import annotations
5
5
 
6
6
  from core.deprecations import deprecated_call
7
7
  from core.errors import InvalidValueError, UnitCompatibilityError, UnitOperandError
8
- from core.unit_definitions import BaseUnit, SIUnit, clone_unit
8
+ from core.unit_definitions import (
9
+ BaseUnit,
10
+ DerivedUnit,
11
+ SIUnit,
12
+ clone_unit,
13
+ require_unit_instance,
14
+ )
9
15
  from models.dimension import SI_DIMENSION_SYSTEM
10
16
  from utils.numbers import Scalar, is_number, is_real_number, validate_numeric_value
11
17
 
@@ -18,6 +24,49 @@ def require_quantity_operand(operand: object, operation: str) -> None:
18
24
  )
19
25
 
20
26
 
27
+ def normalize_scalar(value: Scalar) -> Scalar:
28
+ """Return ``int`` for exact integer floats, otherwise return ``value``."""
29
+ if isinstance(value, float) and value.is_integer():
30
+ return int(value)
31
+ return value
32
+
33
+
34
+ def normalize_product(value: Scalar, left: Scalar, right: Scalar) -> Scalar:
35
+ """Normalize exact integer products only when both inputs were integers."""
36
+ if isinstance(left, int) and isinstance(right, int):
37
+ return normalize_scalar(value)
38
+ return value
39
+
40
+
41
+ def normalize_reverse_floor(value: Scalar, denominator: "Quantity") -> Scalar:
42
+ """Preserve legacy int display for unscaled integer reverse floor division."""
43
+ if (
44
+ denominator.unit.conversion_factor == 1.0
45
+ and isinstance(denominator.value, int)
46
+ ):
47
+ return normalize_scalar(value)
48
+ return value
49
+
50
+
51
+ def normalize_result_unit(result_unit: BaseUnit) -> BaseUnit:
52
+ """Return a renderable result unit with no hidden anonymous scale."""
53
+ if isinstance(result_unit, DerivedUnit) or result_unit.conversion_factor == 1.0:
54
+ return result_unit
55
+ try:
56
+ return result_unit.__class__(
57
+ dimension=result_unit.dimension,
58
+ supports_multiplicative_conversion=(
59
+ result_unit.supports_multiplicative_conversion
60
+ ),
61
+ )
62
+ except TypeError:
63
+ unit = result_unit.__class__(dimension=result_unit.dimension)
64
+ unit._supports_multiplicative_conversion = (
65
+ result_unit.supports_multiplicative_conversion
66
+ )
67
+ return unit
68
+
69
+
21
70
  class Quantity:
22
71
  """A numeric value coupled to a unit definition."""
23
72
 
@@ -81,6 +130,44 @@ class Quantity:
81
130
  "unsupported scalar for {}: {}".format(operation, type(value).__name__)
82
131
  )
83
132
 
133
+ def _base_value(self) -> Scalar:
134
+ return self.value * self.unit.conversion_factor
135
+
136
+ def to(self, target_unit: BaseUnit) -> "Quantity":
137
+ """
138
+ Convert this quantity to a compatible scale-only target unit.
139
+
140
+ Args:
141
+ target_unit: Unit definition with the same dimension.
142
+
143
+ Returns:
144
+ Quantity expressed in ``target_unit``.
145
+
146
+ Raises:
147
+ InvalidUnitError: If ``target_unit`` is not a unit definition.
148
+ UnitCompatibilityError: If dimensions differ or either unit cannot
149
+ be converted with a multiplicative scale factor.
150
+ """
151
+ require_unit_instance(target_unit)
152
+ if self.unit.dimension != target_unit.dimension:
153
+ raise UnitCompatibilityError(
154
+ "cannot convert {} to {}".format(self.unit, target_unit)
155
+ )
156
+ if (
157
+ not self.unit.supports_multiplicative_conversion
158
+ or not target_unit.supports_multiplicative_conversion
159
+ ):
160
+ raise UnitCompatibilityError(
161
+ "units require a non-multiplicative conversion: {} and {}".format(
162
+ self.unit,
163
+ target_unit,
164
+ )
165
+ )
166
+ return self.__class__(
167
+ normalize_scalar(self._base_value() / target_unit.conversion_factor),
168
+ target_unit,
169
+ )
170
+
84
171
  def __add__(self, quantity2: object) -> "Quantity":
85
172
  self._require_compatible_quantity(quantity2, "addition")
86
173
  return self.__class__(self.value + quantity2.value, self.unit)
@@ -98,9 +185,22 @@ class Quantity:
98
185
 
99
186
  def __mul__(self, quantity2: object) -> "Quantity":
100
187
  if isinstance(quantity2, Quantity):
101
- return self.__class__(self.value * quantity2.value, self.unit * quantity2.unit)
188
+ result_unit = self.unit * quantity2.unit
189
+ result_value = self._base_value() * quantity2._base_value()
190
+ return self.__class__(
191
+ normalize_product(result_value, self.value, quantity2.value),
192
+ normalize_result_unit(result_unit),
193
+ )
102
194
  if isinstance(quantity2, BaseUnit):
103
- return self.__class__(self.value, self.unit * quantity2)
195
+ result_unit = self.unit * quantity2
196
+ result_value = (
197
+ self._base_value()
198
+ * quantity2.conversion_factor
199
+ )
200
+ return self.__class__(
201
+ normalize_scalar(result_value),
202
+ normalize_result_unit(result_unit),
203
+ )
104
204
  self._require_numeric_scalar(quantity2, "multiplication")
105
205
  return self.__class__(self.value * quantity2, self.unit)
106
206
 
@@ -109,29 +209,55 @@ class Quantity:
109
209
 
110
210
  def __truediv__(self, quantity2: object) -> "Quantity":
111
211
  if isinstance(quantity2, Quantity):
112
- return self.__class__(self.value / quantity2.value, self.unit / quantity2.unit)
212
+ result_unit = self.unit / quantity2.unit
213
+ result_value = self._base_value() / quantity2._base_value()
214
+ return self.__class__(result_value, normalize_result_unit(result_unit))
113
215
  if isinstance(quantity2, BaseUnit):
114
- return self.__class__(self.value, self.unit / quantity2)
216
+ result_unit = self.unit / quantity2
217
+ result_value = (
218
+ self._base_value()
219
+ / quantity2.conversion_factor
220
+ )
221
+ return self.__class__(
222
+ normalize_scalar(result_value),
223
+ normalize_result_unit(result_unit),
224
+ )
115
225
  self._require_numeric_scalar(quantity2, "division")
116
226
  return self.__class__(self.value / quantity2, self.unit)
117
227
 
118
228
  def __rtruediv__(self, quantity2: object) -> "Quantity":
119
229
  if isinstance(quantity2, Quantity):
120
- return self.__class__(quantity2.value / self.value, quantity2.unit / self.unit)
230
+ result_unit = quantity2.unit / self.unit
231
+ result_value = quantity2._base_value() / self._base_value()
232
+ return self.__class__(result_value, normalize_result_unit(result_unit))
121
233
  self._require_numeric_scalar(quantity2, "division")
122
- return self.__class__(quantity2 / self.value, self._dimensionless_unit() / self.unit)
234
+ result_unit = self._dimensionless_unit() / self.unit
235
+ result_value = quantity2 / self._base_value()
236
+ return self.__class__(result_value, normalize_result_unit(result_unit))
123
237
 
124
238
  def __floordiv__(self, quantity2: object) -> "Quantity":
125
239
  if isinstance(quantity2, Quantity):
126
- return self.__class__(self.value // quantity2.value, self.unit / quantity2.unit)
240
+ result_unit = self.unit / quantity2.unit
241
+ result_value = self._base_value() // quantity2._base_value()
242
+ return self.__class__(result_value, normalize_result_unit(result_unit))
127
243
  self._require_real_scalar(quantity2, "floor division")
128
244
  return self.__class__(self.value // quantity2, self.unit)
129
245
 
130
246
  def __rfloordiv__(self, quantity2: object) -> "Quantity":
131
247
  if isinstance(quantity2, Quantity):
132
- return self.__class__(quantity2.value // self.value, quantity2.unit / self.unit)
248
+ result_unit = quantity2.unit / self.unit
249
+ result_value = quantity2._base_value() // self._base_value()
250
+ return self.__class__(
251
+ normalize_scalar(result_value),
252
+ normalize_result_unit(result_unit),
253
+ )
133
254
  self._require_real_scalar(quantity2, "floor division")
134
- return self.__class__(quantity2 // self.value, self._dimensionless_unit() / self.unit)
255
+ result_unit = self._dimensionless_unit() / self.unit
256
+ result_value = quantity2 // self._base_value()
257
+ return self.__class__(
258
+ normalize_reverse_floor(result_value, self),
259
+ normalize_result_unit(result_unit),
260
+ )
135
261
 
136
262
  def __mod__(self, quantity2: object) -> "Quantity":
137
263
  if isinstance(quantity2, Quantity):
@@ -145,7 +271,12 @@ class Quantity:
145
271
  self._require_compatible_quantity(quantity2, "modulo")
146
272
  return self.__class__(quantity2.value % self.value, self.unit)
147
273
  self._require_real_scalar(quantity2, "modulo")
148
- return self.__class__(quantity2 % self.value, self._dimensionless_unit() / self.unit)
274
+ result_unit = self._dimensionless_unit() / self.unit
275
+ result_value = quantity2 % self._base_value()
276
+ return self.__class__(
277
+ normalize_scalar(result_value),
278
+ normalize_result_unit(result_unit),
279
+ )
149
280
 
150
281
  def __divmod__(self, quantity2: object) -> tuple["Quantity", "Quantity"]:
151
282
  return self.__floordiv__(quantity2), self.__mod__(quantity2)
@@ -163,7 +294,12 @@ class Quantity:
163
294
  )
164
295
  if self.is_unitless:
165
296
  return self.__class__(self.value ** exponent, self.unit)
166
- return self.__class__(self.value ** exponent, self.unit ** exponent)
297
+ result_unit = self.unit ** exponent
298
+ result_value = self._base_value() ** exponent
299
+ return self.__class__(
300
+ normalize_scalar(result_value),
301
+ normalize_result_unit(result_unit),
302
+ )
167
303
 
168
304
  def __neg__(self) -> "Quantity":
169
305
  return self.__class__(-self.value, self.unit)
@@ -215,6 +351,84 @@ def complex_quantity(quantity: Quantity) -> Quantity:
215
351
  return Quantity(complex(quantity.value), quantity.unit)
216
352
 
217
353
 
354
+ def convert(quantity: Quantity, target_unit: BaseUnit) -> Quantity:
355
+ """
356
+ Convert a quantity to a compatible scale-only target unit.
357
+
358
+ Args:
359
+ quantity: Quantity to convert.
360
+ target_unit: Unit definition with the same dimension.
361
+
362
+ Returns:
363
+ Quantity expressed in ``target_unit``.
364
+
365
+ Raises:
366
+ UnitOperandError: If ``quantity`` is not a Quantity.
367
+ InvalidUnitError: If ``target_unit`` is not a unit definition.
368
+ UnitCompatibilityError: If conversion is not scale-only compatible.
369
+ """
370
+ require_quantity_operand(quantity, "conversion")
371
+ return quantity.to(target_unit)
372
+
373
+
374
+ def value(quantity: Quantity) -> Scalar:
375
+ """
376
+ Return the numeric value of a quantity without converting it.
377
+
378
+ Args:
379
+ quantity: Quantity to inspect.
380
+
381
+ Returns:
382
+ Numeric value stored on the quantity.
383
+
384
+ Raises:
385
+ UnitOperandError: If ``quantity`` is not a Quantity.
386
+ """
387
+ require_quantity_operand(quantity, "value extraction")
388
+ return quantity.value
389
+
390
+
391
+ def unit(quantity: Quantity) -> BaseUnit:
392
+ """
393
+ Return the unit definition of a quantity.
394
+
395
+ Args:
396
+ quantity: Quantity to inspect.
397
+
398
+ Returns:
399
+ Unit definition stored on the quantity.
400
+
401
+ Raises:
402
+ UnitOperandError: If ``quantity`` is not a Quantity.
403
+ """
404
+ require_quantity_operand(quantity, "unit extraction")
405
+ return quantity.unit
406
+
407
+
408
+ def multiplier(quantity_or_unit: Quantity | BaseUnit) -> float:
409
+ """
410
+ Return the multiplicative factor to the canonical base dimension.
411
+
412
+ Args:
413
+ quantity_or_unit: Quantity or unit definition to inspect.
414
+
415
+ Returns:
416
+ Multiplicative factor for the unit carried by the input.
417
+
418
+ Raises:
419
+ UnitOperandError: If the input is neither Quantity nor BaseUnit.
420
+ """
421
+ if isinstance(quantity_or_unit, Quantity):
422
+ return quantity_or_unit.unit.conversion_factor
423
+ if isinstance(quantity_or_unit, BaseUnit):
424
+ return quantity_or_unit.conversion_factor
425
+ raise UnitOperandError(
426
+ "unsupported operand for multiplier extraction: {}".format(
427
+ type(quantity_or_unit).__name__
428
+ )
429
+ )
430
+
431
+
218
432
  def int_unit(quantity: Quantity) -> Quantity:
219
433
  """Legacy compatibility helper equivalent to ``int_quantity``."""
220
434
  return deprecated_call(
core/unit_definitions.py CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
- from numbers import Number
6
+ from numbers import Number, Real
7
7
  from types import MappingProxyType
8
8
  from typing import Dict, Mapping
9
9
 
@@ -36,13 +36,50 @@ def require_unit_instance(unit: object) -> None:
36
36
  )
37
37
 
38
38
 
39
+ def validate_conversion_factor(conversion_factor: object) -> float:
40
+ """Validate a multiplicative conversion factor and return it as float."""
41
+ if (
42
+ not isinstance(conversion_factor, Real)
43
+ or isinstance(conversion_factor, bool)
44
+ or conversion_factor <= 0
45
+ ):
46
+ raise InvalidValueError(
47
+ "conversion factor must be a positive real scalar, got {}".format(
48
+ type(conversion_factor).__name__
49
+ )
50
+ )
51
+ return float(conversion_factor)
52
+
53
+
54
+ def validate_conversion_support(supports_conversion: object) -> bool:
55
+ """Validate a conversion support flag and return it."""
56
+ if not isinstance(supports_conversion, bool):
57
+ raise InvalidValueError(
58
+ "conversion support flag must be bool, got {}".format(
59
+ type(supports_conversion).__name__
60
+ )
61
+ )
62
+ return supports_conversion
63
+
64
+
39
65
  def clone_unit(unit: "BaseUnit | None") -> "BaseUnit":
40
66
  """Clone a unit definition while preserving its type."""
41
67
  if unit is None:
42
68
  return SIUnit()
43
69
  require_unit_instance(unit)
44
70
 
45
- cloned_unit = unit.__class__(dimension=unit.dimension)
71
+ try:
72
+ cloned_unit = unit.__class__(
73
+ dimension=unit.dimension,
74
+ conversion_factor=unit.conversion_factor,
75
+ supports_multiplicative_conversion=unit.supports_multiplicative_conversion,
76
+ )
77
+ except TypeError:
78
+ cloned_unit = unit.__class__(dimension=unit.dimension)
79
+ cloned_unit._conversion_factor = unit.conversion_factor
80
+ cloned_unit._supports_multiplicative_conversion = (
81
+ unit.supports_multiplicative_conversion
82
+ )
46
83
  if isinstance(unit, DerivedUnit):
47
84
  cloned_unit.name = unit.name
48
85
  return cloned_unit
@@ -61,11 +98,20 @@ class BaseUnit:
61
98
 
62
99
  dimension_system = SI_DIMENSION_SYSTEM
63
100
 
64
- def __init__(self, dimension: Dimension | None = None) -> None:
101
+ def __init__(
102
+ self,
103
+ dimension: Dimension | None = None,
104
+ conversion_factor: float = 1.0,
105
+ supports_multiplicative_conversion: bool = True,
106
+ ) -> None:
65
107
  self._dimension = dimension or Dimension(
66
108
  system=self.dimension_system,
67
109
  exponents=(0,) * len(self.dimension_system.symbols),
68
110
  )
111
+ self._conversion_factor = validate_conversion_factor(conversion_factor)
112
+ self._supports_multiplicative_conversion = validate_conversion_support(
113
+ supports_multiplicative_conversion
114
+ )
69
115
 
70
116
  @property
71
117
  def unit_dict(self) -> Dict[str, int]:
@@ -85,16 +131,32 @@ class BaseUnit:
85
131
  def dimension(self, dimension: Dimension) -> None:
86
132
  self._dimension = dimension
87
133
 
134
+ @property
135
+ def conversion_factor(self) -> float:
136
+ """Return the multiplicative factor to this dimension's base unit."""
137
+ return self._conversion_factor
138
+
139
+ @property
140
+ def supports_multiplicative_conversion(self) -> bool:
141
+ """Return whether this unit can use scale-only conversion."""
142
+ return self._supports_multiplicative_conversion
143
+
88
144
  def __eq__(self, unit2: object) -> bool:
89
145
  if not isinstance(unit2, BaseUnit):
90
146
  return False
91
147
  if self.dimension != unit2.dimension:
92
148
  return False
149
+ if self.conversion_factor != unit2.conversion_factor:
150
+ return False
93
151
  if isinstance(self, DerivedUnit) or isinstance(unit2, DerivedUnit):
94
152
  return (
95
153
  isinstance(self, DerivedUnit)
96
154
  and isinstance(unit2, DerivedUnit)
97
155
  and self.name == unit2.name
156
+ and (
157
+ self.supports_multiplicative_conversion
158
+ == unit2.supports_multiplicative_conversion
159
+ )
98
160
  )
99
161
  return True
100
162
 
@@ -109,15 +171,48 @@ class BaseUnit:
109
171
  )
110
172
  if operator_name == "mul":
111
173
  dimension = self.dimension * unit2.dimension
174
+ conversion_factor = self.conversion_factor * unit2.conversion_factor
112
175
  else:
113
176
  dimension = self.dimension / unit2.dimension
177
+ conversion_factor = self.conversion_factor / unit2.conversion_factor
178
+ supports_conversion = (
179
+ self.supports_multiplicative_conversion
180
+ and unit2.supports_multiplicative_conversion
181
+ )
114
182
  if dimension.system != SI_DIMENSION_SYSTEM:
115
- return self.__class__(dimension=dimension)
116
- return resolve_unit(dimension)
183
+ return self.__class__(
184
+ dimension=dimension,
185
+ conversion_factor=conversion_factor,
186
+ supports_multiplicative_conversion=supports_conversion,
187
+ )
188
+ if supports_conversion and conversion_factor == 1.0:
189
+ return resolve_unit(dimension)
190
+ return BaseUnit(
191
+ dimension=dimension,
192
+ conversion_factor=conversion_factor,
193
+ supports_multiplicative_conversion=supports_conversion,
194
+ )
117
195
 
118
196
  def _quantity_from_scalar(self, value: Number) -> object:
119
- from core.quantity import Quantity
120
-
197
+ from core.quantity import Quantity, normalize_scalar
198
+
199
+ if not isinstance(self, DerivedUnit) and self.conversion_factor != 1.0:
200
+ try:
201
+ unit = self.__class__(
202
+ dimension=self.dimension,
203
+ supports_multiplicative_conversion=(
204
+ self.supports_multiplicative_conversion
205
+ ),
206
+ )
207
+ except TypeError:
208
+ unit = self.__class__(dimension=self.dimension)
209
+ unit._supports_multiplicative_conversion = (
210
+ self.supports_multiplicative_conversion
211
+ )
212
+ result_value = value * self.conversion_factor
213
+ if self.conversion_factor.is_integer():
214
+ result_value = normalize_scalar(result_value)
215
+ return Quantity(result_value, unit)
121
216
  return Quantity(value, self)
122
217
 
123
218
  def __mul__(self, unit2: object) -> object:
@@ -152,13 +247,37 @@ class BaseUnit:
152
247
  system=self.dimension.system,
153
248
  exponents=tuple(value * exponent for value in self.dimension.exponents),
154
249
  )
155
- if isinstance(self, DerivedUnit) and self.name and exponent != 1:
156
- derived = DerivedUnit(dimension=dimension)
250
+ conversion_factor = self.conversion_factor**exponent
251
+ if (
252
+ isinstance(self, DerivedUnit)
253
+ and self.name
254
+ and exponent != 1
255
+ and self.conversion_factor == 1.0
256
+ ):
257
+ derived = DerivedUnit(
258
+ dimension=dimension,
259
+ conversion_factor=1.0,
260
+ supports_multiplicative_conversion=(
261
+ self.supports_multiplicative_conversion
262
+ ),
263
+ )
157
264
  derived.name = "{}^{}".format(self.name, exponent)
158
265
  return derived
159
266
  if dimension.system != SI_DIMENSION_SYSTEM:
160
- return self.__class__(dimension=dimension)
161
- return resolve_unit(dimension)
267
+ return self.__class__(
268
+ dimension=dimension,
269
+ conversion_factor=conversion_factor,
270
+ supports_multiplicative_conversion=(
271
+ self.supports_multiplicative_conversion
272
+ ),
273
+ )
274
+ if self.supports_multiplicative_conversion and conversion_factor == 1.0:
275
+ return resolve_unit(dimension)
276
+ return BaseUnit(
277
+ dimension=dimension,
278
+ conversion_factor=conversion_factor,
279
+ supports_multiplicative_conversion=self.supports_multiplicative_conversion,
280
+ )
162
281
 
163
282
  def __str__(self) -> str:
164
283
  return self.dimension.render()
@@ -168,7 +287,12 @@ class SIUnit(BaseUnit):
168
287
  """Template class for SI units."""
169
288
 
170
289
  @classmethod
171
- def define(cls, key: str, value: int = 1) -> "SIUnit":
290
+ def define(
291
+ cls,
292
+ key: str,
293
+ value: int = 1,
294
+ conversion_factor: float = 1.0,
295
+ ) -> "SIUnit":
172
296
  """Define an SI unit or exponentiated SI dimension."""
173
297
  if key not in cls.dimension_system.symbols:
174
298
  raise InvalidUnitError("unknown SI unit key: {}".format(key))
@@ -176,7 +300,10 @@ class SIUnit(BaseUnit):
176
300
  raise InvalidValueError(
177
301
  "unit exponent must be an integer, got {}".format(type(value).__name__)
178
302
  )
179
- return cls(dimension=Dimension.from_mapping({key: value}, system=cls.dimension_system))
303
+ return cls(
304
+ dimension=Dimension.from_mapping({key: value}, system=cls.dimension_system),
305
+ conversion_factor=conversion_factor,
306
+ )
180
307
 
181
308
 
182
309
  class CustomUnitBase(BaseUnit):
@@ -185,7 +312,12 @@ class CustomUnitBase(BaseUnit):
185
312
  dimension_system = DimensionSystem("custom", ())
186
313
 
187
314
  @classmethod
188
- def define(cls, key: str, value: int = 1) -> "CustomUnitBase":
315
+ def define(
316
+ cls,
317
+ key: str,
318
+ value: int = 1,
319
+ conversion_factor: float = 1.0,
320
+ ) -> "CustomUnitBase":
189
321
  """Define a custom base unit within the subclass dimension system."""
190
322
  if key not in cls.dimension_system.symbols:
191
323
  raise InvalidUnitError("unknown custom unit key: {}".format(key))
@@ -193,7 +325,10 @@ class CustomUnitBase(BaseUnit):
193
325
  raise InvalidValueError(
194
326
  "unit exponent must be an integer, got {}".format(type(value).__name__)
195
327
  )
196
- return cls(dimension=Dimension.from_mapping({key: value}, system=cls.dimension_system))
328
+ return cls(
329
+ dimension=Dimension.from_mapping({key: value}, system=cls.dimension_system),
330
+ conversion_factor=conversion_factor,
331
+ )
197
332
 
198
333
 
199
334
  class DerivedUnit(BaseUnit):
@@ -218,10 +353,28 @@ class DerivedUnit(BaseUnit):
218
353
  return super().__str__()
219
354
 
220
355
  @classmethod
221
- def define(cls, name: str, unit: BaseUnit) -> "DerivedUnit":
356
+ def define(
357
+ cls,
358
+ name: str,
359
+ unit: BaseUnit,
360
+ conversion_factor: float | None = None,
361
+ supports_multiplicative_conversion: bool | None = None,
362
+ ) -> "DerivedUnit":
222
363
  """Define a named derived unit."""
223
364
  require_unit_instance(unit)
224
- obj = cls(dimension=unit.dimension)
365
+ obj = cls(
366
+ dimension=unit.dimension,
367
+ conversion_factor=(
368
+ unit.conversion_factor
369
+ if conversion_factor is None
370
+ else conversion_factor
371
+ ),
372
+ supports_multiplicative_conversion=(
373
+ unit.supports_multiplicative_conversion
374
+ if supports_multiplicative_conversion is None
375
+ else supports_multiplicative_conversion
376
+ ),
377
+ )
225
378
  obj.name = name
226
379
  return obj
227
380
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-units
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Python library to represent numbers with units
5
5
  Author-email: "Paul K. Korir, PhD" <paul.korir@gmail.com>
6
6
  License-Expression: GPL-3.0-or-later
@@ -30,7 +30,74 @@ Dynamic: license-file
30
30
  [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://pypi.org/project/python-units/)
31
31
  [![Coverage 92%](https://img.shields.io/badge/coverage-92%25-brightgreen.svg)](/Users/paulkorir/PycharmProjects/python-units/tests/unit/test_units.py)
32
32
 
33
- Python library to represent quantities with units.
33
+ # The Price of Unitless Arithmetic
34
+
35
+ On September 23, 1999, flight controllers expected NASA's Mars Climate Orbiter
36
+ to pass behind Mars, fire its engine, and come back into radio contact after
37
+ orbit insertion. It never came back. When engineers reviewed the final hours of
38
+ flight data, the trajectory was not where the navigation system thought it was:
39
+ the spacecraft had approached Mars far lower than planned. The investigation
40
+ traced the loss to a unit boundary that software had failed to defend. One side
41
+ of the system handled "small forces" data in English units; the navigation side
42
+ expected metric units.
43
+
44
+ That is the kind of bug this package is meant to stop. Without units, the
45
+ mistake is just arithmetic:
46
+
47
+ ```python
48
+ # A navigation routine expects impulse in newton-seconds.
49
+ expected_impulse_ns = 120
50
+
51
+ # A supplier routine accidentally sends a value in a different force unit.
52
+ # The number is still just a number, so Python accepts it.
53
+ reported_impulse_other_units = 120
54
+
55
+ trajectory_impulse = expected_impulse_ns + reported_impulse_other_units
56
+ print(trajectory_impulse) # 240
57
+ ```
58
+
59
+ There is nothing in `240` that tells you a spacecraft trajectory may now be
60
+ wrong. With units attached, the mismatch stops at the boundary:
61
+
62
+ ```python
63
+ from units import CustomUnitBase
64
+ from units.dimension import DimensionSystem
65
+ from units.si import newton, second
66
+
67
+ class EnglishImpulseUnit(CustomUnitBase):
68
+ dimension_system = DimensionSystem("english-impulse", ("lbf_s",))
69
+
70
+ pound_force_second = EnglishImpulseUnit.define("lbf_s")
71
+
72
+ expected_impulse = 120 * newton * second
73
+ reported_impulse = 120 * pound_force_second
74
+
75
+ trajectory_impulse = expected_impulse + reported_impulse
76
+ # UnitCompatibilityError: units mismatch: m·kg·s^-1 and lbf_s
77
+ ```
78
+
79
+ That failure is the feature. A bug that would otherwise move through a program
80
+ as an ordinary number is stopped before it contaminates mission-critical
81
+ calculations.
82
+
83
+ Background: NASA/JPL describe the Mars Climate Orbiter loss as a navigation
84
+ error caused by a failure to translate English units to metric, sending the
85
+ spacecraft too close to Mars.
86
+
87
+ Source: https://en.wikipedia.org/wiki/Mars_Climate_Orbiter
88
+
89
+ # About
90
+
91
+ `python-units` is a Python package for unit-aware arithmetic. It provides:
92
+ - a `Quantity` type that combines numeric values with unit information
93
+ - a registry of SI base and derived units
94
+ - algebraic unit manipulation and compatibility checks
95
+ - explicit multiplicative conversions between compatible units
96
+ - a public API that prioritizes scalar-by-unit construction and SI unit imports
97
+ - a migration path from the legacy `Unit` constructor and compatibility helpers
98
+ - a Python 3-only codebase with no Python 2 compatibility shims
99
+ - a project structure that separates public API, core logic, data models, and utilities
100
+ - comprehensive unit tests and documentation
34
101
 
35
102
  Supported Python versions: 3.10+
36
103
 
@@ -139,12 +206,18 @@ Stable top-level imports:
139
206
 
140
207
  * `Quantity`
141
208
  * `Unit` (compatibility alias for `Quantity`)
209
+ * `convert`
210
+ * `value`
211
+ * `unit`
212
+ * `multiplier`
142
213
  * `UnitsError`, `InvalidUnitError`, `InvalidValueError`,
143
214
  `UnitCompatibilityError`, `UnitOperandError`
144
215
 
145
216
  Canonical unit imports:
146
217
 
147
218
  * `from units.si import metre, second, newton`
219
+ * prefixed and scaled units such as `kilometre`, `centimetre`, `gram`,
220
+ `minute`, `hour`, `kilowatt`, and `millivolt`
148
221
 
149
222
  Legacy compatibility helpers:
150
223
 
@@ -166,11 +239,119 @@ deprecated compatibility paths are scheduled for removal in `1.0.0`.
166
239
 
167
240
  * Addition and subtraction require identical units.
168
241
  * Multiplication and division combine units algebraically.
242
+ * Explicit scale-only conversions are available through `quantity.to(unit)` and
243
+ `convert(quantity, unit)`.
169
244
  * Integer powers of units and unit-bearing quantities are supported.
170
245
  * Unitless quantities are supported explicitly.
246
+ * Affine conversions, such as `degree_celcius <-> kelvin`, are intentionally not
247
+ implemented yet.
171
248
  * The core quantity model allows signed values. Domain-specific constraints such
172
249
  as non-negative lengths should be enforced by higher-level types or validators.
173
250
 
251
+ # Conversion foundations
252
+
253
+ `0.4.0` adds explicit multiplicative conversions. Conversion never happens
254
+ silently during addition or subtraction; you choose the target unit.
255
+
256
+ ```python
257
+ from units import convert, multiplier, unit, value
258
+ from units.si import gram, hour, kilogram, kilometre, metre, minute, second
259
+
260
+ distance = 1.5 * kilometre
261
+ print(distance.to(metre)) # 1500 m
262
+ print(convert(2500 * metre, kilometre)) # 2.5 km
263
+
264
+ duration = 2 * hour
265
+ print(duration.to(minute)) # 120 min
266
+ print((1500 * gram).to(kilogram)) # 1.5 kg
267
+
268
+ speed = (72 * kilometre) / (2 * hour)
269
+ print(speed) # 10.0 m·s^-1
270
+
271
+ print(value(distance)) # 1.5
272
+ print(unit(distance)) # km
273
+ print(multiplier(kilometre)) # 1000.0
274
+ ```
275
+
276
+ The conversion model is scale-only in this release. Celsius is a named
277
+ temperature unit, but converting between Celsius and kelvin requires an offset
278
+ and is reserved for a later affine-conversion release.
279
+
280
+ # Prefixed and scaled units
281
+
282
+ Common SI prefixes and practical time units are available from `units.si`:
283
+
284
+ ```python
285
+ from units.si import (
286
+ centimetre,
287
+ gram,
288
+ hour,
289
+ kiloampere,
290
+ kilometre,
291
+ kilovolt,
292
+ kilowatt,
293
+ megawatt,
294
+ micrometre,
295
+ microsecond,
296
+ milliampere,
297
+ milligram,
298
+ millimetre,
299
+ millisecond,
300
+ millivolt,
301
+ milliwatt,
302
+ minute,
303
+ nanometre,
304
+ nanosecond,
305
+ tonne,
306
+ )
307
+ ```
308
+
309
+ Scaled units participate correctly in multiplication, division, and powers:
310
+
311
+ ```python
312
+ from units.si import hour, kilometre, metre
313
+
314
+ area = (2 * kilometre) * (3 * metre)
315
+ print(area) # 6000 m^2
316
+
317
+ square = (2 * kilometre) ** 2
318
+ print(square) # 4000000 m^2
319
+
320
+ speed = (72 * kilometre) / (2 * hour)
321
+ print(speed) # 10.0 m·s^-1
322
+ ```
323
+
324
+ # Familiar composite units
325
+
326
+ Composite unit expressions such as `kilometre / hour` are algebraic unit
327
+ definitions. They carry the correct scale factor, but anonymous composite units
328
+ render in canonical SI base form:
329
+
330
+ ```python
331
+ from units.si import hour, kilometre
332
+
333
+ speed = 30 * kilometre / hour
334
+ print(speed) # 8.333333333333334 m·s^-1
335
+ ```
336
+
337
+ When you want a semantically familiar display unit, give that composite unit an
338
+ explicit name and convert to it:
339
+
340
+ ```python
341
+ from units import DerivedUnit, convert
342
+ from units.si import hour, kilometre
343
+
344
+ kilometres_per_hour = DerivedUnit.define("km·hr^-1", kilometre / hour)
345
+
346
+ speed = 30 * kilometre / hour
347
+ print(convert(speed, kilometres_per_hour)) # 30 km·hr^-1
348
+ print(30 * kilometres_per_hour) # 30 km·hr^-1
349
+ ```
350
+
351
+ This keeps the arithmetic deterministic while letting application code choose
352
+ domain-specific display names such as `km·hr^-1`, `N·m`, or any other familiar
353
+ derived unit form.
354
+
174
355
  # Real-world examples
175
356
 
176
357
  ## Electrical engineering: from resistance to power dissipation
@@ -1,25 +1,25 @@
1
1
  adapters/__init__.py,sha256=CSemt7d9mX9of0YQijrkQJ9FXwzXqN0TcnCeWGhXXC4,64
2
2
  api/__init__.py,sha256=EVoYM25GfHSWZnMigUDRsAnnyMnMUoeVZnlxlgQRfUI,72
3
- api/public.py,sha256=9iHQ3hy6S2NBAQYF9_vqLUrXlaVhSN_ibFVEZKmXjls,1759
4
- api/si.py,sha256=Qx-WMSia7wxd6hnuhadTtpB8jyeVH7eK-4s98rMnlyM,2242
5
- core/__init__.py,sha256=OjXKNa-EMmUZbVVyG4rLhoKfDl6ifZihCSmv27_XIMQ,811
3
+ api/public.py,sha256=sNxV6ZQowrVsqYbMztx7tx_gtjfU6QG7ym9RZQVaAtE,2521
4
+ api/si.py,sha256=kNRegHG1HI87reYFhA3C3zVzLjehVOD8yX2dBIUS7PM,4141
5
+ core/__init__.py,sha256=f3NNOUAbLL9mECMoLLuYoEPbPYmPsP-aL3V1lzitZTA,919
6
6
  core/deprecations.py,sha256=SQf13eA5dJt78CvIKuXi3vI1bBknwEPHT3FkDWh0Xmc,1436
7
7
  core/errors.py,sha256=iwA73z53DPMPmdoGg-T_PYpyfETqbxwGpxcDYOrsxLY,609
8
- core/quantity.py,sha256=KbZowTitSwjudPDgx3cw-nnu4hnnLECxDG6T7vTjE9s,10058
9
- core/unit_definitions.py,sha256=c3vbjM5-bm-wRNG6z9qJY3b54W2U5JsQM-Vpt2QnaW4,10312
8
+ core/quantity.py,sha256=aOJNjXfdV01MIg1KqyuJdVhyTt1sPED8kqxoLd3B4s0,17018
9
+ core/unit_definitions.py,sha256=kdof8fIVrV6KGIsDPisEjsS7RHLKPBHXOENyvwQysJw,15887
10
10
  models/__init__.py,sha256=8-39jReNyDmbbih6K5IHeqwoWfagjXzjy7nIfJBpPMI,189
11
11
  models/dimension.py,sha256=UNlb234AaUim7iXWzc_2RZ5aZCUefZTvOGTNizoQiPc,3789
12
- python_units-0.3.0.dist-info/licenses/LICENSE,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ python_units-0.4.0.dist-info/licenses/LICENSE,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
13
  services/__init__.py,sha256=tSot9S3F7fHezy3Lj4DNRMQK1ATptRaN0efKRJSgrKs,65
14
14
  units/__init__.py,sha256=Tyj9-XgWDco8J7s_wx2jaEP3fylc2Tc6VZIbsZ1ron4,251
15
15
  units/dimension.py,sha256=v-aiG3bDV7tJUV_xuxZSAw8UHQe0GnbybI2UkiEwuQM,221
16
16
  units/errors.py,sha256=e24NaDCrked0wqWL4kLFLYS0Q1-VB-9QxfHzqrrkoRU,351
17
17
  units/quantity.py,sha256=hMYeiQo4EVLjg76Bx88jdNP8b_hXM6shSrXKUXBY3jA,513
18
18
  units/si.py,sha256=2wqvff8wxQP1w-fWPYolWuIeBGl942jyX8N6dQyGwDU,118
19
- units/unit.py,sha256=5g_azobEX4LPiO7W3n5RmpYCmZuVpZDX77Ipy9ydILk,450
19
+ units/unit.py,sha256=r10068WecU2qrE_D8CL8CEK9_tbJvop11pL-7GBmyQU,845
20
20
  utils/__init__.py,sha256=zTkrwGlYAf6IRcS8e_9nRyACmwJO5Ni-Lwoy55K3qCw,191
21
21
  utils/numbers.py,sha256=_wzMOCoU2hOybqVcT-x__7eY6WkwHa97Ahhr9L5reQE,884
22
- python_units-0.3.0.dist-info/METADATA,sha256=2brF79U2LZZXfM_7o-YhOdH7i8RQmqNA4LGMHpBHEJM,7377
23
- python_units-0.3.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
24
- python_units-0.3.0.dist-info/top_level.txt,sha256=xVEgUUcetmpTHsYk3A-xD9qAxU5S2yD0n8YW1r08tn0,46
25
- python_units-0.3.0.dist-info/RECORD,,
22
+ python_units-0.4.0.dist-info/METADATA,sha256=NE6ev6wLQzw_V0yyXeay7cNwfdOykovG0HhXpH7rZPc,13290
23
+ python_units-0.4.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
24
+ python_units-0.4.0.dist-info/top_level.txt,sha256=xVEgUUcetmpTHsYk3A-xD9qAxU5S2yD0n8YW1r08tn0,46
25
+ python_units-0.4.0.dist-info/RECORD,,
units/unit.py CHANGED
@@ -1,6 +1,12 @@
1
1
  # -*- coding: utf-8 -*-
2
2
  """Compatibility exports for unit definitions."""
3
3
 
4
+ from __future__ import annotations
5
+
6
+ import sys
7
+ from types import ModuleType
8
+
9
+ from core.quantity import unit as _extract_unit
4
10
  from core.unit_definitions import (
5
11
  BaseUnit,
6
12
  CustomUnitBase,
@@ -22,3 +28,13 @@ __all__ = [
22
28
  "require_unit_instance",
23
29
  "resolve_unit",
24
30
  ]
31
+
32
+
33
+ class _CallableUnitModule(ModuleType):
34
+ """Module wrapper that preserves the public ``units.unit(...)`` helper."""
35
+
36
+ def __call__(self, quantity: object) -> BaseUnit:
37
+ return _extract_unit(quantity)
38
+
39
+
40
+ sys.modules[__name__].__class__ = _CallableUnitModule