unitx 0.6.0__tar.gz → 0.6.2__tar.gz

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.
Files changed (45) hide show
  1. {unitx-0.6.0 → unitx-0.6.2}/CHANGELOG.md +21 -0
  2. {unitx-0.6.0 → unitx-0.6.2}/PKG-INFO +54 -21
  3. {unitx-0.6.0 → unitx-0.6.2}/README.md +53 -20
  4. {unitx-0.6.0 → unitx-0.6.2}/pyproject.toml +1 -1
  5. {unitx-0.6.0 → unitx-0.6.2}/src/unitx/factor.py +5 -1
  6. {unitx-0.6.0 → unitx-0.6.2}/src/unitx/quantity.py +31 -22
  7. {unitx-0.6.0 → unitx-0.6.2}/src/unitx/unit.py +67 -29
  8. {unitx-0.6.0 → unitx-0.6.2}/tests/test_quantity.py +56 -3
  9. {unitx-0.6.0 → unitx-0.6.2}/tests/test_si.py +5 -0
  10. {unitx-0.6.0 → unitx-0.6.2}/tests/test_unit.py +70 -55
  11. {unitx-0.6.0 → unitx-0.6.2}/tests/typing/check_quantity.py +28 -3
  12. {unitx-0.6.0 → unitx-0.6.2}/tests/typing/check_si.py +1 -0
  13. {unitx-0.6.0 → unitx-0.6.2}/.github/workflows/ci.yml +0 -0
  14. {unitx-0.6.0 → unitx-0.6.2}/.github/workflows/release.yml +0 -0
  15. {unitx-0.6.0 → unitx-0.6.2}/.gitignore +0 -0
  16. {unitx-0.6.0 → unitx-0.6.2}/LICENSE +0 -0
  17. {unitx-0.6.0 → unitx-0.6.2}/conftest.py +0 -0
  18. {unitx-0.6.0 → unitx-0.6.2}/pyrightconfig.json +0 -0
  19. {unitx-0.6.0 → unitx-0.6.2}/src/unitx/__init__.py +0 -0
  20. {unitx-0.6.0 → unitx-0.6.2}/src/unitx/_constclass.py +0 -0
  21. {unitx-0.6.0 → unitx-0.6.2}/src/unitx/_numerics.py +0 -0
  22. {unitx-0.6.0 → unitx-0.6.2}/src/unitx/_unicode.py +0 -0
  23. {unitx-0.6.0 → unitx-0.6.2}/src/unitx/_unit_data.py +0 -0
  24. {unitx-0.6.0 → unitx-0.6.2}/src/unitx/dimension.py +0 -0
  25. {unitx-0.6.0 → unitx-0.6.2}/src/unitx/exceptions.py +0 -0
  26. {unitx-0.6.0 → unitx-0.6.2}/src/unitx/monomial.py +0 -0
  27. {unitx-0.6.0 → unitx-0.6.2}/src/unitx/py.typed +0 -0
  28. {unitx-0.6.0 → unitx-0.6.2}/src/unitx/si/__init__.py +0 -0
  29. {unitx-0.6.0 → unitx-0.6.2}/src/unitx/si/constants.py +0 -0
  30. {unitx-0.6.0 → unitx-0.6.2}/src/unitx/si/units.py +0 -0
  31. {unitx-0.6.0 → unitx-0.6.2}/src/unitx/singleunit.py +0 -0
  32. {unitx-0.6.0 → unitx-0.6.2}/src/unitx/unitparser.py +0 -0
  33. {unitx-0.6.0 → unitx-0.6.2}/tests/__init__.py +0 -0
  34. {unitx-0.6.0 → unitx-0.6.2}/tests/test_dimension.py +0 -0
  35. {unitx-0.6.0 → unitx-0.6.2}/tests/test_factor.py +0 -0
  36. {unitx-0.6.0 → unitx-0.6.2}/tests/test_import.py +0 -0
  37. {unitx-0.6.0 → unitx-0.6.2}/tests/test_monomial.py +0 -0
  38. {unitx-0.6.0 → unitx-0.6.2}/tests/test_numeric.py +0 -0
  39. {unitx-0.6.0 → unitx-0.6.2}/tests/test_singleunit.py +0 -0
  40. {unitx-0.6.0 → unitx-0.6.2}/tests/test_typing.py +0 -0
  41. {unitx-0.6.0 → unitx-0.6.2}/tests/test_unicode.py +0 -0
  42. {unitx-0.6.0 → unitx-0.6.2}/tests/test_unit_data.py +0 -0
  43. {unitx-0.6.0 → unitx-0.6.2}/tests/test_unit_definition.py +0 -0
  44. {unitx-0.6.0 → unitx-0.6.2}/tests/test_unitparser.py +0 -0
  45. {unitx-0.6.0 → unitx-0.6.2}/tests/test_warnings.py +0 -0
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.2] - 2026-10-18
4
+
5
+ ### Added
6
+
7
+ - Support multiplication and division between Quantity and Factor or SymbolicConstant in both operand orders. Multiplication and right-hand division preserve the magnitude and its type, combining the exact factor with the unit's scale; left-hand division takes the reciprocal magnitude and scales the reciprocal unit. Ordinary numeric operands continue to operate on the magnitude.
8
+ - Preserve generic magnitude types in the corresponding public type hints, with runtime and static regression coverage for exact scaling and reciprocal operations.
9
+
10
+ ### Changed
11
+
12
+ - Combine the compact exact scale and formal units in ScaledUnit string output, grouping denominator factors into one product, such as `J⋅s/(2⋅π)`.
13
+ - Display quantities with scaled units using the combined product and multiplication dots between adjacent numeric factors, such as `3⋅60⋅s`, without evaluating the exact scale. String output remains readable display rather than parser input; `repr()` retains reconstructible state.
14
+ - Document exact-factor quantity arithmetic and the distinction between a symbolic expression such as `SI.h / (2 * PI)` and predefined `SI.hbar`, which continues to expose its numerical value in ordinary `J*s` units.
15
+
16
+ ## [0.6.1] - 2026-10-17
17
+
18
+ ### Changed
19
+
20
+ - Make multiplication of ordinary numeric magnitudes with Unit and ScaledUnit symmetric: both operand orders construct a Quantity and preserve the magnitude type. Zero and negative magnitudes are supported; float magnitudes retain their stored values without Factor rationalization.
21
+ - Return Quantity when dividing Unit or ScaledUnit by an ordinary number. Integer, Fraction, and finite Decimal divisors produce exact Fraction reciprocals, so `si.deg / 60` preserves the exact arcminute definition; float divisors use native division.
22
+ - Keep Factor and SymbolicConstant multiplication and division as exact ScaledUnit operations. To retain the former explicit scaling behavior, use `unit * Factor(value)` or `unit / Factor(value)`.
23
+
3
24
  ## [0.6.0] - 2026-10-17
4
25
 
5
26
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: unitx
3
- Version: 0.6.0
3
+ Version: 0.6.2
4
4
  Summary: A modern, intuitive Python library for units, quantities, and dimensional analysis.
5
5
  Project-URL: Homepage, https://github.com/DaitTHU/unitx
6
6
  Project-URL: Issues, https://github.com/DaitTHU/unitx/issues
@@ -115,6 +115,7 @@ evaluated numerically.
115
115
  For unit expressions and explicit construction, import the classes:
116
116
 
117
117
  ```pycon
118
+ >>> from unitx.factor import Factor
118
119
  >>> from unitx.quantity import Quantity
119
120
  >>> from unitx.unit import Unit
120
121
  >>> Quantity(50, 'cm') == 50 * si.cm
@@ -129,33 +130,65 @@ True
129
130
  with the reciprocal unit. Construction preserves the original magnitude and
130
131
  its type.
131
132
 
132
- These are directional construction operators: `value * unit` and `value / unit`
133
- construct a `Quantity`, while `unit * value` and `unit / value` adjust the
134
- unit's scale and return a `ScaledUnit`. Right-hand numeric scales must be
135
- positive finite `int`, `Fraction`, `Decimal`, or `float` values; booleans are
136
- not accepted. Fraction and Decimal scales remain exact. Floats follow Factor's
137
- denominator limit and may warn or round to zero and fail. A `Factor` or
138
- `SymbolicConstant` on either side of a unit produces a `ScaledUnit`.
133
+ Multiplication is symmetric: `unit * value` and `value * unit` both construct
134
+ `Quantity` objects and preserve the magnitude's type. Dividing a unit by an
135
+ `int`, `Fraction`, or finite `Decimal` constructs a quantity with an exact
136
+ `Fraction` reciprocal, so `degree / 60` loses no precision. Float divisors use
137
+ native floating-point division without Factor's denominator limit. Booleans
138
+ are not accepted as right-hand numeric operands. A `Factor` or
139
+ `SymbolicConstant` on either side of a unit instead produces a `ScaledUnit`.
139
140
 
140
141
  ```pycon
141
142
  >>> metre = Unit('m')
142
- >>> quantity = 2 * metre
143
- >>> quantity.value, quantity.unit == metre
144
- (2, True)
145
- >>> double_metre = metre * 2
143
+ >>> metre * 2 == 2 * metre
144
+ True
145
+ >>> (metre * 2).value
146
+ 2
147
+ >>> double_metre = metre * Factor(2)
146
148
  >>> print(double_metre)
147
- 2 m
149
+ 2⋅m
148
150
  >>> (3 * double_metre).to('m').value
149
151
  6
150
- >>> (3 * (metre / 2)).to('m').value
151
- Fraction(3, 2)
152
+ >>> arcmin = si.deg / 60
153
+ >>> arcmin.value
154
+ Fraction(1, 60)
155
+ >>> arcmin.to(si.arcmin).value
156
+ Fraction(1, 1)
157
+
158
+ ```
159
+
160
+ Multiplying a `ScaledUnit` by an ordinary number also constructs a quantity,
161
+ keeping the exact scale in its unit and preserving the supplied magnitude.
162
+ Combining a Unit with a Quantity continues to return a Quantity.
163
+
164
+ Quantities also accept `Factor` and `SymbolicConstant` operands on either side
165
+ of multiplication or division. Multiplication and right-hand division preserve
166
+ the magnitude and its type, combining the exact factor with the unit instead.
167
+ Left-hand division takes the reciprocal magnitude and unit. Ordinary numeric
168
+ operands continue to act on the magnitude: `q * 2` and `q * Factor(2)` represent
169
+ equal physical quantities but can have different `.value` and `.unit` pairs.
170
+
171
+ ```pycon
172
+ >>> from unitx.factor import PI
173
+ >>> hbar = SI.h / (2 * PI)
174
+ >>> print(hbar)
175
+ 6.62607015E-34 J⋅s/(2⋅π)
176
+ >>> hbar.value == SI.h.value
177
+ True
178
+ >>> hbar * (2 * PI) == SI.h
179
+ True
180
+ >>> round(float(hbar.to('J*s').value) / 1e-34, 8)
181
+ 1.05457182
152
182
 
153
183
  ```
154
184
 
155
- Grouping can therefore change the stored magnitude and unit: `(3 * metre) * 2`
156
- stores magnitude 6 in metres, while `3 * (metre * 2)` stores magnitude 3 in
157
- double metres. They compare as equal quantities. Combining a Unit with a
158
- Quantity continues to return a Quantity.
185
+ The local `hbar` above retains the exact scale, whereas predefined `SI.hbar`
186
+ provides its numerical value in ordinary `J*s` units. `.value` is always the
187
+ magnitude paired with `.unit`, including its exact scale. Use
188
+ `.to('J*s').value` to evaluate the local expression in joule seconds;
189
+ conversion involving an irrational factor evaluates it approximately.
190
+ String output combines the scale and formal units into one product, with
191
+ grouped denominator factors and multiplication dots between adjacent numbers.
159
192
 
160
193
  ## Conversion and arithmetic
161
194
 
@@ -238,8 +271,8 @@ True
238
271
 
239
272
  ```
240
273
 
241
- Multiplying or dividing a unit by a positive numeric scale, `Factor`, or
242
- `SymbolicConstant` creates a `ScaledUnit`, which preserves an exact additional
274
+ Multiplying or dividing a unit by a `Factor` or `SymbolicConstant` creates a
275
+ `ScaledUnit`, which preserves an exact additional
243
276
  scale through unit arithmetic. Its `scale`
244
277
  is relative to its formal unit product; its `factor` is the total scale to the
245
278
  coherent SI unit. An ordinary `Unit` has an implicit additional scale of one;
@@ -93,6 +93,7 @@ evaluated numerically.
93
93
  For unit expressions and explicit construction, import the classes:
94
94
 
95
95
  ```pycon
96
+ >>> from unitx.factor import Factor
96
97
  >>> from unitx.quantity import Quantity
97
98
  >>> from unitx.unit import Unit
98
99
  >>> Quantity(50, 'cm') == 50 * si.cm
@@ -107,33 +108,65 @@ True
107
108
  with the reciprocal unit. Construction preserves the original magnitude and
108
109
  its type.
109
110
 
110
- These are directional construction operators: `value * unit` and `value / unit`
111
- construct a `Quantity`, while `unit * value` and `unit / value` adjust the
112
- unit's scale and return a `ScaledUnit`. Right-hand numeric scales must be
113
- positive finite `int`, `Fraction`, `Decimal`, or `float` values; booleans are
114
- not accepted. Fraction and Decimal scales remain exact. Floats follow Factor's
115
- denominator limit and may warn or round to zero and fail. A `Factor` or
116
- `SymbolicConstant` on either side of a unit produces a `ScaledUnit`.
111
+ Multiplication is symmetric: `unit * value` and `value * unit` both construct
112
+ `Quantity` objects and preserve the magnitude's type. Dividing a unit by an
113
+ `int`, `Fraction`, or finite `Decimal` constructs a quantity with an exact
114
+ `Fraction` reciprocal, so `degree / 60` loses no precision. Float divisors use
115
+ native floating-point division without Factor's denominator limit. Booleans
116
+ are not accepted as right-hand numeric operands. A `Factor` or
117
+ `SymbolicConstant` on either side of a unit instead produces a `ScaledUnit`.
117
118
 
118
119
  ```pycon
119
120
  >>> metre = Unit('m')
120
- >>> quantity = 2 * metre
121
- >>> quantity.value, quantity.unit == metre
122
- (2, True)
123
- >>> double_metre = metre * 2
121
+ >>> metre * 2 == 2 * metre
122
+ True
123
+ >>> (metre * 2).value
124
+ 2
125
+ >>> double_metre = metre * Factor(2)
124
126
  >>> print(double_metre)
125
- 2 m
127
+ 2⋅m
126
128
  >>> (3 * double_metre).to('m').value
127
129
  6
128
- >>> (3 * (metre / 2)).to('m').value
129
- Fraction(3, 2)
130
+ >>> arcmin = si.deg / 60
131
+ >>> arcmin.value
132
+ Fraction(1, 60)
133
+ >>> arcmin.to(si.arcmin).value
134
+ Fraction(1, 1)
135
+
136
+ ```
137
+
138
+ Multiplying a `ScaledUnit` by an ordinary number also constructs a quantity,
139
+ keeping the exact scale in its unit and preserving the supplied magnitude.
140
+ Combining a Unit with a Quantity continues to return a Quantity.
141
+
142
+ Quantities also accept `Factor` and `SymbolicConstant` operands on either side
143
+ of multiplication or division. Multiplication and right-hand division preserve
144
+ the magnitude and its type, combining the exact factor with the unit instead.
145
+ Left-hand division takes the reciprocal magnitude and unit. Ordinary numeric
146
+ operands continue to act on the magnitude: `q * 2` and `q * Factor(2)` represent
147
+ equal physical quantities but can have different `.value` and `.unit` pairs.
148
+
149
+ ```pycon
150
+ >>> from unitx.factor import PI
151
+ >>> hbar = SI.h / (2 * PI)
152
+ >>> print(hbar)
153
+ 6.62607015E-34 J⋅s/(2⋅π)
154
+ >>> hbar.value == SI.h.value
155
+ True
156
+ >>> hbar * (2 * PI) == SI.h
157
+ True
158
+ >>> round(float(hbar.to('J*s').value) / 1e-34, 8)
159
+ 1.05457182
130
160
 
131
161
  ```
132
162
 
133
- Grouping can therefore change the stored magnitude and unit: `(3 * metre) * 2`
134
- stores magnitude 6 in metres, while `3 * (metre * 2)` stores magnitude 3 in
135
- double metres. They compare as equal quantities. Combining a Unit with a
136
- Quantity continues to return a Quantity.
163
+ The local `hbar` above retains the exact scale, whereas predefined `SI.hbar`
164
+ provides its numerical value in ordinary `J*s` units. `.value` is always the
165
+ magnitude paired with `.unit`, including its exact scale. Use
166
+ `.to('J*s').value` to evaluate the local expression in joule seconds;
167
+ conversion involving an irrational factor evaluates it approximately.
168
+ String output combines the scale and formal units into one product, with
169
+ grouped denominator factors and multiplication dots between adjacent numbers.
137
170
 
138
171
  ## Conversion and arithmetic
139
172
 
@@ -216,8 +249,8 @@ True
216
249
 
217
250
  ```
218
251
 
219
- Multiplying or dividing a unit by a positive numeric scale, `Factor`, or
220
- `SymbolicConstant` creates a `ScaledUnit`, which preserves an exact additional
252
+ Multiplying or dividing a unit by a `Factor` or `SymbolicConstant` creates a
253
+ `ScaledUnit`, which preserves an exact additional
221
254
  scale through unit arithmetic. Its `scale`
222
255
  is relative to its formal unit product; its `factor` is the total scale to the
223
256
  coherent SI unit. An ordinary `Unit` has an implicit additional scale of one;
@@ -4,7 +4,7 @@ build-backend = 'hatchling.build'
4
4
 
5
5
  [project]
6
6
  name = 'unitx'
7
- version = '0.6.0'
7
+ version = '0.6.2'
8
8
  description = 'A modern, intuitive Python library for units, quantities, and dimensional analysis.'
9
9
  readme = 'README.md'
10
10
  requires-python = '>=3.12'
@@ -398,6 +398,10 @@ class Factor:
398
398
  """
399
399
  if not compact:
400
400
  return str(self._monomial)
401
+ return str(self._display_monomial())
402
+
403
+ def _display_monomial(self) -> Monomial[int | str]:
404
+ """Collect numeric bases for compact display, without changing exact state."""
401
405
  rational, irrational = self.decompose(rationalize_denominator=True)
402
406
  display_components: dict[int | str, Fraction] = {}
403
407
  if rational.numerator > 1:
@@ -417,7 +421,7 @@ class Factor:
417
421
  # Numeric roots use string keys to stay distinct from rational coefficients.
418
422
  # Symbol validation and Factor construction prevent collisions with symbols.
419
423
  display_components[str(base)] = exponent
420
- return str(Monomial._from_dict(display_components))
424
+ return Monomial._from_dict(display_components)
421
425
 
422
426
  def __str__(self) -> str:
423
427
  return self.format()
@@ -7,10 +7,11 @@ from fractions import Fraction
7
7
  from typing import Any, Generic, TypeVar, cast, overload
8
8
 
9
9
  from ._numerics import Fractionable, Magnitude, Number, Real, as_fraction, as_int_if_integral
10
+ from ._unicode import DOT
10
11
  from .dimension import DIMENSIONLESS, Dimension
11
12
  from .exceptions import DimensionError
12
- from .factor import ONE, Factor
13
- from .unit import ScaledUnit, Unit
13
+ from .factor import ONE, Factor, SymbolicConstant
14
+ from .unit import Unit
14
15
 
15
16
  # Magnitudes are exposed read-only; operations construct new quantities.
16
17
  T = TypeVar('T', bound=Magnitude, covariant=True)
@@ -22,7 +23,9 @@ class Quantity(Generic[T]):
22
23
  Construction preserves the magnitude's type. Conversion uses Factor's
23
24
  scaling rules and can change that type. Arithmetic converts finite Decimal
24
25
  operands exactly to Fraction; other magnitudes use native arithmetic.
25
- Multiplication or division by a Unit preserves the magnitude unchanged.
26
+ Multiplication or division by a Unit, Factor, or SymbolicConstant preserves
27
+ the magnitude unchanged. Exact factors are combined with the unit's scale,
28
+ while ordinary numeric operands participate in magnitude arithmetic.
26
29
 
27
30
  Magnitudes must be scalars whose equality returns a bool. NumPy scalars,
28
31
  arrays, and NumPy operations are not supported yet; NumPy integration is
@@ -33,9 +36,9 @@ class Quantity(Generic[T]):
33
36
  Scalar multiplication and division accept int, float, Fraction, and Decimal.
34
37
 
35
38
  Type hints preserve the magnitude type at construction and when combining
36
- with a Unit. Numeric conversions and arithmetic use unions where the result
37
- depends on runtime units or values. In particular, float annotations also
38
- accept int inputs, which may produce Fraction after scaling. Custom
39
+ with a Unit or exact factor. Numeric conversions and arithmetic use unions
40
+ where the result depends on runtime units or values. In particular, float
41
+ annotations also accept int inputs, which may produce Fraction after scaling. Custom
39
42
  magnitudes retain their arithmetic protocol rather than an assumed Self.
40
43
 
41
44
  Equality and hashing retain the exact rational coefficient and the unit's
@@ -50,9 +53,9 @@ class Quantity(Generic[T]):
50
53
  Powers normalize the exponent once using as_fraction(), just as Unit does,
51
54
  and use that exponent for both the magnitude and the unit. Nonintegral
52
55
  magnitude powers use native numerical evaluation and can be approximate.
53
- str() is readable display and groups units with an explicit scale in
54
- parentheses without evaluating that scale. repr() uses a reconstructible
55
- Unit expression.
56
+ str() displays the magnitude followed by the unit's exact product expression
57
+ without evaluating its scale. Adjacent numeric factors use a multiplication
58
+ dot. repr() uses a reconstructible Unit expression.
56
59
 
57
60
  >>> Quantity(1, 'm') + Quantity(50, 'cm')
58
61
  Quantity(Fraction(3, 2), Unit('m'))
@@ -106,11 +109,13 @@ class Quantity(Generic[T]):
106
109
  return f'{type(self).__name__}({self.value!r}, {self.unit!r})'
107
110
 
108
111
  def __str__(self) -> str:
109
- if isinstance(self.unit, ScaledUnit) and self.unit.scale != ONE:
110
- return f'{self.value} ({self.unit})'
111
- if self.unit.symbol.startswith('1'):
112
- return f'{self.value}{self.unit.symbol[1:]}'
113
- return f'{self.value} {self.unit}'
112
+ unit = str(self.unit)
113
+ if unit == '1':
114
+ return str(self.value)
115
+ if unit.startswith('1/'):
116
+ return f'{self.value}{unit[1:]}'
117
+ separator = DOT if unit[0].isdecimal() else ' '
118
+ return f'{self.value}{separator}{unit}'
114
119
 
115
120
  def is_dimensionless(self) -> bool:
116
121
  return self.dimension is DIMENSIONLESS
@@ -263,7 +268,7 @@ class Quantity(Generic[T]):
263
268
  return NotImplemented
264
269
 
265
270
  @overload
266
- def __mul__(self, other: Unit, /) -> Quantity[T]: ...
271
+ def __mul__(self, other: Unit | Factor | SymbolicConstant, /) -> Quantity[T]: ...
267
272
 
268
273
  @overload
269
274
  def __mul__(self: Quantity[int], other: Quantity[int] | int, /) -> Quantity[int]: ...
@@ -281,7 +286,7 @@ class Quantity(Generic[T]):
281
286
  quantity = cast(Quantity[Magnitude], other)
282
287
  value = cast(Magnitude, quantity._arithmetic_value)
283
288
  return Quantity(self._arithmetic_value * value, self.unit * quantity.unit)
284
- if isinstance(other, Unit):
289
+ if isinstance(other, (Unit, Factor, SymbolicConstant)):
285
290
  return Quantity(self.value, self.unit * other)
286
291
  if isinstance(other, Fractionable):
287
292
  value = cast(Magnitude, Fraction(other) if isinstance(other, Decimal) else other)
@@ -291,7 +296,7 @@ class Quantity(Generic[T]):
291
296
  __rmul__ = __mul__
292
297
 
293
298
  @overload
294
- def __truediv__(self, other: Unit, /) -> Quantity[T]: ...
299
+ def __truediv__(self, other: Unit | Factor | SymbolicConstant, /) -> Quantity[T]: ...
295
300
 
296
301
  @overload
297
302
  def __truediv__(self: Quantity[int], other: Quantity[int] | int, /) -> Quantity[float]: ...
@@ -309,7 +314,7 @@ class Quantity(Generic[T]):
309
314
  quantity = cast(Quantity[Magnitude], other)
310
315
  value = cast(Magnitude, quantity._arithmetic_value)
311
316
  return Quantity(self._arithmetic_value / value, self.unit / quantity.unit)
312
- if isinstance(other, Unit):
317
+ if isinstance(other, (Unit, Factor, SymbolicConstant)):
313
318
  return Quantity(self.value, self.unit / other)
314
319
  if isinstance(other, Fractionable):
315
320
  value = cast(Magnitude, Fraction(other) if isinstance(other, Decimal) else other)
@@ -317,18 +322,22 @@ class Quantity(Generic[T]):
317
322
  return NotImplemented
318
323
 
319
324
  @overload
320
- def __rtruediv__(self: Quantity[int], other: Unit | int, /) -> Quantity[float]: ...
325
+ def __rtruediv__(
326
+ self: Quantity[int], other: Unit | Factor | SymbolicConstant | int, /
327
+ ) -> Quantity[float]: ...
321
328
 
322
329
  @overload
323
330
  def __rtruediv__[N: Fractionable](
324
- self: Quantity[N], other: Unit | Fractionable, /
331
+ self: Quantity[N], other: Unit | Factor | SymbolicConstant | Fractionable, /
325
332
  ) -> Quantity[Real]: ...
326
333
 
327
334
  @overload
328
- def __rtruediv__(self, other: Unit | Fractionable, /) -> Quantity[Magnitude]: ...
335
+ def __rtruediv__(
336
+ self, other: Unit | Factor | SymbolicConstant | Fractionable, /
337
+ ) -> Quantity[Magnitude]: ...
329
338
 
330
339
  def __rtruediv__(self, other: object, /) -> Quantity[Magnitude]:
331
- if isinstance(other, Unit):
340
+ if isinstance(other, (Unit, Factor, SymbolicConstant)):
332
341
  return Quantity(1 / self._arithmetic_value, other / self.unit)
333
342
  if isinstance(other, Fractionable):
334
343
  value = Fraction(other) if isinstance(other, Decimal) else other
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from decimal import Decimal
4
+ from fractions import Fraction
3
5
  from typing import TYPE_CHECKING, Self, overload
4
6
 
5
7
  from ._numerics import Fractionable, Magnitude, as_fraction
@@ -32,18 +34,16 @@ class Unit:
32
34
  exponents remain exact; float exponents may be approximated with an
33
35
  `InexactFloatWarning`.
34
36
 
35
- A supported scalar magnitude multiplied by a Unit constructs a Quantity
36
- with that unit. Dividing a magnitude by a Unit constructs a Quantity with
37
- the reciprocal unit. Both preserve the original magnitude and its type
38
- without scaling. A Factor or SymbolicConstant instead constructs a
39
- ScaledUnit and preserves its exact scale. Multiplying or dividing a Unit
40
- by a positive finite int, Fraction, Decimal, or float instead adjusts its
41
- scale and returns a ScaledUnit. Factor and SymbolicConstant operands on
42
- the right also adjust the scale. Thus 2 * unit constructs a Quantity,
43
- while unit * 2 constructs a ScaledUnit. Float scales follow Factor's
44
- approximation and warning rules; prefer Fraction or Decimal for exact
45
- scales. NumPy scalars, arrays, and NumPy operations are not supported yet;
46
- NumPy integration is planned.
37
+ Multiplying a Unit by an int, Fraction, Decimal, or float constructs a
38
+ Quantity, just as multiplying the magnitude by the Unit does. Construction
39
+ preserves the magnitude and its type. Dividing a magnitude by a Unit also
40
+ preserves it and uses the reciprocal unit. Dividing a Unit by an int,
41
+ Fraction, or finite Decimal constructs a Quantity with an exact Fraction
42
+ reciprocal; float divisors use native floating-point division. Booleans
43
+ are not accepted as right-hand numeric operands.
44
+ Factor and SymbolicConstant operands instead construct a ScaledUnit on
45
+ either side and preserve their exact scale. NumPy scalars, arrays, and
46
+ NumPy operations are not supported yet; NumPy integration is planned.
47
47
 
48
48
  Equality and hashing compare canonical unit identities and rational
49
49
  exponents, regardless of factor insertion order. Aliases compare equal,
@@ -133,7 +133,10 @@ class Unit:
133
133
  return self._monomial == other._monomial
134
134
 
135
135
  @overload
136
- def __mul__(self, other: Fractionable | SymbolicConstant | Factor) -> ScaledUnit: ...
136
+ def __mul__[T: Fractionable](self, other: T) -> Quantity[T]: ...
137
+
138
+ @overload
139
+ def __mul__(self, other: SymbolicConstant | Factor) -> ScaledUnit: ...
137
140
 
138
141
  @overload
139
142
  def __mul__(self, other: ScaledUnit) -> ScaledUnit: ...
@@ -141,8 +144,14 @@ class Unit:
141
144
  @overload
142
145
  def __mul__(self, other: Unit) -> Unit: ...
143
146
 
144
- def __mul__(self, other: object) -> Unit:
145
- if isinstance(other, Fractionable | SymbolicConstant | Factor):
147
+ def __mul__(self, other: object) -> Unit | Quantity[Magnitude]:
148
+ from .quantity import Quantity
149
+
150
+ if isinstance(other, Fractionable):
151
+ if isinstance(other, bool):
152
+ raise TypeError('Boolean numeric operands are not supported.')
153
+ return Quantity(other, self)
154
+ if isinstance(other, SymbolicConstant | Factor):
146
155
  scale = other if isinstance(other, Factor) else Factor(other)
147
156
  return ScaledUnit(self, scale)
148
157
  if not isinstance(other, Unit):
@@ -150,7 +159,13 @@ class Unit:
150
159
  return Unit._from_monomial(self._monomial * other._monomial)
151
160
 
152
161
  @overload
153
- def __truediv__(self, other: Fractionable | SymbolicConstant | Factor) -> ScaledUnit: ...
162
+ def __truediv__(self, other: int | Fraction | Decimal) -> Quantity[Fraction]: ...
163
+
164
+ @overload
165
+ def __truediv__(self, other: float) -> Quantity[float]: ...
166
+
167
+ @overload
168
+ def __truediv__(self, other: SymbolicConstant | Factor) -> ScaledUnit: ...
154
169
 
155
170
  @overload
156
171
  def __truediv__(self, other: ScaledUnit) -> ScaledUnit: ...
@@ -158,8 +173,15 @@ class Unit:
158
173
  @overload
159
174
  def __truediv__(self, other: Unit) -> Unit: ...
160
175
 
161
- def __truediv__(self, other: object) -> Unit:
162
- if isinstance(other, Fractionable | SymbolicConstant | Factor):
176
+ def __truediv__(self, other: object) -> Unit | Quantity[Magnitude]:
177
+ from .quantity import Quantity
178
+
179
+ if isinstance(other, Fractionable):
180
+ if isinstance(other, bool):
181
+ raise TypeError('Boolean numeric operands are not supported.')
182
+ reciprocal = 1 / other if isinstance(other, float) else 1 / Fraction(other)
183
+ return Quantity(reciprocal, self)
184
+ if isinstance(other, SymbolicConstant | Factor):
163
185
  scale = 1 / other if isinstance(other, Factor) else 1 / Factor(other)
164
186
  return ScaledUnit(self, scale)
165
187
  if not isinstance(other, Unit):
@@ -284,7 +306,8 @@ class ScaledUnit(Unit):
284
306
  involving a ScaledUnit return ScaledUnit, even when the scale becomes one.
285
307
  Equality compares the formal product and explicit scale, so a scale of one
286
308
  compares equal to the unscaled Unit. `symbol` describes only the product;
287
- str() also includes its scale, and repr() reconstructs both.
309
+ str() combines its compact exact scale and formal units into a single
310
+ product with a grouped denominator, and repr() reconstructs both.
288
311
 
289
312
  Copying and pickling preserve the formal units and exact scale. Restoring
290
313
  a unit requires its referenced unit definitions to be registered.
@@ -293,7 +316,7 @@ class ScaledUnit(Unit):
293
316
  >>> minute
294
317
  ScaledUnit('s', scale=Factor.from_dict({2: 2, 3: 1, 5: 1}))
295
318
  >>> str(minute)
296
- '60 s'
319
+ '60⋅s'
297
320
  >>> minute == Unit('s')
298
321
  False
299
322
  """
@@ -336,9 +359,11 @@ class ScaledUnit(Unit):
336
359
  def __str__(self) -> str:
337
360
  if self.scale == ONE:
338
361
  return self.symbol
339
- if self.symbol.startswith('1'):
340
- return f'{self.scale}{self.symbol[1:]}'
341
- return f'{self.scale} {self.symbol}'
362
+ components: dict[SingleUnit | int | str, Fraction] = dict(
363
+ self.scale._display_monomial().components()
364
+ )
365
+ components.update(self._monomial.components())
366
+ return str(Monomial(components))
342
367
 
343
368
  def __hash__(self) -> int:
344
369
  if self.scale == ONE:
@@ -351,19 +376,32 @@ class ScaledUnit(Unit):
351
376
  other_scale = other.scale if isinstance(other, ScaledUnit) else ONE
352
377
  return self._monomial == other._monomial and self.scale == other_scale
353
378
 
354
- def __mul__(self, other: Unit | Fractionable | SymbolicConstant | Factor) -> ScaledUnit:
379
+ @overload
380
+ def __mul__[T: Fractionable](self, other: T) -> Quantity[T]: ...
381
+
382
+ @overload
383
+ def __mul__(self, other: Unit | SymbolicConstant | Factor) -> ScaledUnit: ...
384
+
385
+ def __mul__(self, other: object) -> ScaledUnit | Quantity[Magnitude]:
355
386
  if isinstance(other, Fractionable | SymbolicConstant | Factor):
356
- scale = other if isinstance(other, Factor) else Factor(other)
357
- return ScaledUnit(self, scale)
387
+ return super().__mul__(other)
358
388
  if not isinstance(other, Unit):
359
389
  return NotImplemented
360
390
  scale = self.scale * other.scale if isinstance(other, ScaledUnit) else self.scale
361
391
  return ScaledUnit._from_monomial(self._monomial * other._monomial, scale)
362
392
 
363
- def __truediv__(self, other: Unit | Fractionable | SymbolicConstant | Factor) -> ScaledUnit:
393
+ @overload
394
+ def __truediv__(self, other: int | Fraction | Decimal) -> Quantity[Fraction]: ...
395
+
396
+ @overload
397
+ def __truediv__(self, other: float) -> Quantity[float]: ...
398
+
399
+ @overload
400
+ def __truediv__(self, other: Unit | SymbolicConstant | Factor) -> ScaledUnit: ...
401
+
402
+ def __truediv__(self, other: object) -> ScaledUnit | Quantity[Magnitude]:
364
403
  if isinstance(other, Fractionable | SymbolicConstant | Factor):
365
- scale = 1 / other if isinstance(other, Factor) else 1 / Factor(other)
366
- return ScaledUnit(self, scale)
404
+ return super().__truediv__(other)
367
405
  if not isinstance(other, Unit):
368
406
  return NotImplemented
369
407
  scale = self.scale / other.scale if isinstance(other, ScaledUnit) else self.scale
@@ -38,9 +38,14 @@ def test_quantity_str(unit: str, expected: str) -> None:
38
38
  @pytest.mark.parametrize(
39
39
  ('unit', 'expected'),
40
40
  [
41
- (Factor(2) * Unit(), '3 (2)'),
42
- (Factor(2) / Unit('s'), '3 (2/s)'),
43
- (Factor(60) * Unit('s'), '3 (60 s)'),
41
+ (Factor(2) * Unit(), '3⋅2'),
42
+ (Factor(2) / Unit('s'), '3⋅2/s'),
43
+ (Factor(60) * Unit('s'), '3⋅60⋅s'),
44
+ (Unit('J*s') / (2 * PI), '3 J⋅s/(2⋅π)'),
45
+ (Unit('1/s') / (2 * PI), '3/(2⋅π⋅s)'),
46
+ (Unit() / PI, '3/π'),
47
+ (PI * Unit('s'), '3 π⋅s'),
48
+ (Factor(12) * Unit('s'), '3⋅12⋅s'),
44
49
  (ScaledUnit(Unit(), ONE), '3'),
45
50
  (ScaledUnit(Unit('1/s'), ONE), '3/s'),
46
51
  ],
@@ -52,6 +57,54 @@ def test_quantity_scaled_str(unit: ScaledUnit, expected: str) -> None:
52
57
  assert (quantity.value, quantity.unit, quantity.unit.factor) == before
53
58
 
54
59
 
60
+ @pytest.mark.parametrize('value', [0, -2, 2**60 + 1, 0.1, Fraction(3, 7), Decimal('1.2345'), 2j])
61
+ @pytest.mark.parametrize('unit', [Unit('cm'), ScaledUnit('cm', PI), Unit('km/m')])
62
+ @pytest.mark.parametrize('factor', [ONE, Factor(2), PI, 1 / (2 * PI), Factor(2) ** Fraction(1, 2)])
63
+ def test_quantity_factor_operations(value, unit: Unit, factor) -> None:
64
+ quantity = Quantity(value, unit)
65
+ before = (repr(quantity), hash(quantity))
66
+ with localcontext() as context:
67
+ context.prec = 2
68
+ products = (quantity * factor, factor * quantity)
69
+ quotient = quantity / factor
70
+ for result in products:
71
+ assert result.value is value
72
+ assert result.unit == unit * factor
73
+ assert (result / factor).unit == unit
74
+ assert result == value * (unit * factor)
75
+ assert quotient.value is value
76
+ assert quotient.unit == unit / factor
77
+ assert (quotient * factor) == quantity
78
+ assert hash(quotient * factor) == hash(quantity)
79
+ assert {quantity: 'found'}[quotient * factor] == 'found'
80
+ assert (repr(quantity), hash(quantity)) == before
81
+
82
+
83
+ @pytest.mark.parametrize('value', [2, Fraction(3, 7), Decimal('1.2345'), 2j])
84
+ @pytest.mark.parametrize('factor', [Factor(2), PI])
85
+ def test_quantity_factor_reciprocal(value, factor) -> None:
86
+ quantity = Quantity(value, ScaledUnit('cm', PI))
87
+ result = factor / quantity
88
+ reciprocal = 1 / quantity
89
+ assert result.value == reciprocal.value
90
+ assert type(result.value) is type(reciprocal.value)
91
+ assert result.unit == factor / quantity.unit
92
+ assert result == reciprocal * factor
93
+
94
+
95
+ def test_quantity_factor_scaling() -> None:
96
+ quantity = Quantity(3, 'cm')
97
+ numeric = quantity * 2
98
+ exact = quantity * Factor(2)
99
+ assert numeric.value == 6
100
+ assert numeric.unit == quantity.unit
101
+ assert exact.value == 3
102
+ assert exact.unit == ScaledUnit('cm', Factor(2))
103
+ assert exact == numeric
104
+ assert hash(exact) == hash(numeric)
105
+ assert (exact.to('m').to(exact.unit)).value == exact.value
106
+
107
+
55
108
  def test_quantity_scaled_operations() -> None:
56
109
  minute = Factor(60) * Unit('s')
57
110
  quantity = Quantity(1, minute)
@@ -132,6 +132,11 @@ def test_si_defining_constants(attribute: str, target: str, value: str) -> None:
132
132
  assert result.value == Decimal(value)
133
133
 
134
134
 
135
+ def test_si_hbar_value() -> None:
136
+ assert SI.hbar.unit == SI.h.unit
137
+ assert SI.hbar.value == pytest.approx(1.0545718176461565e-34, rel=1e-15, abs=0)
138
+
139
+
135
140
  @pytest.mark.parametrize(
136
141
  ('attribute', 'target'),
137
142
  [('Phi0', si.Wb), ('G0', si.S), ('RK', si.ohm), ('a0', si.m), ('lambdaC', si.m), ('Eh', si.J)],
@@ -54,7 +54,7 @@ def test_unit_quantity_construction(value, unit: Unit, reciprocal: Unit) -> None
54
54
  @pytest.mark.parametrize('unit', [Unit('cm'), ScaledUnit('cm', scale=PI), Unit('km/m')])
55
55
  @pytest.mark.parametrize(
56
56
  'value',
57
- [1, 2, Fraction(7, 3), Decimal('1.2345'), 0.5, Factor(2), PI, Factor(2) ** Fraction(1, 2)],
57
+ [Factor(2), PI, Factor(2) ** Fraction(1, 2)],
58
58
  )
59
59
  def test_unit_numeric_scaling(unit: Unit, value, operation) -> None:
60
60
  before = (repr(unit), unit.factor, hash(unit))
@@ -74,12 +74,17 @@ def test_unit_numeric_scaling(unit: Unit, value, operation) -> None:
74
74
  def test_unit_scaling_cancellation() -> None:
75
75
  unit = Unit('cm')
76
76
  scale = 2**54
77
- scaled = unit * scale
77
+ scaled = unit * Factor(scale)
78
78
  assert scaled.scale.value == scale
79
79
  assert (scaled / Unit('s')).factor == Factor(Fraction(scale, 100))
80
80
  assert ((scaled ** Fraction(1, 2)) ** 2) == scaled
81
81
  assert (PI * unit / PI) == unit
82
- for result in (scaled / scale, unit / scale * scale, unit * 1, unit / 1):
82
+ for result in (
83
+ scaled / Factor(scale),
84
+ unit / Factor(scale) * Factor(scale),
85
+ unit * ONE,
86
+ unit / ONE,
87
+ ):
83
88
  assert type(result) is ScaledUnit
84
89
  assert result.scale == ONE
85
90
  assert result.factor == unit.factor
@@ -96,17 +101,17 @@ def test_unit_quantity_grouping() -> None:
96
101
  assert type(left) is type(right) is Quantity
97
102
  assert left.value == 6
98
103
  assert left.unit is metre
99
- assert right.value == 3
100
- assert right.unit == ScaledUnit(metre, Factor(2))
104
+ assert right.value == 6
105
+ assert right.unit is metre
101
106
  assert left == right
102
107
  assert hash(left) == hash(right)
103
108
  half = 3 * (metre / 2)
104
- assert half.value == 3
109
+ assert half.value == Fraction(3, 2)
105
110
  assert half.to(metre).value == Fraction(3, 2)
106
111
  assert type(half.to(metre).value) is Fraction
107
112
 
108
113
 
109
- @pytest.mark.parametrize('unit', [Unit('cm'), Unit('cm') * 2])
114
+ @pytest.mark.parametrize('unit', [Unit('cm'), Factor(2) * Unit('cm')])
110
115
  def test_unit_quantity_dispatch(unit: Unit) -> None:
111
116
  quantity = Quantity(Fraction(3, 2), 's')
112
117
  for product in (unit * quantity, quantity * unit):
@@ -119,28 +124,50 @@ def test_unit_quantity_dispatch(unit: Unit) -> None:
119
124
  assert quotient.unit == unit / quantity.unit
120
125
 
121
126
 
122
- @pytest.mark.parametrize('operation', [operator.mul, operator.truediv])
123
- @pytest.mark.parametrize('unit', [Unit('cm'), Unit('cm') * 2])
124
- def test_unit_scaling_warning(unit: Unit, operation) -> None:
125
- with pytest.warns(InexactFloatWarning, match='0.1') as recorded:
126
- result = operation(unit, 0.1)
127
- assert len(recorded) == 1
128
- assert recorded[0].filename == __file__
129
- assert recorded[0].lineno == test_unit_scaling_warning.__code__.co_firstlineno + 4
130
- assert result == operation(unit, Fraction(1, 10))
127
+ @pytest.mark.parametrize('unit', [Unit('cm'), ScaledUnit('cm', PI)])
128
+ @pytest.mark.parametrize(
129
+ 'value', [0, -2, 2**60 + 1, Fraction(7, 3), Decimal('1.2345'), 0.1, 1e-300]
130
+ )
131
+ def test_unit_multiplication_symmetry(unit: Unit, value) -> None:
131
132
  with warnings.catch_warnings():
132
133
  warnings.simplefilter('error', InexactFloatWarning)
133
- with pytest.raises(InexactFloatWarning, match='0.1'):
134
- operation(unit, 0.1)
134
+ left, right = value * unit, unit * value
135
+ assert type(left) is type(right) is Quantity
136
+ assert left.value is right.value is value
137
+ assert left.unit is right.unit is unit
138
+ assert left == right
139
+ assert hash(left) == hash(right)
135
140
 
136
141
 
137
- @pytest.mark.parametrize('operation', [operator.mul, operator.truediv])
138
- def test_unit_scaling_underflow(operation) -> None:
139
- unit = Unit('m')
140
- with pytest.warns(InexactFloatWarning, match='1e-10') as recorded:
141
- with pytest.raises(ValueError, match='Factor must be positive'):
142
- operation(unit, 1e-10)
143
- assert len(recorded) == 1
142
+ @pytest.mark.parametrize('unit', [Unit('deg'), ScaledUnit('deg', PI)])
143
+ @pytest.mark.parametrize('divisor', [60, 2**60 + 1, -60, Fraction(7, 3), Decimal('1.2345')])
144
+ def test_unit_division_exact(unit: Unit, divisor) -> None:
145
+ with localcontext() as context:
146
+ context.prec = 2
147
+ result = unit / divisor
148
+ assert type(result) is Quantity
149
+ assert type(result.value) is Fraction
150
+ assert result.value == 1 / Fraction(divisor)
151
+ assert result.unit is unit
152
+ assert (result * divisor) == 1 * unit
153
+
154
+
155
+ def test_unit_division_arcminute() -> None:
156
+ degree = Unit('deg')
157
+ arcmin = degree / 60
158
+ assert arcmin.value == Fraction(1, 60)
159
+ assert arcmin.unit is degree
160
+ assert arcmin.to('arcmin').value == 1
161
+ assert arcmin.to('arcmin').to(degree).value == Fraction(1, 60)
162
+
163
+
164
+ @pytest.mark.parametrize('value', [0.1, 1e-300, -0.5])
165
+ def test_unit_division_float(value: float) -> None:
166
+ with warnings.catch_warnings():
167
+ warnings.simplefilter('error', InexactFloatWarning)
168
+ result = Unit('m') / value
169
+ assert type(result.value) is float
170
+ assert result.value == 1 / value
144
171
 
145
172
 
146
173
  @pytest.mark.parametrize(
@@ -417,37 +444,18 @@ def test_unit_invalid_operands(value: object) -> None:
417
444
  unit / value # pyright: ignore[reportUnusedExpression, reportOperatorIssue]
418
445
 
419
446
 
420
- @pytest.mark.parametrize('unit', [Unit('m'), ScaledUnit('m', scale=Factor(2))])
421
- @pytest.mark.parametrize('value', [0, -2, 0.0, -0.5, Fraction(-1, 2), Decimal('-1.5')])
422
- @pytest.mark.parametrize('operation', [operator.mul, operator.truediv])
423
- def test_unit_invalid_scale(unit: Unit, value, operation) -> None:
424
- before = (repr(unit), unit.factor, hash(unit))
425
- with pytest.raises(ValueError, match='Factor must be positive'):
426
- operation(unit, value)
427
- assert (repr(unit), unit.factor, hash(unit)) == before
447
+ @pytest.mark.parametrize('unit', [Unit('m'), ScaledUnit('m', Factor(2))])
448
+ @pytest.mark.parametrize('divisor', [0, 0.0, Fraction(0), Decimal(0)])
449
+ def test_unit_division_invalid(unit: Unit, divisor) -> None:
450
+ with pytest.raises(ZeroDivisionError):
451
+ unit / divisor
428
452
 
429
453
 
430
- @pytest.mark.parametrize('unit', [Unit('m'), Unit('m') * 2])
454
+ @pytest.mark.parametrize('unit', [Unit('m'), ScaledUnit('m', Factor(2))])
431
455
  @pytest.mark.parametrize('operation', [operator.mul, operator.truediv])
432
456
  @pytest.mark.parametrize('value', [True, False])
433
- def test_unit_invalid_boolean_scale(unit: Unit, value: bool, operation) -> None:
434
- with pytest.raises(TypeError, match='Factor must be a number'):
435
- operation(unit, value)
436
-
437
-
438
- @pytest.mark.parametrize('unit', [Unit('m'), Unit('m') * 2])
439
- @pytest.mark.parametrize('operation', [operator.mul, operator.truediv])
440
- @pytest.mark.parametrize('value', [float('nan'), Decimal('NaN')])
441
- def test_unit_invalid_nan_scale(unit: Unit, value, operation) -> None:
442
- with pytest.raises(ValueError, match='cannot convert NaN'):
443
- operation(unit, value)
444
-
445
-
446
- @pytest.mark.parametrize('unit', [Unit('m'), Unit('m') * 2])
447
- @pytest.mark.parametrize('operation', [operator.mul, operator.truediv])
448
- @pytest.mark.parametrize('value', [float('inf'), -float('inf'), Decimal('Infinity')])
449
- def test_unit_invalid_infinite_scale(unit: Unit, value, operation) -> None:
450
- with pytest.raises(OverflowError, match='cannot convert Infinity'):
457
+ def test_unit_invalid_boolean(unit: Unit, value: bool, operation) -> None:
458
+ with pytest.raises(TypeError, match='Boolean numeric operands'):
451
459
  operation(unit, value)
452
460
 
453
461
 
@@ -567,11 +575,16 @@ def test_scaled_unit_exact_power() -> None:
567
575
  @pytest.mark.parametrize(
568
576
  ('base', 'scale', 'expected'),
569
577
  [
570
- ('s', Factor(60), '60 s'),
578
+ ('s', Factor(60), '60⋅s'),
571
579
  ('', Factor(2), '2'),
572
580
  ('1/s', Factor(2), '2/s'),
573
581
  ('1/(m*s)', Factor(2), '2/(m⋅s)'),
574
582
  ('cm', ONE, 'cm'),
583
+ ('J*s', 1 / (2 * PI), 'J⋅s/(2⋅π)'),
584
+ ('m/s', PI / 180, 'π⋅m/(180⋅s)'),
585
+ ('1/(m*s)', 1 / (2 * PI), '1/(2⋅π⋅m⋅s)'),
586
+ ('s', Factor(5040) ** Fraction(1, 2), '12⋅35¹ᐟ²⋅s'),
587
+ ('m^(1/2)/s', PI ** Fraction(1, 2), 'π¹ᐟ²⋅m¹ᐟ²/s'),
575
588
  ],
576
589
  )
577
590
  def test_scaled_unit_str(base: str, scale: Factor, expected: str) -> None:
@@ -597,7 +610,7 @@ def test_scaled_unit_repr_roundtrip(text: str, scale: Factor | SymbolicConstant)
597
610
  ScaledUnit('cm', scale=ONE),
598
611
  ScaledUnit('cm^(1/2)/s', scale=PI / 180),
599
612
  ScaledUnit('', scale=Factor(2) ** Fraction(1, 2)),
600
- Unit('cm') * 3 / 2,
613
+ Unit('cm') * Factor(Fraction(3, 2)),
601
614
  ],
602
615
  )
603
616
  def test_scaled_unit_copy(unit: ScaledUnit, rebuild) -> None:
@@ -613,7 +626,9 @@ def test_scaled_unit_copy(unit: ScaledUnit, rebuild) -> None:
613
626
 
614
627
 
615
628
  @pytest.mark.parametrize('protocol', range(2, pickle.HIGHEST_PROTOCOL + 1))
616
- @pytest.mark.parametrize('unit', [ScaledUnit('cm^(1/2)/s', scale=PI / 180), Unit('cm') * 3 / 2])
629
+ @pytest.mark.parametrize(
630
+ 'unit', [ScaledUnit('cm^(1/2)/s', scale=PI / 180), Unit('cm') * Factor(Fraction(3, 2))]
631
+ )
617
632
  def test_scaled_unit_pickle(protocol: int, unit: ScaledUnit) -> None:
618
633
  rebuilt = pickle.loads(pickle.dumps(unit, protocol=protocol))
619
634
  assert type(rebuilt) is ScaledUnit
@@ -84,14 +84,26 @@ def test_scaled_unit_construction() -> None:
84
84
 
85
85
 
86
86
  def test_unit_numeric_scaling(unit: Unit, scaled: ScaledUnit) -> None:
87
- for value in (2, 0.5, Fraction(3, 2), Decimal('1.5'), Factor(2), PI):
87
+ for value in (Factor(2), PI):
88
88
  assert_type(unit * value, ScaledUnit)
89
89
  assert_type(unit / value, ScaledUnit)
90
90
  assert_type(scaled * value, ScaledUnit)
91
91
  assert_type(scaled / value, ScaledUnit)
92
- assert_type(unit * 2 / 3, ScaledUnit)
92
+ assert_type(unit * 2, Quantity[int])
93
+ assert_type(unit * 0.1, Quantity[float])
94
+ assert_type(unit * Fraction(1, 3), Quantity[Fraction])
95
+ assert_type(unit * Decimal('0.1'), Quantity[Decimal])
96
+ assert_type(scaled * 2, Quantity[int])
97
+ assert_type(scaled * Decimal('0.1'), Quantity[Decimal])
98
+ assert_type(unit / 60, Quantity[Fraction])
99
+ assert_type(unit / Fraction(3, 2), Quantity[Fraction])
100
+ assert_type(unit / Decimal('0.1'), Quantity[Fraction])
101
+ assert_type(unit / 0.1, Quantity[float])
102
+ assert_type(scaled / 60, Quantity[Fraction])
103
+ assert_type(scaled / 0.1, Quantity[float])
104
+ assert_type(unit * 2 / 3, Quantity[float])
93
105
  assert_type(3 * (unit * 2), Quantity[int])
94
- assert_type(Decimal(3) * (unit / 2), Quantity[Decimal])
106
+ assert_type(Decimal(3) * (unit / 2), Quantity[Real])
95
107
  assert_type(Factor(2) * unit, ScaledUnit)
96
108
  assert_type(PI * scaled, ScaledUnit)
97
109
  assert_type(unit * Quantity(2, 's'), Quantity[int])
@@ -131,6 +143,19 @@ def test_quantity_unit_operations[T: Magnitude](value: T) -> Quantity[T]:
131
143
  return quantity
132
144
 
133
145
 
146
+ def test_quantity_factor_operations[T: Magnitude](value: T) -> Quantity[T]:
147
+ quantity = Quantity(value, 'm')
148
+ for factor in (Factor(2), PI):
149
+ assert_type(quantity * factor, Quantity[T])
150
+ assert_type(factor * quantity, Quantity[T])
151
+ assert_type(quantity / factor, Quantity[T])
152
+ assert_type(factor / quantity, Quantity[Magnitude])
153
+ assert_type(Factor(2) / Quantity(2, 's'), Quantity[float])
154
+ assert_type(PI / Quantity(Decimal(2), 's'), Quantity[Real])
155
+ assert_type(Quantity(Decimal(2), 's') / (2 * PI), Quantity[Decimal])
156
+ return quantity
157
+
158
+
134
159
  def test_quantity_numeric_operations() -> None:
135
160
  integer = Quantity(1, 'm')
136
161
  rational = Quantity(Fraction(1), 'm')
@@ -20,5 +20,6 @@ def test_si_unit_types() -> None:
20
20
  def test_si_constant_types() -> None:
21
21
  assert_type(SI.c, Quantity[int])
22
22
  assert_type(SI.h, Quantity[Decimal])
23
+ assert_type(SI.hbar, Quantity[Real])
23
24
  assert_type(SI.e, Quantity[Decimal])
24
25
  assert_type(SI.h.to(si.J * si.s), Quantity[Real])
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes