diff --git a/CHANGELOG.md b/CHANGELOG.md index 99790add62c..04471f399bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* Added `TOL.update()` method for explicit global state modification. +* Added `TOL.temporary()` context manager for scoped changes. + ### Changed +* Changed `Tolerance` class to no longer use singleton pattern. `Tolerance()` now creates independent instances instead of returning the global `TOL`. +* Renamed `Tolerance.units` to `Tolerance.unit` to better reflect the documented properties. Left `units` with deprecation warning. + ### Removed diff --git a/src/compas/tolerance.py b/src/compas/tolerance.py index fbcfd021892..5fe4cdb1048 100644 --- a/src/compas/tolerance.py +++ b/src/compas/tolerance.py @@ -1,12 +1,40 @@ """ The tolerance module provides functionality to deal with tolerances consistently across all other COMPAS packages. + +The module provides: +- :class:`Tolerance`: A class for tolerance settings that can be instantiated independently. +- :obj:`TOL`: The global tolerance instance used throughout COMPAS (in-process). + +To modify global tolerance settings, use the explicit methods on `TOL`: +- ``TOL.update(...)`` - Update specific tolerance values +- ``TOL.reset()`` - Reset to default values +- ``TOL.temporary(...)`` - Context manager for temporary changes + +Example +------- +>>> from compas.tolerance import TOL, Tolerance +>>> # Create an independent tolerance instance +>>> my_tol = Tolerance(absolute=0.01) +>>> my_tol.absolute +0.01 +>>> # Global TOL is unchanged +>>> TOL.absolute +1e-09 +>>> # To modify global state, use update() +>>> TOL.update(absolute=0.001) +>>> TOL.absolute +0.001 +>>> TOL.reset() + """ from __future__ import absolute_import from __future__ import division from __future__ import print_function +from contextlib import contextmanager from decimal import Decimal +from warnings import warn import compas from compas.data import Data @@ -21,6 +49,21 @@ class Tolerance(Data): ---------- unit : {"M", "MM"}, optional The unit of the tolerance settings. + absolute : float, optional + The absolute tolerance. Default is :attr:`ABSOLUTE`. + relative : float, optional + The relative tolerance. Default is :attr:`RELATIVE`. + angular : float, optional + The angular tolerance. Default is :attr:`ANGULAR`. + approximation : float, optional + The tolerance used in approximation processes. Default is :attr:`APPROXIMATION`. + precision : int, optional + The precision used when converting numbers to strings. Default is :attr:`PRECISION`. + lineardeflection : float, optional + The maximum distance between a curve/surface and its polygonal approximation. + Default is :attr:`LINEARDEFLECTION`. + angulardeflection : float, optional + The maximum curvature deviation. Default is :attr:`ANGULARDEFLECTION`. name : str, optional The name of the tolerance settings. @@ -53,25 +96,35 @@ class Tolerance(Data): This value is called the "true value". By convention, the second value is considered the "true value" by the comparison functions of this class. - The :class:`compas.tolerance.Tolerance` class is implemented using a "singleton" pattern and can therefore have only 1 (one) instance per context. - Usage of :attr:`compas.tolerance.TOL` outside of :mod:`compas` internals is therefore deprecated. + Each call to ``Tolerance(...)`` creates an independent instance. To modify the global + tolerance settings used throughout COMPAS, use the explicit methods on :obj:`TOL`: + + - ``TOL.update(...)`` - Update specific tolerance values + - ``TOL.reset()`` - Reset all values to defaults + - ``TOL.temporary(...)`` - Context manager for temporary changes Examples -------- - >>> tol = Tolerance() - >>> tol.unit - 'M' + Create an independent tolerance instance: + + >>> tol = Tolerance(absolute=0.01) >>> tol.absolute + 0.01 + + The global TOL is separate: + + >>> from compas.tolerance import TOL + >>> TOL.absolute # unchanged 1e-09 - >>> tol.relative - 1e-06 - >>> tol.angular - 1e-06 - """ + Modify global state explicitly: - _instance = None - _is_inited = False + >>> TOL.update(absolute=0.001) + >>> TOL.absolute + 0.001 + >>> TOL.reset() + + """ SUPPORTED_UNITS = ["M", "MM"] """{"M", "MM"}: Default tolerances are defined in relation to length units. @@ -120,12 +173,6 @@ class Tolerance(Data): """ - def __new__(cls, *args, **kwargs): - if not cls._instance: - cls._instance = object.__new__(cls, *args, **kwargs) - cls._is_inited = False - return cls._instance - @property def __data__(self): return { @@ -160,22 +207,19 @@ def __init__( angular=None, approximation=None, precision=None, - lineardflection=None, - angulardflection=None, + lineardeflection=None, + angulardeflection=None, name=None, ): super(Tolerance, self).__init__(name=name) - if not self._is_inited: - self._unit = None - self._absolute = None - self._relative = None - self._angular = None - self._approximation = None - self._precision = None - self._lineardeflection = None - self._angulardeflection = None - - self._is_inited = True + self._unit = None + self._absolute = None + self._relative = None + self._angular = None + self._approximation = None + self._precision = None + self._lineardeflection = None + self._angulardeflection = None if unit is not None: self.unit = unit @@ -189,13 +233,10 @@ def __init__( self.approximation = approximation if precision is not None: self.precision = precision - if lineardflection is not None: - self.lineardeflection = lineardflection - if angulardflection is not None: - self.angulardeflection = angulardflection - - # this can be autogenerated if we use slots - # __repr__: return f"{__class__.__name__}({', '.join(f'{k}={v!r}' for k, v in self.__dict__.items())})}" + if lineardeflection is not None: + self.lineardeflection = lineardeflection + if angulardeflection is not None: + self.angulardeflection = angulardeflection def __repr__(self): return "Tolerance(unit='{}', absolute={}, relative={}, angular={}, approximation={}, precision={}, lineardeflection={}, angulardeflection={})".format( @@ -220,7 +261,7 @@ def reset(self): self._angulardeflection = None def update_from_dict(self, tolerance): - """Update the tolerance singleton from the key-value pairs found in a dict. + """Update the tolerance from the key-value pairs found in a dict. Parameters ---------- @@ -236,16 +277,183 @@ def update_from_dict(self, tolerance): if hasattr(self, name): setattr(self, name, tolerance[name]) + def update( + self, + unit=None, + absolute=None, + relative=None, + angular=None, + approximation=None, + precision=None, + lineardeflection=None, + angulardeflection=None, + ): + """Update tolerance settings. + + Only the provided parameters will be updated; others remain unchanged. + Use this method to explicitly modify tolerance settings. + + Parameters + ---------- + unit : {"M", "MM"}, optional + The unit of the tolerance settings. + absolute : float, optional + The absolute tolerance. + relative : float, optional + The relative tolerance. + angular : float, optional + The angular tolerance. + approximation : float, optional + The tolerance used in approximation processes. + precision : int, optional + The precision used when converting numbers to strings. + lineardeflection : float, optional + The maximum distance between a curve/surface and its polygonal approximation. + angulardeflection : float, optional + The maximum curvature deviation. + + Returns + ------- + None + + Examples + -------- + >>> from compas.tolerance import TOL + >>> TOL.update(absolute=0.001, precision=6) + >>> TOL.absolute + 0.001 + >>> TOL.precision + 6 + >>> TOL.reset() + + """ + if unit is not None: + self.unit = unit + if absolute is not None: + self.absolute = absolute + if relative is not None: + self.relative = relative + if angular is not None: + self.angular = angular + if approximation is not None: + self.approximation = approximation + if precision is not None: + self.precision = precision + if lineardeflection is not None: + self.lineardeflection = lineardeflection + if angulardeflection is not None: + self.angulardeflection = angulardeflection + + @contextmanager + def temporary( + self, + unit=None, + absolute=None, + relative=None, + angular=None, + approximation=None, + precision=None, + lineardeflection=None, + angulardeflection=None, + ): + """Context manager for temporarily changing tolerance settings. + + The original settings are automatically restored when the context exits, + even if an exception occurs. + + Parameters + ---------- + unit : {"M", "MM"}, optional + The unit of the tolerance settings. + absolute : float, optional + The absolute tolerance. + relative : float, optional + The relative tolerance. + angular : float, optional + The angular tolerance. + approximation : float, optional + The tolerance used in approximation processes. + precision : int, optional + The precision used when converting numbers to strings. + lineardeflection : float, optional + The maximum distance between a curve/surface and its polygonal approximation. + angulardeflection : float, optional + The maximum curvature deviation. + + Yields + ------ + :class:`Tolerance` + The tolerance instance with temporary settings applied. + + Examples + -------- + >>> from compas.tolerance import TOL + >>> TOL.absolute + 1e-09 + >>> with TOL.temporary(absolute=0.01): + ... TOL.absolute + 0.01 + >>> TOL.absolute + 1e-09 + + """ + # Save current state + saved = { + "unit": self.unit, + "absolute": self.absolute, + "relative": self.relative, + "angular": self.angular, + "approximation": self.approximation, + "precision": self.precision, + "lineardeflection": self.lineardeflection, + "angulardeflection": self.angulardeflection, + } + try: + # Apply temporary changes + self.update( + unit=unit, + absolute=absolute, + relative=relative, + angular=angular, + approximation=approximation, + precision=precision, + lineardeflection=lineardeflection, + angulardeflection=angulardeflection, + ) + yield self + finally: + # Restore original state + self._unit = saved["unit"] + self._absolute = saved["absolute"] + self._relative = saved["relative"] + self._angular = saved["angular"] + self._approximation = saved["approximation"] + self._precision = saved["precision"] + self._lineardeflection = saved["lineardeflection"] + self._angulardeflection = saved["angulardeflection"] + @property - def units(self): + def unit(self): + if not self._unit: + return "M" return self._unit - @units.setter - def units(self, value): + @unit.setter + def unit(self, value): if value not in ["M", "MM"]: raise ValueError("Invalid unit: {}".format(value)) self._unit = value + @property + def units(self): + warn("The 'units' property is deprecated. Use 'unit' instead.", DeprecationWarning) + return self.unit + + @units.setter + def units(self, value): + warn("The 'units' property is deprecated. Use 'unit' instead.", DeprecationWarning) + self.unit = value + @property def absolute(self): if not self._absolute: diff --git a/tests/compas/test_tolerance.py b/tests/compas/test_tolerance.py index 792137f59d9..9a0de26c814 100644 --- a/tests/compas/test_tolerance.py +++ b/tests/compas/test_tolerance.py @@ -8,6 +8,70 @@ def test_tolerance_default_tolerance(): assert TOL.precision == 3 +def test_tolerance_creates_independent_instances(): + """Test that Tolerance() creates independent instances, not the singleton.""" + tol1 = Tolerance(absolute=0.01) + tol2 = Tolerance(absolute=0.02) + + # Each instance is independent + assert tol1 is not tol2 + assert tol1.absolute == 0.01 + assert tol2.absolute == 0.02 + + # TOL is unchanged + assert TOL.absolute == Tolerance.ABSOLUTE + + +def test_tolerance_update(): + """Test that TOL.update() explicitly modifies global state.""" + original = TOL.absolute + try: + TOL.update(absolute=0.001) + assert TOL.absolute == 0.001 + finally: + TOL.reset() + assert TOL.absolute == original + + +def test_tolerance_temporary_context_manager(): + """Test that TOL.temporary() provides scoped changes.""" + original = TOL.absolute + assert TOL.absolute == Tolerance.ABSOLUTE + + with TOL.temporary(absolute=0.01, precision=6): + assert TOL.absolute == 0.01 + assert TOL.precision == 6 + + # After context exit, values are restored + assert TOL.absolute == original + assert TOL.precision == Tolerance.PRECISION + + +def test_tolerance_temporary_restores_on_exception(): + """Test that temporary() restores values even if an exception occurs.""" + original = TOL.absolute + + try: + with TOL.temporary(absolute=0.01): + assert TOL.absolute == 0.01 + raise ValueError("test exception") + except ValueError: + pass + + # Values are restored despite the exception + assert TOL.absolute == original + + +def test_tolerance_temporary_restores_unit(): + """Test that temporary() restores values even if an exception occurs.""" + original = TOL.unit + + with TOL.temporary(unit="MM"): + assert TOL.unit == "MM" + + assert TOL.unit == original + + def test_tolerance_format_number(): assert TOL.format_number(0, precision=3) == "0.000" assert TOL.format_number(0.5, precision=3) == "0.500"