From 8521ee4fc40e606f9aadf447a9e184fadd27d736 Mon Sep 17 00:00:00 2001 From: Chen Kasirer Date: Wed, 4 Feb 2026 10:20:50 +0100 Subject: [PATCH 1/5] removed implicit singleton behavior of Tolerance. Independent instances can be created and global instance can be explicitly modified --- CHANGELOG.md | 5 + src/compas/tolerance.py | 273 ++++++++++++++++++++++++++++----- tests/compas/test_tolerance.py | 54 +++++++ 3 files changed, 293 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99790add62cc..0c9a8f74e623 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,13 @@ 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`. + ### Removed diff --git a/src/compas/tolerance.py b/src/compas/tolerance.py index fbcfd0218923..af25e8617534 100644 --- a/src/compas/tolerance.py +++ b/src/compas/tolerance.py @@ -1,11 +1,38 @@ """ 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 import compas @@ -21,6 +48,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 +95,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 +172,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 +206,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 +232,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 +260,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,6 +276,161 @@ 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): return self._unit diff --git a/tests/compas/test_tolerance.py b/tests/compas/test_tolerance.py index 792137f59d94..342f0ca6347a 100644 --- a/tests/compas/test_tolerance.py +++ b/tests/compas/test_tolerance.py @@ -8,6 +8,60 @@ 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_format_number(): assert TOL.format_number(0, precision=3) == "0.000" assert TOL.format_number(0.5, precision=3) == "0.500" From 4e99cdf41deee4db740628fac77dd0c933ecdf6b Mon Sep 17 00:00:00 2001 From: Chen Kasirer Date: Mon, 9 Feb 2026 09:33:03 +0100 Subject: [PATCH 2/5] fixed typo(?) in property name `units` vs `unit` --- src/compas/tolerance.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/compas/tolerance.py b/src/compas/tolerance.py index af25e8617534..1b81cec157ff 100644 --- a/src/compas/tolerance.py +++ b/src/compas/tolerance.py @@ -432,11 +432,13 @@ def temporary( 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 From 553ccfb387a57d0d09769b76beab935b0d99e859 Mon Sep 17 00:00:00 2001 From: Chen Kasirer Date: Mon, 9 Feb 2026 09:33:54 +0100 Subject: [PATCH 3/5] backup properties instead of potentially empty private attributes --- src/compas/tolerance.py | 16 ++++++++-------- tests/compas/test_tolerance.py | 10 ++++++++++ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/compas/tolerance.py b/src/compas/tolerance.py index 1b81cec157ff..0a69da4ba093 100644 --- a/src/compas/tolerance.py +++ b/src/compas/tolerance.py @@ -398,14 +398,14 @@ def temporary( """ # 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, + "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 diff --git a/tests/compas/test_tolerance.py b/tests/compas/test_tolerance.py index 342f0ca6347a..9a0de26c8141 100644 --- a/tests/compas/test_tolerance.py +++ b/tests/compas/test_tolerance.py @@ -62,6 +62,16 @@ def test_tolerance_temporary_restores_on_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" From 42901707dc6f7903bdb33512856449f7f2f71c59 Mon Sep 17 00:00:00 2001 From: Chen Kasirer Date: Mon, 9 Feb 2026 09:45:17 +0100 Subject: [PATCH 4/5] brought back units with deprecation warning --- CHANGELOG.md | 1 + src/compas/tolerance.py | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c9a8f74e623..04471f399bfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 0a69da4ba093..5fe4cdb1048c 100644 --- a/src/compas/tolerance.py +++ b/src/compas/tolerance.py @@ -34,6 +34,7 @@ from contextlib import contextmanager from decimal import Decimal +from warnings import warn import compas from compas.data import Data @@ -443,6 +444,16 @@ def unit(self, value): 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: From 13febbe537ab3bf76b347d59d16a60f3b62e392e Mon Sep 17 00:00:00 2001 From: Chen Kasirer Date: Wed, 18 Mar 2026 09:35:21 +0100 Subject: [PATCH 5/5] added backwards compatibility with user warning for properties with typos --- src/compas/tolerance.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/compas/tolerance.py b/src/compas/tolerance.py index 5fe4cdb1048c..114f6f113f01 100644 --- a/src/compas/tolerance.py +++ b/src/compas/tolerance.py @@ -526,6 +526,26 @@ def angulardeflection(self): def angulardeflection(self, value): self._angulardeflection = value + @property + def lineardflection(self): + warn("The 'lineardflection' property is deprecated. Use 'lineardeflection' instead.", DeprecationWarning) + return self.lineardeflection + + @lineardflection.setter + def lineardflection(self, value): + warn("The 'lineardflection' property is deprecated. Use 'lineardeflection' instead.", DeprecationWarning) + self.lineardeflection = value + + @property + def angulardflection(self): + warn("The 'angulardflection' property is deprecated. Use 'angulardeflection' instead.", DeprecationWarning) + return self.angulardeflection + + @angulardflection.setter + def angulardflection(self, value): + warn("The 'angulardflection' property is deprecated. Use 'angulardeflection' instead.", DeprecationWarning) + self.angulardeflection = value + def tolerance(self, truevalue, rtol, atol): """Compute the tolerance for a comparison.