diff --git a/tconnectsync/api/webui.py b/tconnectsync/api/webui.py index 04124df..bd67d34 100644 --- a/tconnectsync/api/webui.py +++ b/tconnectsync/api/webui.py @@ -1,4 +1,4 @@ -from typing import List, Tuple +from typing import Dict, List, Tuple import requests import urllib import datetime @@ -70,7 +70,7 @@ class WebUIScraper: Returns a mapping between pump/device IDs and information about that device, including the GUID used for obtaining pump settings. """ - def my_devices(self): + def my_devices(self) -> Dict[str, DeviceSettings]: devices = {} r = self.get('myaccount/my_devices.aspx') soup = BeautifulSoup(r.content, features='lxml') @@ -274,7 +274,7 @@ class WebUIScraper: Wraps a call to my_devices to identify the device GUID from the given pump serial, and then returns device_settings_from_guid. """ - def device_settings(self, pump_serial): + def device_settings(self, pump_serial: str) -> Tuple[List[Profile], DeviceSettings]: devices = self.my_devices() if str(pump_serial) in devices.keys(): dev = devices[str(pump_serial)] diff --git a/tconnectsync/features.py b/tconnectsync/features.py index 12dd794..7506635 100644 --- a/tconnectsync/features.py +++ b/tconnectsync/features.py @@ -7,6 +7,7 @@ IOB = "IOB" BOLUS_BG = "BOLUS_BG" CGM = "CGM" PUMP_EVENTS = "PUMP_EVENTS" +PROFILES = "PROFILES" DEFAULT_FEATURES = [ BASAL, @@ -17,7 +18,8 @@ ALL_FEATURES = [ BASAL, BOLUS, IOB, - PUMP_EVENTS + PUMP_EVENTS, + PROFILES ] diff --git a/tconnectsync/nightscout.py b/tconnectsync/nightscout.py index c890de7..f4b0dd5 100644 --- a/tconnectsync/nightscout.py +++ b/tconnectsync/nightscout.py @@ -43,7 +43,7 @@ class NightscoutApi: 'api-secret': hashlib.sha1(self.secret.encode()).hexdigest() }, verify=self.verify) if r.status_code != 200: - raise ApiException(r.status_code, "Nightscout upload response: %s" % r.text) + raise ApiException(r.status_code, "Nightscout upload %s response: %s" % (r.status_code, r.text)) def delete_entry(self, entity): r = requests.delete(urljoin(self.url, 'api/v1/' + entity + '?api_secret=' + self.secret), json={}, headers={ @@ -52,7 +52,7 @@ class NightscoutApi: 'api-secret': hashlib.sha1(self.secret.encode()).hexdigest() }, verify=self.verify) if r.status_code != 200: - raise ApiException(r.status_code, "Nightscout delete response: %s" % r.text) + raise ApiException(r.status_code, "Nightscout delete %s response: %s" % (r.status_code, r.text)) def put_entry(self, ns_format, entity): r = requests.put(urljoin(self.url, 'api/v1/' + entity + '?api_secret=' + self.secret), json=ns_format, headers={ @@ -61,7 +61,7 @@ class NightscoutApi: 'api-secret': hashlib.sha1(self.secret.encode()).hexdigest() }, verify=self.verify) if r.status_code != 200: - raise ApiException(r.status_code, "Nightscout put response: %s" % r.text) + raise ApiException(r.status_code, "Nightscout put %s response: %s" % (r.status_code, r.text)) def last_uploaded_entry(self, eventType, time_start=None, time_end=None): def internal(t_to_space): @@ -70,7 +70,7 @@ class NightscoutApi: 'api-secret': hashlib.sha1(self.secret.encode()).hexdigest() }, verify=self.verify) if latest.status_code != 200: - raise ApiException(latest.status_code, "Nightscout last_uploaded_entry response: %s" % latest.text) + raise ApiException(latest.status_code, "Nightscout last_uploaded_entry %s response: %s" % (latest.status_code, latest.text)) j = latest.json() if j and len(j) > 0: @@ -100,7 +100,7 @@ class NightscoutApi: 'api-secret': hashlib.sha1(self.secret.encode()).hexdigest() }, verify=self.verify) if latest.status_code != 200: - raise ApiException(latest.status_code, "Nightscout last_uploaded_bg_entry response: %s" % latest.text) + raise ApiException(latest.status_code, "Nightscout last_uploaded_bg_entry %s response: %s" % (latest.status_code, latest.text)) j = latest.json() if j and len(j) > 0: @@ -121,7 +121,7 @@ class NightscoutApi: 'api-secret': hashlib.sha1(self.secret.encode()).hexdigest() }, verify=self.verify) if latest.status_code != 200: - raise ApiException(latest.status_code, "Nightscout activity response: %s" % latest.text) + raise ApiException(latest.status_code, "Nightscout activity %s response: %s" % (latest.status_code, latest.text)) j = latest.json() if j and len(j) > 0: @@ -147,26 +147,15 @@ class NightscoutApi: return status.json() """ - Returns information on configured Nightscout profiles, over the optional time range. - Will only return the most recent profile for the given range. + Returns information on the currently configured Nightscout profile data store + (contains all profiles in Nightscout under one mongo object). """ - def profiles(self, time_start=None, time_end=None): - def internal(t_to_space): - dateFilter = time_range('created_at', time_start, time_end, t_to_space=t_to_space) - latest = requests.get(urljoin(self.url, 'api/v1/profile.json?' + dateFilter + '&ts=' + str(time.time())), headers={ - 'api-secret': hashlib.sha1(self.secret.encode()).hexdigest() - }, verify=self.verify) - if latest.status_code != 200: - raise ApiException(latest.status_code, "Nightscout profiles response: %s" % latest.text) - - j = latest.json() - if j and len(j) > 0: - return j[0] - return None - - ret = internal(False) - if ret is None and (time_start or time_end): - ret = internal(True) - if ret is not None: - logger.warning("profiles with time_start=%s time_end=%s only returned data when timestamps contained a space" % (time_start, time_end)) - return ret \ No newline at end of file + def current_profile(self, time_start=None, time_end=None): + r = requests.get(urljoin(self.url, 'api/v1/profile/current?api_secret=' + self.secret), json={}, headers={ + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'api-secret': hashlib.sha1(self.secret.encode()).hexdigest() + }, verify=self.verify) + if r.status_code != 200: + raise ApiException(r.status_code, "Nightscout current_profile %s response: %s" % (r.status_code, r.text)) + return r.json() \ No newline at end of file diff --git a/tconnectsync/process.py b/tconnectsync/process.py index ad68a9c..e32cb37 100644 --- a/tconnectsync/process.py +++ b/tconnectsync/process.py @@ -29,8 +29,9 @@ from .sync.pump_events import ( process_basalsuspension_events, ns_write_pump_events ) +from .sync.profile import process_profiles from .parser.tconnect import TConnectEntry -from .features import BASAL, BOLUS, IOB, BOLUS_BG, CGM, DEFAULT_FEATURES, PUMP_EVENTS +from .features import BASAL, BOLUS, IOB, BOLUS_BG, CGM, DEFAULT_FEATURES, PUMP_EVENTS, PROFILES from tconnectsync.sync import basal logger = logging.getLogger(__name__) @@ -41,18 +42,21 @@ cycle of synchronizing data within the time range. If pretend is true, then doesn't actually write data to Nightscout. """ def process_time_range(tconnect, nightscout, time_start, time_end, pretend, features=DEFAULT_FEATURES): - logger.info("Downloading t:connect ControlIQ data") - try: - ciqTherapyTimelineData = tconnect.controliq.therapy_timeline(time_start, time_end) - except ApiException as e: - # The ControlIQ API returns a 404 if the user did not have a ControlIQ enabled - # device in the time range which is queried. Since it launched in early 2020, - # ignore 404's before February. - if e.status_code == 404 and time_start.date() < datetime.date(2020, 2, 1): - logger.warning("Ignoring HTTP 404 for ControlIQ API request before Feb 2020") - ciqTherapyTimelineData = None - else: - raise e + ciqTherapyTimelineData = None + if BASAL in features or PUMP_EVENTS in features: + logger.info("Downloading t:connect ControlIQ data") + + try: + ciqTherapyTimelineData = tconnect.controliq.therapy_timeline(time_start, time_end) + except ApiException as e: + # The ControlIQ API returns a 404 if the user did not have a ControlIQ enabled + # device in the time range which is queried. Since it launched in early 2020, + # ignore 404's before February. + if e.status_code == 404 and time_start.date() < datetime.date(2020, 2, 1): + logger.warning("Ignoring HTTP 404 for ControlIQ API request before Feb 2020") + ciqTherapyTimelineData = None + else: + raise e csvReadingData = None csvIobData = None @@ -182,6 +186,10 @@ def process_time_range(tconnect, nightscout, time_start, time_end, pretend, feat logger.debug("Writing iob events") added += ns_write_iob_events(nightscout, iobEvents, pretend=pretend) logger.debug("Finished writing iob events") + + if PROFILES in features: + logger.debug("Running profiles feature") + process_profiles(tconnect, nightscout, pretend=pretend) logger.info("Wrote %d events to Nightscout this process cycle" % added) return added diff --git a/tconnectsync/secret.py b/tconnectsync/secret.py index 4b4643b..ac60a04 100644 --- a/tconnectsync/secret.py +++ b/tconnectsync/secret.py @@ -16,6 +16,13 @@ else: def get(val, default=None): return os.environ.get(val, values.get(val, default)) +def get_one_of(name, default=None, options=[]): + val = get(name, default) + if val not in options: + print("Error: %s must be one of: %s" % (name, options)) + print("Current value: %s" % val) + sys.exit(1) + def get_number(name, default): val = get(name, default) try: @@ -59,6 +66,8 @@ AUTOUPDATE_FAILURE_MINUTES = get_number('AUTOUPDATE_FAILURE_MINUTES', '15') # 15 AUTOUPDATE_RESTART_ON_FAILURE = get_bool('AUTOUPDATE_RESTART_ON_FAILURE', 'true') AUTOUPDATE_MAX_LOOP_INVOCATIONS = get_number('AUTOUPDATE_MAX_LOOP_INVOCATIONS', '-1') +NIGHTSCOUT_PROFILE_UPLOAD_MODE = get_one_of('NIGHTSCOUT_PROFILE_UPLOAD_MODE', 'add', ['add', 'replace']) + # Default Nightscout profile segment fields which aren't stored by Tandem NIGHTSCOUT_PROFILE_CARBS_HR_VALUE = get('NIGHTSCOUT_PROFILE_CARBS_HR_VALUE', '20') NIGHTSCOUT_PROFILE_DELAY_VALUE = get('NIGHTSCOUT_PROFILE_DELAY_VALUE', '20') diff --git a/tconnectsync/sync/profile.py b/tconnectsync/sync/profile.py index 62ecba6..318bb20 100644 --- a/tconnectsync/sync/profile.py +++ b/tconnectsync/sync/profile.py @@ -2,22 +2,30 @@ from typing import List, Tuple import logging import json import copy +import arrow from ..api import TConnectApi from ..domain.device_settings import Profile, DeviceSettings from ..parser.nightscout import NightscoutEntry -from ..secret import PUMP_SERIAL_NUMBER +from ..nightscout import NightscoutApi +from ..secret import PUMP_SERIAL_NUMBER, NIGHTSCOUT_PROFILE_UPLOAD_MODE logger = logging.getLogger(__name__) +def _get_default_serial_number(): + return PUMP_SERIAL_NUMBER + +def _get_default_upload_mode(): + return NIGHTSCOUT_PROFILE_UPLOAD_MODE + def get_pump_profiles(tconnect: TConnectApi, serial_number: int = None) -> Tuple[List[Profile], DeviceSettings]: all_devices = tconnect.webui.my_devices() if serial_number is None: - serial_number = PUMP_SERIAL_NUMBER + serial_number = _get_default_serial_number() if str(serial_number) not in all_devices: logger.warn("Could not find entry for provided pump serial number in t:connect device list: %s, received: %s", serial_number, all_devices) - return [] + return [], None device = all_devices[str(serial_number)] @@ -36,7 +44,7 @@ Compare pump device and Nightscout profiles, and return a final dictionary of Nightscout profile objects, with the pump profile settings overriding what is currently in Nightscout. -ns_profile_obj is the output from NightscoutApi.profiles() and should be the most +ns_profile_obj is the output from NightscoutApi.current_profile() and should be the most recent profile object in mongo. Returns the new Nightscout profile and whether it was changed. @@ -61,12 +69,12 @@ def compare_profiles(device_profiles: List[Profile], device_settings: DeviceSett existent_profiles_in_ns = set(device.keys()) & set(ns.keys()) for profile_name in existent_profiles_in_ns: - logger.info("Checking for differences for %s profile between pump and nightscout", profile_name) + logger.debug("Checking for differences for %s profile between pump and nightscout", profile_name) pump_configured_profile = device[profile_name] ns_translated_profile = NightscoutEntry.profile_store(pump_configured_profile, device_settings) ns_configured_profile = ns[profile_name] - logger.info("Comparing %s profile from pump: %s to nightscout: %s", profile_name, ns_translated_profile, ns_configured_profile) + logger.debug("Comparing %s profile from pump: %s to nightscout: %s", profile_name, ns_translated_profile, ns_configured_profile) if nightscout_profiles_identical(ns_configured_profile, ns_translated_profile): logger.info("Profile %s identical between pump and nightscout", profile_name) continue @@ -95,7 +103,7 @@ def compare_profiles(device_profiles: List[Profile], device_settings: DeviceSett def nightscout_profiles_identical(configured: dict, translated: dict) -> bool: if json.dumps(configured, sort_keys=True, indent=None) == json.dumps(translated, sort_keys=True, indent=None): - logger.debug("Initial JSON dump identical") + logger.debug("initial JSON dump identical") return True # convert all JSON values into strings @@ -129,3 +137,41 @@ def nightscout_profiles_identical(configured: dict, translated: dict) -> bool: logger.debug("profiles not identical") return False + +def setup_new_profile(ns_profile: dict) -> dict: + if '_id' in ns_profile: + del ns_profile['_id'] + + now = arrow.now().isoformat() + ns_profile['startDate'] = now + ns_profile['created_at'] = now + + return ns_profile + +def process_profiles(tconnect: TConnectApi, nightscout: NightscoutApi, pretend: bool = False, upload_mode: str = None): + if upload_mode is None: + upload_mode = _get_default_upload_mode() + + logger.debug("Checking for differences between pump and nightscout profiles: %s mode", upload_mode) + + ns_profile_obj = nightscout.current_profile() + pump_profiles, pump_settings = get_pump_profiles(tconnect) + diff, ns_profile_new = compare_profiles(pump_profiles, pump_settings, ns_profile_obj) + + if not diff: + logger.info("Pump and Nightscout profiles up to date") + return + + if upload_mode == 'add': + profile_to_upload = setup_new_profile(ns_profile_new) + logger.info("Adding new Nightscout profiles object: %s", profile_to_upload) + + if not pretend: + nightscout.upload_entry(profile_to_upload, entity='profile') + elif upload_mode == 'replace': + logger.info("Replacing new Nightscout profiles object: %s", ns_profile_new) + + if not pretend: + nightscout.put_entry(ns_profile_new, entity='profile') + else: + raise RuntimeError('invalid upload_mode: %s' % upload_mode) \ No newline at end of file diff --git a/tests/api/fake.py b/tests/api/fake.py index 8075f93..d15496e 100644 --- a/tests/api/fake.py +++ b/tests/api/fake.py @@ -44,6 +44,12 @@ class WebUIScraper(tconnectsync.api.webui.WebUIScraper): def __init__(self, controliq): self.controliq = controliq + def my_devices(self): + raise NotImplementedError + + def device_settings(self, pump_guid): + raise NotImplementedError + class TConnectApi(tconnectsync.api.TConnectApi): def __init__(self, email=None, password=None): if email is not None and password is not None: @@ -54,3 +60,4 @@ class TConnectApi(tconnectsync.api.TConnectApi): _ciq = ControlIQApi() _ws2 = WS2Api() _android = AndroidApi() + _webui = WebUIScraper(_ciq) diff --git a/tests/test_process.py b/tests/test_process.py index 2e2514b..9e91fd6 100644 --- a/tests/test_process.py +++ b/tests/test_process.py @@ -4,17 +4,22 @@ import unittest import datetime import pprint import copy +from unittest.mock import patch from tconnectsync.process import process_time_range from tconnectsync.parser.nightscout import EXERCISE_EVENTTYPE, IOB_ACTIVITYTYPE, SLEEP_EVENTTYPE, NightscoutEntry -from tconnectsync.features import BASAL, BOLUS, IOB, PUMP_EVENTS -from tests.domain.test_therapy_event import BOLUS_FULL_EXAMPLES, TestCGMTherapyEvent +from tconnectsync.features import BASAL, BOLUS, IOB, PROFILES, PUMP_EVENTS +from tconnectsync.domain.device_settings import Device +from tests.secrets import build_secrets +from tests.sync.test_profile import DEVICE_PROFILE_A, DEVICE_PROFILE_B, DEVICE_SETTINGS, NS_PROFILE_A, NS_PROFILE_B, build_ns_profile + from .api.fake import TConnectApi from .nightscout_fake import NightscoutApi from .sync.test_basal import TestBasalSync from .sync.test_bolus import TestBolusSync from .sync.test_iob import TestIOBSync +from .domain.test_therapy_event import BOLUS_FULL_EXAMPLES, TestCGMTherapyEvent class TestProcessTimeRange(unittest.TestCase): maxDiff = None @@ -750,6 +755,200 @@ class TestProcessTimeRange(unittest.TestCase): self.assertDictEqual(nightscout.put_entries, {}) self.assertListEqual(nightscout.deleted_entries, []) + """Profile present on pump and not in Nightscout with PROFILES feature enabled, adds profile.""" + def test_pump_profile_added(self): + tconnect = TConnectApi() + + # datetimes are unused by the API fake + start = datetime.datetime(2021, 5, 1, 0, 0) + end = datetime.datetime(2021, 5, 3, 0, 0) + + pump_guid = '00000000-0000-0000-0000-000000000001' + serial_number = '12345' + + def fake_my_devices(): + return { + serial_number: Device( + name='test', + model_number=serial_number, + status='OK', + guid=pump_guid) + } + tconnect.webui.my_devices = fake_my_devices + + def fake_device_settings_from_guid(guid): + if guid != pump_guid: + raise RuntimeError('invalid guid') + return [DEVICE_PROFILE_A.activeProfile()], DEVICE_SETTINGS + + tconnect.webui.device_settings_from_guid = fake_device_settings_from_guid + + nightscout = NightscoutApi() + + def fake_current_profile(time_start=None, time_end=None): + return build_ns_profile({}, '') + + nightscout.current_profile = fake_current_profile + + with patch("tconnectsync.sync.profile._get_default_upload_mode") as mock_upload_mode, \ + patch("tconnectsync.sync.profile._get_default_serial_number") as mock_serial_number: + mock_upload_mode.return_value = 'add' + mock_serial_number.return_value = serial_number + + process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PROFILES]) + + self.assertEqual(len(nightscout.uploaded_entries["profile"]), 1) + self.assertDictEqual(nightscout.uploaded_entries["profile"][0]['store']['A'], NS_PROFILE_A) + self.assertDictEqual(nightscout.put_entries, {}) + self.assertListEqual(nightscout.deleted_entries, []) + + """Profile present on pump and in Nightscout with PROFILES feature enabled, does not add profile.""" + def test_pump_profile_not_updated(self): + tconnect = TConnectApi() + + # datetimes are unused by the API fake + start = datetime.datetime(2021, 5, 1, 0, 0) + end = datetime.datetime(2021, 5, 3, 0, 0) + + pump_guid = '00000000-0000-0000-0000-000000000001' + serial_number = '12345' + + def fake_my_devices(): + return { + serial_number: Device( + name='test', + model_number=serial_number, + status='OK', + guid=pump_guid) + } + tconnect.webui.my_devices = fake_my_devices + + def fake_device_settings_from_guid(guid): + if guid != pump_guid: + raise RuntimeError('invalid guid') + return [DEVICE_PROFILE_A.activeProfile()], DEVICE_SETTINGS + + tconnect.webui.device_settings_from_guid = fake_device_settings_from_guid + + nightscout = NightscoutApi() + + def fake_current_profile(time_start=None, time_end=None): + return build_ns_profile({'A': NS_PROFILE_A}, 'A') + + nightscout.current_profile = fake_current_profile + + with patch("tconnectsync.sync.profile._get_default_upload_mode") as mock_upload_mode, \ + patch("tconnectsync.sync.profile._get_default_serial_number") as mock_serial_number: + mock_upload_mode.return_value = 'add' + mock_serial_number.return_value = serial_number + + process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PROFILES]) + + self.assertEqual(len(nightscout.uploaded_entries["profile"]), 0) + self.assertDictEqual(nightscout.put_entries, {}) + self.assertListEqual(nightscout.deleted_entries, []) + + """Profile present on pump and in Nightscout with PROFILES feature enabled, with changes on pump, adds new profile object.""" + def test_pump_profile_new_entry_added(self): + tconnect = TConnectApi() + + # datetimes are unused by the API fake + start = datetime.datetime(2021, 5, 1, 0, 0) + end = datetime.datetime(2021, 5, 3, 0, 0) + + pump_guid = '00000000-0000-0000-0000-000000000001' + serial_number = '12345' + + def fake_my_devices(): + return { + serial_number: Device( + name='test', + model_number=serial_number, + status='OK', + guid=pump_guid) + } + tconnect.webui.my_devices = fake_my_devices + + def fake_device_settings_from_guid(guid): + if guid != pump_guid: + raise RuntimeError('invalid guid') + return [DEVICE_PROFILE_A, DEVICE_PROFILE_B.activeProfile()], DEVICE_SETTINGS + + tconnect.webui.device_settings_from_guid = fake_device_settings_from_guid + + nightscout = NightscoutApi() + + def fake_current_profile(time_start=None, time_end=None): + return build_ns_profile({'A': NS_PROFILE_A}, 'A') + + nightscout.current_profile = fake_current_profile + + with patch("tconnectsync.sync.profile._get_default_upload_mode") as mock_upload_mode, \ + patch("tconnectsync.sync.profile._get_default_serial_number") as mock_serial_number: + mock_upload_mode.return_value = 'add' + mock_serial_number.return_value = serial_number + + process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PROFILES]) + + self.assertEqual(len(nightscout.uploaded_entries["profile"]), 1) + self.assertEqual(len(nightscout.uploaded_entries["profile"][0]['store']), 2) + self.assertDictEqual(nightscout.uploaded_entries["profile"][0]['store']['A'], NS_PROFILE_A) + self.assertDictEqual(nightscout.uploaded_entries["profile"][0]['store']['B'], NS_PROFILE_B) + self.assertDictEqual(nightscout.put_entries, {}) + self.assertListEqual(nightscout.deleted_entries, []) + + """ + Profile present on pump and in Nightscout with PROFILES feature enabled, with changes on pump, + replaces existing profile object with NIGHTSCOUT_PROFILE_UPLOAD_MODE=replace. + """ + def test_pump_profile_new_entry_replaced(self): + tconnect = TConnectApi() + + # datetimes are unused by the API fake + start = datetime.datetime(2021, 5, 1, 0, 0) + end = datetime.datetime(2021, 5, 3, 0, 0) + + pump_guid = '00000000-0000-0000-0000-000000000001' + serial_number = '12345' + + def fake_my_devices(): + return { + serial_number: Device( + name='test', + model_number=serial_number, + status='OK', + guid=pump_guid) + } + tconnect.webui.my_devices = fake_my_devices + + def fake_device_settings_from_guid(guid): + if guid != pump_guid: + raise RuntimeError('invalid guid') + return [DEVICE_PROFILE_A, DEVICE_PROFILE_B.activeProfile()], DEVICE_SETTINGS + + tconnect.webui.device_settings_from_guid = fake_device_settings_from_guid + + nightscout = NightscoutApi() + + def fake_current_profile(time_start=None, time_end=None): + return build_ns_profile({'A': NS_PROFILE_A}, 'A') + + nightscout.current_profile = fake_current_profile + + with patch("tconnectsync.sync.profile._get_default_upload_mode") as mock_upload_mode, \ + patch("tconnectsync.sync.profile._get_default_serial_number") as mock_serial_number: + mock_upload_mode.return_value = 'replace' + mock_serial_number.return_value = serial_number + + process_time_range(tconnect, nightscout, start, end, pretend=False, features=[PROFILES]) + + self.assertDictEqual(nightscout.uploaded_entries, {}) + self.assertEqual(len(nightscout.put_entries["profile"]), 1) + self.assertEqual(len(nightscout.put_entries["profile"][0]['store']), 2) + self.assertDictEqual(nightscout.put_entries["profile"][0]['store']['A'], NS_PROFILE_A) + self.assertDictEqual(nightscout.put_entries["profile"][0]['store']['B'], NS_PROFILE_B) + self.assertListEqual(nightscout.deleted_entries, []) + if __name__ == '__main__': unittest.main() \ No newline at end of file