Skip to content

Commit cd15f3a

Browse files
author
Horde
committed
test: add service locator unit tests and changelog fragment
- 7 unit tests covering get_service, set_service, replacement lifecycle, close-on-clear_instance, multiple service types, and idempotent re-registration - Changelog entry for the new service locator API
1 parent e266cb8 commit cd15f3a

4 files changed

Lines changed: 221 additions & 34 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
Added
2+
^^^^^
3+
4+
* Added :class:`~isaaclab.sim.ServiceLocator` and exposed it as
5+
:attr:`~isaaclab.sim.SimulationContext.services`.
6+
7+
Backend-specific caches can be registered and retrieved using subscript
8+
syntax (``services[cls] = instance``, ``services[cls]``). Services with
9+
a ``close()`` method are automatically closed on ``clear_instance()``.
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).
2+
# All rights reserved.
3+
#
4+
# SPDX-License-Identifier: BSD-3-Clause
5+
6+
"""Typed service locator for lifecycle-managed singletons."""
7+
8+
from __future__ import annotations
9+
10+
from typing import TypeVar
11+
12+
_T = TypeVar("_T")
13+
14+
15+
def _try_close(service: object) -> None:
16+
"""Call close() on *service* if it exists and is callable."""
17+
close = getattr(service, "close", None)
18+
if close is not None and callable(close):
19+
close()
20+
21+
22+
class ServiceLocator:
23+
"""A typed service registry keyed by class, interface, or abstract base class.
24+
25+
Services are registered and retrieved using subscript syntax::
26+
27+
locator[FabricStageCache] = FabricStageCache(stage)
28+
cache = locator[FabricStageCache]
29+
30+
Deleting a service calls ``close()`` on it if available::
31+
32+
del locator[FabricStageCache]
33+
34+
All registered services are closed and cleared via :meth:`close_all`.
35+
"""
36+
37+
def __init__(self) -> None:
38+
self._services: dict[type, object] = {}
39+
40+
def __getitem__(self, cls: type[_T]) -> _T | None:
41+
"""Retrieve a service by its key class, or ``None`` if not registered."""
42+
return self._services.get(cls) # type: ignore[return-value]
43+
44+
def __setitem__(self, cls: type[_T], instance: _T) -> None:
45+
"""Register a service under the given key.
46+
47+
The key can be the concrete class of *instance*, a parent class,
48+
or an abstract base class / protocol — allowing retrieval by
49+
interface rather than implementation.
50+
51+
Does *not* close a previously registered service — the caller is
52+
responsible for closing the old instance before replacing it.
53+
Use ``del locator[cls]`` or :meth:`pop` to close and remove.
54+
"""
55+
self._services[cls] = instance
56+
57+
def __delitem__(self, cls: type) -> None:
58+
"""Close and remove a service.
59+
60+
Calls ``close()`` on the instance if it has one, then removes it.
61+
62+
Raises:
63+
KeyError: If no service is registered under *cls*.
64+
"""
65+
instance = self._services.pop(cls)
66+
_try_close(instance)
67+
68+
def __contains__(self, cls: type) -> bool:
69+
"""Check if a service is registered under *cls*."""
70+
return cls in self._services
71+
72+
def pop(self, cls: type[_T]) -> _T | None:
73+
"""Remove and return a service without closing it.
74+
75+
Returns:
76+
The previously registered instance, or ``None`` if not registered.
77+
"""
78+
return self._services.pop(cls, None) # type: ignore[return-value]
79+
80+
def close_all(self) -> None:
81+
"""Close all registered services and clear the registry.
82+
83+
Calls ``close()`` on each service that has one.
84+
"""
85+
for service in self._services.values():
86+
_try_close(service)
87+
self._services.clear()

source/isaaclab/isaaclab/sim/simulation_context.py

Lines changed: 12 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import traceback
1212
from collections.abc import Iterator
1313
from contextlib import contextmanager
14-
from typing import TYPE_CHECKING, Any, TypeVar
14+
from typing import TYPE_CHECKING, Any
1515

1616
import toml
1717
import torch
@@ -30,6 +30,7 @@
3030
)
3131
from isaaclab.renderers.render_context import RenderContext
3232
from isaaclab.scene.scene_data_provider import SceneDataProvider
33+
from isaaclab.sim.service_locator import ServiceLocator
3334
from isaaclab.sim.utils import create_new_stage
3435
from isaaclab.utils.string import clear_resolve_matching_names_cache
3536
from isaaclab.utils.version import has_kit
@@ -38,7 +39,6 @@
3839
if TYPE_CHECKING:
3940
from isaaclab.cloner.clone_plan import ClonePlan
4041

41-
_T = TypeVar("_T")
4242

4343
from .simulation_cfg import SimulationCfg
4444
from .spawners import DomeLightCfg, GroundPlaneCfg
@@ -216,10 +216,7 @@ def __init__(self, cfg: SimulationCfg | None = None):
216216
order=5,
217217
)
218218

219-
# Singleton service registry — backend-specific caches register themselves
220-
# here, keyed by their class. Services with a ``close()`` method are closed
221-
# when the SimulationContext is torn down via ``clear_instance()``.
222-
self._services: dict[type, object] = {}
219+
self._services = ServiceLocator()
223220

224221
type(self)._instance = self # Mark as valid singleton only after successful init
225222

@@ -863,33 +860,17 @@ def get_setting(self, name: str) -> Any:
863860
# Service locator
864861
# ------------------------------------------------------------------
865862

866-
def get_service(self, cls: type[_T]) -> _T | None:
867-
"""Retrieve a registered singleton service by its class.
868-
869-
Args:
870-
cls: The service class used as key.
871-
872-
Returns:
873-
The registered instance, or ``None`` if not registered.
874-
"""
875-
return self._services.get(cls) # type: ignore[return-value]
876-
877-
def set_service(self, cls: type[_T], instance: _T) -> None:
878-
"""Register a singleton service, keyed by its class.
863+
@property
864+
def services(self) -> ServiceLocator:
865+
"""Typed service registry for backend-specific singletons.
879866
880-
Overwrites any previously registered instance for the same class.
881-
If the old instance has a ``close()`` method it is called before
882-
replacement. Services are automatically closed and cleared when
883-
:meth:`clear_instance` is called.
867+
Usage::
884868
885-
Args:
886-
cls: The service class used as key.
887-
instance: The service instance to register.
869+
sim_context.services[FabricStageCache] = cache
870+
cache = sim_context.services[FabricStageCache]
871+
del sim_context.services[FabricStageCache] # closes and removes
888872
"""
889-
old = self._services.get(cls)
890-
if old is not None and old is not instance and hasattr(old, "close"):
891-
old.close()
892-
self._services[cls] = instance
873+
return self._services
893874

894875
@classmethod
895876
def clear_instance(cls) -> None:
@@ -905,10 +886,7 @@ def clear_instance(cls) -> None:
905886
cls._instance._visualizers.clear()
906887

907888
# Close and drop all registered singleton services
908-
for service in cls._instance._services.values():
909-
if hasattr(service, "close"):
910-
service.close()
911-
cls._instance._services.clear()
889+
cls._instance._services.close_all()
912890

913891
# Tear down the stage. We skip clear_stage() (prim-by-prim deletion) since
914892
# close_stage() + app shutdown destroy the entire stage at once.
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).
2+
# All rights reserved.
3+
#
4+
# SPDX-License-Identifier: BSD-3-Clause
5+
6+
"""Tests for ServiceLocator."""
7+
8+
import pytest
9+
10+
from isaaclab.sim.service_locator import ServiceLocator
11+
12+
13+
class _DummyService:
14+
"""Minimal service with a close() method for testing lifecycle."""
15+
16+
def __init__(self):
17+
self.closed = False
18+
19+
def close(self):
20+
self.closed = True
21+
22+
23+
class _ServiceNoClose:
24+
"""Service without a close() method."""
25+
26+
pass
27+
28+
29+
class TestServiceLocator:
30+
"""Test ServiceLocator subscript API."""
31+
32+
def test_get_returns_none_when_unregistered(self):
33+
loc = ServiceLocator()
34+
assert loc[_DummyService] is None
35+
36+
def test_set_and_get(self):
37+
loc = ServiceLocator()
38+
svc = _DummyService()
39+
loc[_DummyService] = svc
40+
assert loc[_DummyService] is svc
41+
42+
def test_contains(self):
43+
loc = ServiceLocator()
44+
assert _DummyService not in loc
45+
loc[_DummyService] = _DummyService()
46+
assert _DummyService in loc
47+
48+
def test_del_closes_service(self):
49+
loc = ServiceLocator()
50+
svc = _DummyService()
51+
loc[_DummyService] = svc
52+
del loc[_DummyService]
53+
assert svc.closed
54+
assert loc[_DummyService] is None
55+
56+
def test_del_without_close_method(self):
57+
"""del on a service without close() should not raise."""
58+
loc = ServiceLocator()
59+
loc[_ServiceNoClose] = _ServiceNoClose()
60+
del loc[_ServiceNoClose]
61+
assert loc[_ServiceNoClose] is None
62+
63+
def test_del_missing_raises_key_error(self):
64+
loc = ServiceLocator()
65+
with pytest.raises(KeyError):
66+
del loc[_DummyService]
67+
68+
def test_pop_returns_without_closing(self):
69+
loc = ServiceLocator()
70+
svc = _DummyService()
71+
loc[_DummyService] = svc
72+
popped = loc.pop(_DummyService)
73+
assert popped is svc
74+
assert not svc.closed
75+
assert loc[_DummyService] is None
76+
77+
def test_pop_missing_returns_none(self):
78+
loc = ServiceLocator()
79+
assert loc.pop(_DummyService) is None
80+
81+
def test_close_all(self):
82+
loc = ServiceLocator()
83+
svc1 = _DummyService()
84+
svc2 = _ServiceNoClose()
85+
loc[_DummyService] = svc1
86+
loc[_ServiceNoClose] = svc2
87+
loc.close_all()
88+
assert svc1.closed
89+
assert loc[_DummyService] is None
90+
assert loc[_ServiceNoClose] is None
91+
92+
def test_multiple_service_types(self):
93+
loc = ServiceLocator()
94+
svc1 = _DummyService()
95+
svc2 = _ServiceNoClose()
96+
loc[_DummyService] = svc1
97+
loc[_ServiceNoClose] = svc2
98+
assert loc[_DummyService] is svc1
99+
assert loc[_ServiceNoClose] is svc2
100+
101+
def test_base_class_key(self):
102+
"""Can register under a base class and retrieve by it."""
103+
104+
class Base:
105+
pass
106+
107+
class Impl(Base):
108+
pass
109+
110+
loc = ServiceLocator()
111+
impl = Impl()
112+
loc[Base] = impl
113+
assert loc[Base] is impl

0 commit comments

Comments
 (0)