From 6ef1284de458d863093283985e66d946719cb5a5 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 25 Feb 2021 12:07:51 +0100 Subject: [PATCH 1/7] Include storable functions as simulation objects --- openpathsampling/experimental/simstore/storage.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/openpathsampling/experimental/simstore/storage.py b/openpathsampling/experimental/simstore/storage.py index 59de2e795..37b818fb4 100644 --- a/openpathsampling/experimental/simstore/storage.py +++ b/openpathsampling/experimental/simstore/storage.py @@ -113,6 +113,8 @@ def initialize_with_mode(self, mode): self._load_missing_info_tables(table_to_class) sim_objs = {get_uuid(obj): obj for obj in self.simulation_objects} + sim_objs.update({get_uuid(obj): obj + for obj in self.storable_functions}) self._simulation_objects.update(sim_objs) self._update_pseudo_tables(sim_objs) @@ -441,7 +443,10 @@ def sync_all(self): def _cache_simulation_objects(self): # load up all the simulation objects try: - backend_iter = self.backend.table_iterator('simulation_objects') + backend_iter = itertools.chain( + self.backend.table_iterator('simulation_objects'), + self.backend.table_iterator('storable_functions') + ) sim_obj_uuids = [row.uuid for row in backend_iter] except KeyError: # TODO: this should probably be a custom error; don't rely on From 151b3a688d2a0f8d56d5b766890e5e911827d136 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Fri, 26 Feb 2021 11:07:24 +0100 Subject: [PATCH 2/7] more support on memory_backend and storage Parts of ensuring these can be used independently for testing. --- openpathsampling/experimental/simstore/__init__.py | 1 + openpathsampling/experimental/simstore/memory_backend.py | 7 +++++++ openpathsampling/experimental/simstore/storage.py | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/openpathsampling/experimental/simstore/__init__.py b/openpathsampling/experimental/simstore/__init__.py index 137f052fb..c78e85e7d 100644 --- a/openpathsampling/experimental/simstore/__init__.py +++ b/openpathsampling/experimental/simstore/__init__.py @@ -4,6 +4,7 @@ ) from .class_info import ClassInfoContainer, ClassInfo from .sql_backend import SQLStorageBackend +from .memory_backend import MemoryStorageBackend from . import dict_serialization_helpers from .storable_functions import ( Processor, StorableFunctionConfig, StorableFunctionResults, diff --git a/openpathsampling/experimental/simstore/memory_backend.py b/openpathsampling/experimental/simstore/memory_backend.py index f6bb4dd03..c404eb6c3 100644 --- a/openpathsampling/experimental/simstore/memory_backend.py +++ b/openpathsampling/experimental/simstore/memory_backend.py @@ -79,9 +79,16 @@ def __init__(self, mode='w'): self.uuid_table = {} # maps UUID to table name self._table_to_class = {} + @property + def identifier(self): + return hex(id(self)) + def close(self): pass # no such thing as closing here, but may be needed for API + def register_type(self, type_str, backend_type): + pass # no need to do anything here? + def register_schema(self, schema, table_to_class, metadata=None): for table_name, schema_entries in schema.items(): logger.debug("Registering table: %s" % table_name) diff --git a/openpathsampling/experimental/simstore/storage.py b/openpathsampling/experimental/simstore/storage.py index 37b818fb4..dea845b8f 100644 --- a/openpathsampling/experimental/simstore/storage.py +++ b/openpathsampling/experimental/simstore/storage.py @@ -138,6 +138,10 @@ def _load_missing_info_tables(self, table_to_class): raise RuntimeError("Unable to register existing database " + "tables: " + str(missing_info_tables)) + def register_from_tables(self, table_names, classes): + # override in subclass to handle special lookups + pass + def stash(self, objects): objects = tools.listify(objects) self._stashed.extend(objects) From 1cd473668b7153d8a1e89b6494e4e8e9cbdb375a Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Fri, 26 Feb 2021 11:55:35 +0100 Subject: [PATCH 3/7] Fixes for storable function behavior --- openpathsampling/experimental/simstore/sql_backend.py | 8 ++++---- openpathsampling/experimental/simstore/storage.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/openpathsampling/experimental/simstore/sql_backend.py b/openpathsampling/experimental/simstore/sql_backend.py index dd02dc74b..0470dc580 100644 --- a/openpathsampling/experimental/simstore/sql_backend.py +++ b/openpathsampling/experimental/simstore/sql_backend.py @@ -413,10 +413,10 @@ def load_storable_function_results(self, table_name, uuids): table = self.metadata.tables[table_name] results = [] for uuid_block in tools.block(uuids, self.max_query_size): - # logger - uuid_sel = table.select( - sql.exists().where(table.c.uuid.in_(uuid_block)) - ) + # uuid_sel = table.select( + # sql.exists().where(table.c.uuid.in_(uuid_block)) + # ) + uuid_sel = table.select().where(table.c.uuid.in_(uuid_block)) with self.engine.connect() as conn: res = conn.execute(uuid_sel) res = res.fetchall() diff --git a/openpathsampling/experimental/simstore/storage.py b/openpathsampling/experimental/simstore/storage.py index dea845b8f..439e0a08c 100644 --- a/openpathsampling/experimental/simstore/storage.py +++ b/openpathsampling/experimental/simstore/storage.py @@ -259,7 +259,6 @@ def save(self, obj_list, use_cache=True): if not input_uuids: return # exit early if everything is already in storage - # check default table for things to register; register them # TODO: move to function: self.register_missing(by_table) # TODO: convert to while? @@ -291,6 +290,7 @@ def save(self, obj_list, use_cache=True): old_missing = missing # TODO: move to function self.store_sfr_results(by_table) + self.save_function_results() # always for canonical has_sfr = (self.class_info.sfr_info is not None and self.class_info.sfr_info.table in by_table) if has_sfr: From 706741bfbbce863bfd932818f21e51df9f3fd17c Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Fri, 26 Feb 2021 13:38:54 +0100 Subject: [PATCH 4/7] Add test_storable_function_integration --- .../test_storable_function_integration.py | 287 ++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 openpathsampling/experimental/simstore/test_storable_function_integration.py diff --git a/openpathsampling/experimental/simstore/test_storable_function_integration.py b/openpathsampling/experimental/simstore/test_storable_function_integration.py new file mode 100644 index 000000000..519cbf562 --- /dev/null +++ b/openpathsampling/experimental/simstore/test_storable_function_integration.py @@ -0,0 +1,287 @@ +"""Integration tests for storable functions. + +This includes more complicated tests of storable functions to ensure that +certain behaviors are preserved when interacting with storage. +""" + +import pytest +import mock + +from openpathsampling.experimental.simstore import ( + StorableFunction, GeneralStorage, MemoryStorageBackend, + SQLStorageBackend, ClassInfo, ClassInfoContainer, + storable_function_find_uuids, StorableFunctionResults, + CallableCodec +) +from openpathsampling.experimental.simstore.custom_json import ( + JSONSerializerDeserializer, DEFAULT_CODECS +) + +from openpathsampling.experimental.simstore.serialization_helpers import \ + default_find_uuids + +from openpathsampling.experimental.simstore.uuids import get_uuid + +from openpathsampling.experimental.simstore.attribute_handlers import \ + DEFAULT_HANDLERS + + +from openpathsampling.netcdfplus import StorableNamedObject + +class InputObj(StorableNamedObject): + pass # just a thing that has a UUID + +_schema = { + 'input_objs': [], + 'storable_functions': [('json', 'json_obj')], + 'simulation_objects': [('json', 'json_obj')], +} + +_json = JSONSerializerDeserializer(DEFAULT_CODECS + [CallableCodec()]) + +_serialization = ClassInfoContainer( + default_info=ClassInfo( + table='simulation_objects', + cls=StorableNamedObject, + serializer=_json.simobj_serializer, + deserializer=_json.simobj_deserializer, + find_uuids=default_find_uuids + ), + sfr_info=ClassInfo( + table="function_results", + cls=StorableFunctionResults, + serializer=StorableFunctionResults.to_dict, + deserializer=lambda x: x, # not used + find_uuids=default_find_uuids + ), + schema=_schema, + class_info_list=[ + ClassInfo(table='input_objs', cls=InputObj), + ClassInfo(table='storable_functions', cls=StorableFunction, + find_uuids=storable_function_find_uuids, + serializer=_json.simobj_serializer, + deserializer=_json.simobj_deserializer), + ] +) + +for info in _serialization.class_info_list: + info.set_defaults(_schema, DEFAULT_HANDLERS) + + +class Mock(StorableNamedObject): + # can't serialize mock.Mock, so we make our own + def __init__(self, return_value): + super().__init__() + self.call_count = 0 + self.return_value = return_value + + def __call__(self, obj): + self.call_count += 1 + return self.return_value + + +def test_setup(): + # test that we can store and load at all + backend = MemoryStorageBackend() + storage = GeneralStorage(backend=backend, + class_info=_serialization, + schema=_schema) + obj = InputObj() + assert storage.backend.table_len('input_objs') == 0 + storage.save(obj) + assert storage.backend.table_len('input_objs') == 1 + func = Mock(return_value='foo') + sf = StorableFunction(func).named('foo-return') + sf_uuid = get_uuid(sf) + assert not storage.backend.has_table(sf_uuid) + assert len(sf.local_cache) == 0 + assert sf(obj) == 'foo' + assert len(sf.local_cache) == 1 + storage.save(sf) + assert storage.backend.has_table(sf_uuid) + + +def test_multiple_storage(tmpdir): + # TODO: This should probably be refactored into several individual tests + # that are each a little smaller. However, the main goal is to ensure + # that this functionality gets tested + inp1 = InputObj() + inp2 = InputObj() + storage = GeneralStorage( + backend=SQLStorageBackend(tmpdir.join("st1.db"), mode='w'), + class_info=_serialization, + schema=_schema + ) + func = Mock(return_value='foo') + sf = StorableFunction(func).named('foo-return') + out1 = sf(inp1) + storage.save(sf) + GeneralStorage._known_storages = {} + storage = GeneralStorage( + backend=SQLStorageBackend(tmpdir.join("st1.db"), mode='r'), + class_info=_serialization, + schema=_schema + ) + sf = storage.storable_functions[0] + assert sf.name == 'foo-return' + assert len(sf.local_cache) == 0 + assert sf.func.call_count == 1 + + # load result from storage: don't call func; do cache result + _ = sf(inp1) + assert sf.func.call_count == 1 + assert len(sf.local_cache) == 1 + + # save to a new storage: this should save the cached result, too! + _ = sf(inp2) + assert sf.func.call_count == 2 + assert len(sf.local_cache) == 2 + st2 = GeneralStorage( + backend=SQLStorageBackend(tmpdir.join("st2.db"), mode='w'), + class_info=_serialization, + schema=_schema + ) + st2.save(sf) + assert st2.backend.table_len(get_uuid(sf)) == 2 + + # save one result to one storage, the other result to another storage; + # can we get both results without calling the function? + GeneralStorage._known_storages = {} + storage = GeneralStorage( + backend=SQLStorageBackend(tmpdir.join("st1.db"), mode='r'), + class_info=_serialization, + schema=_schema + ) + sf = storage.storable_functions[0] + assert sf.name == 'foo-return' + assert len(sf.local_cache) == 0 + assert sf.func.call_count == 1 + st2 = GeneralStorage( + backend=SQLStorageBackend(tmpdir.join("st2.db"), mode='w'), + class_info=_serialization, + schema=_schema + ) + _ = sf(inp2) + st2.save(sf) + assert sf.func.call_count == 2 + res1 = sf(inp1) + assert sf.func.call_count == 2 + res2 = sf(inp2) + assert sf.func.call_count == 2 + + +class Container(StorableNamedObject): + def __init__(self, cv): + super().__init__() + self.cv = cv + + def __call__(self, inp): + return self.cv(inp)[0] + +@pytest.fixture +def inputs_and_func(): + inp1 = InputObj() + inp2 = InputObj() + func = Mock(return_value='foo') + sf = StorableFunction(func).named('foo-return') + container = Container(sf).named("container") + return inp1, inp2, func, sf, container + +def _store_via_container(backend, container, inp, call_count, + backend_count): + storage = GeneralStorage( + backend=backend, + class_info=_serialization, + schema=_schema + ) + assert container(inp) == 'f' + assert container.cv.func.call_count == call_count + storage.save(container) + sf_uuid = str(get_uuid(container.cv)) + assert storage.backend.has_table(sf_uuid) + assert storage.backend.table_len(sf_uuid) == backend_count + +def _load_container(storage, sf): + container_list = [obj for obj in storage.simulation_objects + if obj.name == 'container'] + assert len(container_list) == 1 + container = container_list[0] + assert get_uuid(container.cv) == get_uuid(sf) + assert container.cv is not sf # should not be the same obj in memory + assert container.cv.func.call_count == 1 + assert len(container.cv.local_cache) == 0 + return container + +def test_storage_inside_other_object(tmpdir, inputs_and_func): + inp1, inp2, func, sf, container = inputs_and_func + be_1w = SQLStorageBackend(tmpdir.join("st1.db"), mode='w') + _store_via_container(be_1w, container, inp1, call_count=1, + backend_count=1) + + + GeneralStorage._known_storages = {} + storage = GeneralStorage( + backend=SQLStorageBackend(tmpdir.join("st1.db"), mode='a'), + class_info=_serialization, + schema=_schema + ) + container = _load_container(storage, sf) + + # same store; store new result should work + assert container(inp2) == 'f' + assert container.cv.func.call_count == 2 + storage.save([container, inp2]) + sf_uuid = str(get_uuid(sf)) + assert storage.backend.table_len(sf_uuid) == 2 + +def test_multiple_storage_inside_other_object(tmpdir, inputs_and_func): + inp1, inp2, func, sf, container = inputs_and_func + sf_uuid = str(get_uuid(sf)) + be_1w = SQLStorageBackend(tmpdir.join("st1.db"), mode='w') + _store_via_container(be_1w, container, inp1, call_count=1, + backend_count=1) + + # different store, first store one result then store the other + GeneralStorage._known_storages = {} + storage = GeneralStorage( + backend=SQLStorageBackend(tmpdir.join("st1.db"), mode='r'), + class_info=_serialization, + schema=_schema + ) + container = _load_container(storage, sf) + + st2 = GeneralStorage( + backend=SQLStorageBackend(tmpdir.join('st2.db'), mode='w'), + class_info=_serialization, + schema=_schema + ) + # store container before using it: should not add table + st2.save(container) + assert not st2.backend.has_table(sf_uuid) + assert len(container.cv.local_cache) == 0 + assert container.cv.func.call_count == 1 + + # now we use container on inp2; should call CV; should disk-cache + assert container(inp2) == 'f' + assert container.cv.func.call_count == 2 + assert len(container.cv.local_cache) == 1 + st2.save([container, inp2]) + assert st2.backend.has_table(sf_uuid) + assert st2.backend.table_len(sf_uuid) == 1 + + # now we calculate and save the value with inp1; should load from + # existing storage (st1) + assert container.cv.func.call_count == 2 + assert len(container.cv.local_cache) == 1 + assert container(inp1) == 'f' + assert container.cv.func.call_count == 2 + assert len(container.cv.local_cache) == 2 + st2.save([container, inp1]) + assert st2.backend.table_len(sf_uuid) == 2 + + # now clear the local cache and load inp2; should load from st2 without + # calculating + container.cv.local_cache.clear() + assert container.cv.func.call_count == 2 + assert container(inp2) == 'f' + # assert container.cv.func.call_count == 2 # TODO: get this to work From 1a952b0596a58cea4037cfd8e576db398e814062 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Fri, 26 Feb 2021 17:40:53 +0100 Subject: [PATCH 5/7] Updates to use multiple handlers per func --- .../simstore/storable_functions.py | 69 +++++++++++++++---- .../experimental/simstore/storage.py | 1 + .../simstore/test_storable_function.py | 2 +- .../test_storable_function_integration.py | 2 +- 4 files changed, 57 insertions(+), 17 deletions(-) diff --git a/openpathsampling/experimental/simstore/storable_functions.py b/openpathsampling/experimental/simstore/storable_functions.py index 09992fad7..fd29b117e 100644 --- a/openpathsampling/experimental/simstore/storable_functions.py +++ b/openpathsampling/experimental/simstore/storable_functions.py @@ -222,6 +222,21 @@ def from_dict(cls, dct): obj.result_dict = dct['result_dict'] +def _storage_exception_msg(func, storage, exc): + func_id = str(get_uuid(func)) + if func.is_named: + func_id += " (" + func.name + ")" + try: + storage_id = " in " + str(storage.identifier) + "." + except: + storage_id = "." + + msg = ("A problem occured while loading disk-cached results. " + "Function " + func_id + storage_id + "\n" + str(type(exc)) + + str(exc)) + return msg + + class StorableFunction(StorableNamedObject): """Function wrapper, providing result caching and storage to disk. @@ -254,18 +269,18 @@ def __init__(self, func, func_config=None, store_source=None, **kwargs): self.local_cache = None # set correctly by self.mode setter self._disk_cache = True - self._handler = None + self._handlers = set([]) self.mode = 'analysis' - def set_handler(self, storage, override=False): - if not override and self._handler is not None: - raise RuntimeError("Handler for this StorableFunction has " - + "already been set:" + str(self._handler)) - self._handler = storage + def add_handler(self, storage, override=False): + self._handlers.add(storage) + + def remove_handler(self, handler): + self._handlers.discard(handler) @property def has_handler(self): - return self._handler is not None + return len(self._handlers) > 0 @property def disk_cache(self): @@ -314,11 +329,14 @@ def from_dict(cls, dct): def preload_cache(self, storage=None): if storage is None: - storage = self._handler.storage + storages = [h.storage for h in self._handlers] + else: + storages = [storage] uuid = get_uuid(self) - cache_values = storage.backend.load_storable_function_table(uuid) - self.local_cache.cache_results(cache_values) + for storage in storages: + cache = storage.backend.load_storable_function_table(uuid) + self.local_cache.cache_results(cache) def is_scalar(self, item): """Determine whether the input needs to be wrapped in a list. @@ -360,10 +378,27 @@ def _get_cached(self, uuid_items): return self.local_cache.get_results_as_dict(uuid_items) def _get_storage(self, uuid_items): - if not self._handler: + if not self.has_handler: return {}, uuid_items - return self._handler.get_function_results(get_uuid(self), uuid_items) + missing = uuid_items + found = {} + my_uuid = get_uuid(self) + for handler in self._handlers: + try: + uuid_map, missing = handler.get_function_results( + my_uuid, missing + ) + except Exception as e: + # for any error, we just warn -- can be correctly calculated + # in eval + msg = _storage_exception_msg(self, handler.storage, e) + warnings.warn(msg) + uuid_map = {} + + found.update(uuid_map) + + return found, missing def __call__(self, items): # important: implementation is that we always try to take an @@ -475,9 +510,13 @@ def register_function(self, func, example_result=None): if not is_registered: self.all_functions[func_uuid].append(func) - # set handler; only if the table exists - if not func.has_handler and (add_table or not needs_table): - func.set_handler(self) + if add_table or not needs_table: + func.add_handler(self) + + def close(self): + for uuid, copies in self.all_functions.items(): + for func in copies: + func.remove_handler(self) def clear_non_canonical(self): self.all_functions = collections.defaultdict(list) diff --git a/openpathsampling/experimental/simstore/storage.py b/openpathsampling/experimental/simstore/storage.py index 439e0a08c..0cba668ce 100644 --- a/openpathsampling/experimental/simstore/storage.py +++ b/openpathsampling/experimental/simstore/storage.py @@ -149,6 +149,7 @@ def stash(self, objects): def close(self): # TODO: should sync on close self.backend.close() + self._sf_handler.close() for fallback in self.fallbacks: fallback.close() diff --git a/openpathsampling/experimental/simstore/test_storable_function.py b/openpathsampling/experimental/simstore/test_storable_function.py index 2c6c57e05..6d9912301 100644 --- a/openpathsampling/experimental/simstore/test_storable_function.py +++ b/openpathsampling/experimental/simstore/test_storable_function.py @@ -386,7 +386,7 @@ def test_register_function(self, has_table, with_result): assert self.sf_handler.all_functions[uuid] == [self.func] if not unable_to_register: assert self.func.has_handler - assert self.func._handler == self.sf_handler + assert self.func._handlers == {self.sf_handler} assert self.sf_handler.functions == [self.func] # make a copy of the func diff --git a/openpathsampling/experimental/simstore/test_storable_function_integration.py b/openpathsampling/experimental/simstore/test_storable_function_integration.py index 519cbf562..6971cf856 100644 --- a/openpathsampling/experimental/simstore/test_storable_function_integration.py +++ b/openpathsampling/experimental/simstore/test_storable_function_integration.py @@ -284,4 +284,4 @@ def test_multiple_storage_inside_other_object(tmpdir, inputs_and_func): container.cv.local_cache.clear() assert container.cv.func.call_count == 2 assert container(inp2) == 'f' - # assert container.cv.func.call_count == 2 # TODO: get this to work + assert container.cv.func.call_count == 2 # TODO: get this to work From 6f6423ab1c626abc90eefcaf5ec16598cd458a7a Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sun, 28 Feb 2021 17:35:57 +0100 Subject: [PATCH 6/7] Tests for losing storable function handlers --- .../test_storable_function_integration.py | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/openpathsampling/experimental/simstore/test_storable_function_integration.py b/openpathsampling/experimental/simstore/test_storable_function_integration.py index 6971cf856..f77845d0b 100644 --- a/openpathsampling/experimental/simstore/test_storable_function_integration.py +++ b/openpathsampling/experimental/simstore/test_storable_function_integration.py @@ -5,7 +5,7 @@ """ import pytest -import mock +import os from openpathsampling.experimental.simstore import ( StorableFunction, GeneralStorage, MemoryStorageBackend, @@ -200,6 +200,7 @@ def _store_via_container(backend, container, inp, call_count, sf_uuid = str(get_uuid(container.cv)) assert storage.backend.has_table(sf_uuid) assert storage.backend.table_len(sf_uuid) == backend_count + return storage def _load_container(storage, sf): container_list = [obj for obj in storage.simulation_objects @@ -285,3 +286,38 @@ def test_multiple_storage_inside_other_object(tmpdir, inputs_and_func): assert container.cv.func.call_count == 2 assert container(inp2) == 'f' assert container.cv.func.call_count == 2 # TODO: get this to work + +def test_closing(tmpdir, inputs_and_func): + inp1, inp2, func, sf, container = inputs_and_func + sf_uuid = str(get_uuid(sf)) + st1_fname = tmpdir.join("st1.db") + be_1w = SQLStorageBackend(st1_fname, mode='w') + storage = _store_via_container(be_1w, container, inp1, call_count=1, + backend_count=1) + + assert sf.has_handler + storage.close() + assert not sf.has_handler + +def test_storage_file_problem(tmpdir, inputs_and_func): + inp1, inp2, func, sf, container = inputs_and_func + sf_uuid = str(get_uuid(sf)) + st1_fname = tmpdir.join("st1.db") + be_1w = SQLStorageBackend(st1_fname, mode='w') + _store_via_container(be_1w, container, inp1, call_count=1, + backend_count=1) + + GeneralStorage._known_storages = {} + storage = GeneralStorage( + backend=SQLStorageBackend(st1_fname, mode='r'), + class_info=_serialization, + schema=_schema + ) + container = _load_container(storage, sf) + + assert container.cv.func.call_count == 1 + os.remove(st1_fname) # naughty user behavior + + with pytest.warns(UserWarning): + assert container(inp1) == 'f' + assert container.cv.func.call_count == 2 From 12b0158059551f7569e6d3c97dcd7c3de53b57fb Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 1 Mar 2021 14:15:29 +0100 Subject: [PATCH 7/7] Update openpathsampling/experimental/simstore/test_storable_function_integration.py Co-authored-by: Sander Roet --- .../experimental/simstore/test_storable_function_integration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpathsampling/experimental/simstore/test_storable_function_integration.py b/openpathsampling/experimental/simstore/test_storable_function_integration.py index f77845d0b..44dc75a0e 100644 --- a/openpathsampling/experimental/simstore/test_storable_function_integration.py +++ b/openpathsampling/experimental/simstore/test_storable_function_integration.py @@ -285,7 +285,7 @@ def test_multiple_storage_inside_other_object(tmpdir, inputs_and_func): container.cv.local_cache.clear() assert container.cv.func.call_count == 2 assert container(inp2) == 'f' - assert container.cv.func.call_count == 2 # TODO: get this to work + assert container.cv.func.call_count == 2 def test_closing(tmpdir, inputs_and_func): inp1, inp2, func, sf, container = inputs_and_func