|  |  |  | #!/usr/bin/env python3
 | 
					
						
							|  |  |  | import contextlib
 | 
					
						
							|  |  |  | import io
 | 
					
						
							|  |  |  | import shutil
 | 
					
						
							|  |  |  | import tempfile
 | 
					
						
							|  |  |  | import os
 | 
					
						
							|  |  |  | import unittest
 | 
					
						
							|  |  |  | import pytest
 | 
					
						
							|  |  |  | import requests
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | from parameterized import parameterized
 | 
					
						
							|  |  |  | from unittest import mock
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | from openpilot.tools.lib.logreader import LogIterable, LogReader, comma_api_source, parse_indirect, ReadMode, InternalUnavailableException
 | 
					
						
							|  |  |  | from openpilot.tools.lib.route import SegmentRange
 | 
					
						
							|  |  |  | from openpilot.tools.lib.url_file import URLFileException
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | NUM_SEGS = 17  # number of segments in the test route
 | 
					
						
							|  |  |  | ALL_SEGS = list(range(NUM_SEGS))
 | 
					
						
							|  |  |  | TEST_ROUTE = "344c5c15b34f2d8a/2024-01-03--09-37-12"
 | 
					
						
							|  |  |  | QLOG_FILE = "https://commadataci.blob.core.windows.net/openpilotci/0375fdf7b1ce594d/2019-06-13--08-32-25/3/qlog.bz2"
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | def noop(segment: LogIterable):
 | 
					
						
							|  |  |  |   return segment
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | @contextlib.contextmanager
 | 
					
						
							|  |  |  | def setup_source_scenario(is_internal=False):
 | 
					
						
							|  |  |  |   with (
 | 
					
						
							|  |  |  |     mock.patch("openpilot.tools.lib.logreader.internal_source") as internal_source_mock,
 | 
					
						
							|  |  |  |     mock.patch("openpilot.tools.lib.logreader.openpilotci_source") as openpilotci_source_mock,
 | 
					
						
							|  |  |  |     mock.patch("openpilot.tools.lib.logreader.comma_api_source") as comma_api_source_mock,
 | 
					
						
							|  |  |  |   ):
 | 
					
						
							|  |  |  |     if is_internal:
 | 
					
						
							|  |  |  |       internal_source_mock.return_value = [QLOG_FILE]
 | 
					
						
							|  |  |  |     else:
 | 
					
						
							|  |  |  |       internal_source_mock.side_effect = InternalUnavailableException
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     openpilotci_source_mock.return_value = [None]
 | 
					
						
							|  |  |  |     comma_api_source_mock.return_value = [QLOG_FILE]
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     yield
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | class TestLogReader(unittest.TestCase):
 | 
					
						
							|  |  |  |   @parameterized.expand([
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}", ALL_SEGS),
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE.replace('/', '|')}", ALL_SEGS),
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}--0", [0]),
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}--5", [5]),
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}/0", [0]),
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}/5", [5]),
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}/0:10", ALL_SEGS[0:10]),
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}/0:0", []),
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}/4:6", ALL_SEGS[4:6]),
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}/0:-1", ALL_SEGS[0:-1]),
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}/:5", ALL_SEGS[:5]),
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}/2:", ALL_SEGS[2:]),
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}/2:-1", ALL_SEGS[2:-1]),
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}/-1", [ALL_SEGS[-1]]),
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}/-2", [ALL_SEGS[-2]]),
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}/-2:-1", ALL_SEGS[-2:-1]),
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}/-4:-2", ALL_SEGS[-4:-2]),
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}/:10:2", ALL_SEGS[:10:2]),
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}/5::2", ALL_SEGS[5::2]),
 | 
					
						
							|  |  |  |     (f"https://useradmin.comma.ai/?onebox={TEST_ROUTE}", ALL_SEGS),
 | 
					
						
							|  |  |  |     (f"https://useradmin.comma.ai/?onebox={TEST_ROUTE.replace('/', '|')}", ALL_SEGS),
 | 
					
						
							|  |  |  |     (f"https://useradmin.comma.ai/?onebox={TEST_ROUTE.replace('/', '%7C')}", ALL_SEGS),
 | 
					
						
							|  |  |  |     (f"https://cabana.comma.ai/?route={TEST_ROUTE}", ALL_SEGS),
 | 
					
						
							|  |  |  |   ])
 | 
					
						
							|  |  |  |   def test_indirect_parsing(self, identifier, expected):
 | 
					
						
							|  |  |  |     parsed, _, _ = parse_indirect(identifier)
 | 
					
						
							|  |  |  |     sr = SegmentRange(parsed)
 | 
					
						
							|  |  |  |     self.assertListEqual(list(sr.seg_idxs), expected, identifier)
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   @parameterized.expand([
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}", f"{TEST_ROUTE}"),
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE.replace('/', '|')}", f"{TEST_ROUTE}"),
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}--5", f"{TEST_ROUTE}/5"),
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}/0/q", f"{TEST_ROUTE}/0/q"),
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}/5:6/r", f"{TEST_ROUTE}/5:6/r"),
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}/5", f"{TEST_ROUTE}/5"),
 | 
					
						
							|  |  |  |   ])
 | 
					
						
							|  |  |  |   def test_canonical_name(self, identifier, expected):
 | 
					
						
							|  |  |  |     sr = SegmentRange(identifier)
 | 
					
						
							|  |  |  |     self.assertEqual(str(sr), expected)
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   @parameterized.expand([(True,), (False,)])
 | 
					
						
							|  |  |  |   @mock.patch("openpilot.tools.lib.logreader.file_exists")
 | 
					
						
							|  |  |  |   def test_direct_parsing(self, cache_enabled, file_exists_mock):
 | 
					
						
							|  |  |  |     os.environ["FILEREADER_CACHE"] = "1" if cache_enabled else "0"
 | 
					
						
							|  |  |  |     qlog = tempfile.NamedTemporaryFile(mode='wb', delete=False)
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     with requests.get(QLOG_FILE, stream=True) as r:
 | 
					
						
							|  |  |  |       with qlog as f:
 | 
					
						
							|  |  |  |         shutil.copyfileobj(r.raw, f)
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     for f in [QLOG_FILE, qlog.name]:
 | 
					
						
							|  |  |  |       l = len(list(LogReader(f)))
 | 
					
						
							|  |  |  |       self.assertGreater(l, 100)
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     with self.assertRaises(URLFileException) if not cache_enabled else self.assertRaises(AssertionError):
 | 
					
						
							|  |  |  |       l = len(list(LogReader(QLOG_FILE.replace("/3/", "/200/"))))
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     # file_exists should not be called for direct files
 | 
					
						
							|  |  |  |     self.assertEqual(file_exists_mock.call_count, 0)
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   @parameterized.expand([
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}///",),
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}---",),
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}/-4:--2",),
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}/-a",),
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}/j",),
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}/0:1:2:3",),
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}/:::3",),
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}3",),
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}-3",),
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}--3a",),
 | 
					
						
							|  |  |  |   ])
 | 
					
						
							|  |  |  |   def test_bad_ranges(self, segment_range):
 | 
					
						
							|  |  |  |     with self.assertRaises(AssertionError):
 | 
					
						
							|  |  |  |       _ = SegmentRange(segment_range).seg_idxs
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   @parameterized.expand([
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}/0", False),
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}/:2", False),
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}/0:", True),
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}/-1", True),
 | 
					
						
							|  |  |  |     (f"{TEST_ROUTE}", True),
 | 
					
						
							|  |  |  |   ])
 | 
					
						
							|  |  |  |   def test_slicing_api_call(self, segment_range, api_call):
 | 
					
						
							|  |  |  |     with mock.patch("openpilot.tools.lib.route.get_max_seg_number_cached") as max_seg_mock:
 | 
					
						
							|  |  |  |       max_seg_mock.return_value = NUM_SEGS
 | 
					
						
							|  |  |  |       _ = SegmentRange(segment_range).seg_idxs
 | 
					
						
							|  |  |  |       self.assertEqual(api_call, max_seg_mock.called)
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   @pytest.mark.slow
 | 
					
						
							|  |  |  |   def test_modes(self):
 | 
					
						
							|  |  |  |     qlog_len = len(list(LogReader(f"{TEST_ROUTE}/0", ReadMode.QLOG)))
 | 
					
						
							|  |  |  |     rlog_len = len(list(LogReader(f"{TEST_ROUTE}/0", ReadMode.RLOG)))
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     self.assertLess(qlog_len * 6, rlog_len)
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   @pytest.mark.slow
 | 
					
						
							|  |  |  |   def test_modes_from_name(self):
 | 
					
						
							|  |  |  |     qlog_len = len(list(LogReader(f"{TEST_ROUTE}/0/q")))
 | 
					
						
							|  |  |  |     rlog_len = len(list(LogReader(f"{TEST_ROUTE}/0/r")))
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     self.assertLess(qlog_len * 6, rlog_len)
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   @pytest.mark.slow
 | 
					
						
							|  |  |  |   def test_list(self):
 | 
					
						
							|  |  |  |     qlog_len = len(list(LogReader(f"{TEST_ROUTE}/0/q")))
 | 
					
						
							|  |  |  |     qlog_len_2 = len(list(LogReader([f"{TEST_ROUTE}/0/q", f"{TEST_ROUTE}/0/q"])))
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     self.assertEqual(qlog_len * 2, qlog_len_2)
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   @pytest.mark.slow
 | 
					
						
							|  |  |  |   @mock.patch("openpilot.tools.lib.logreader._LogFileReader")
 | 
					
						
							|  |  |  |   def test_multiple_iterations(self, init_mock):
 | 
					
						
							|  |  |  |     lr = LogReader(f"{TEST_ROUTE}/0/q")
 | 
					
						
							|  |  |  |     qlog_len1 = len(list(lr))
 | 
					
						
							|  |  |  |     qlog_len2 = len(list(lr))
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     # ensure we don't create multiple instances of _LogFileReader, which means downloading the files twice
 | 
					
						
							|  |  |  |     self.assertEqual(init_mock.call_count, 1)
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     self.assertEqual(qlog_len1, qlog_len2)
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   @pytest.mark.slow
 | 
					
						
							|  |  |  |   def test_helpers(self):
 | 
					
						
							|  |  |  |     lr = LogReader(f"{TEST_ROUTE}/0/q")
 | 
					
						
							|  |  |  |     self.assertEqual(lr.first("carParams").carFingerprint, "SUBARU OUTBACK 6TH GEN")
 | 
					
						
							|  |  |  |     self.assertTrue(0 < len(list(lr.filter("carParams"))) < len(list(lr)))
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   @parameterized.expand([(True,), (False,)])
 | 
					
						
							|  |  |  |   @pytest.mark.slow
 | 
					
						
							|  |  |  |   def test_run_across_segments(self, cache_enabled):
 | 
					
						
							|  |  |  |     os.environ["FILEREADER_CACHE"] = "1" if cache_enabled else "0"
 | 
					
						
							|  |  |  |     lr = LogReader(f"{TEST_ROUTE}/0:4")
 | 
					
						
							|  |  |  |     self.assertEqual(len(lr.run_across_segments(4, noop)), len(list(lr)))
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   @pytest.mark.slow
 | 
					
						
							|  |  |  |   def test_auto_mode(self):
 | 
					
						
							|  |  |  |     lr = LogReader(f"{TEST_ROUTE}/0/q")
 | 
					
						
							|  |  |  |     qlog_len = len(list(lr))
 | 
					
						
							|  |  |  |     with mock.patch("openpilot.tools.lib.route.Route.log_paths") as log_paths_mock:
 | 
					
						
							|  |  |  |       log_paths_mock.return_value = [None] * NUM_SEGS
 | 
					
						
							|  |  |  |       # Should fall back to qlogs since rlogs are not available
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |       with self.subTest("interactive_yes"):
 | 
					
						
							|  |  |  |         with mock.patch("sys.stdin", new=io.StringIO("y\n")):
 | 
					
						
							|  |  |  |           lr = LogReader(f"{TEST_ROUTE}/0", default_mode=ReadMode.AUTO_INTERACTIVE, default_source=comma_api_source)
 | 
					
						
							|  |  |  |           log_len = len(list(lr))
 | 
					
						
							|  |  |  |         self.assertEqual(qlog_len, log_len)
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |       with self.subTest("interactive_no"):
 | 
					
						
							|  |  |  |         with mock.patch("sys.stdin", new=io.StringIO("n\n")):
 | 
					
						
							|  |  |  |           with self.assertRaises(AssertionError):
 | 
					
						
							|  |  |  |             lr = LogReader(f"{TEST_ROUTE}/0", default_mode=ReadMode.AUTO_INTERACTIVE, default_source=comma_api_source)
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |       with self.subTest("non_interactive"):
 | 
					
						
							|  |  |  |         lr = LogReader(f"{TEST_ROUTE}/0", default_mode=ReadMode.AUTO, default_source=comma_api_source)
 | 
					
						
							|  |  |  |         log_len = len(list(lr))
 | 
					
						
							|  |  |  |         self.assertEqual(qlog_len, log_len)
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   @parameterized.expand([(True,), (False,)])
 | 
					
						
							|  |  |  |   @pytest.mark.slow
 | 
					
						
							|  |  |  |   def test_auto_source_scenarios(self, is_internal):
 | 
					
						
							|  |  |  |     lr = LogReader(QLOG_FILE)
 | 
					
						
							|  |  |  |     qlog_len = len(list(lr))
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     with setup_source_scenario(is_internal=is_internal):
 | 
					
						
							|  |  |  |       lr = LogReader(f"{TEST_ROUTE}/0/q")
 | 
					
						
							|  |  |  |       log_len = len(list(lr))
 | 
					
						
							|  |  |  |       self.assertEqual(qlog_len, log_len)
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | if __name__ == "__main__":
 | 
					
						
							|  |  |  |   unittest.main()
 |