Skip to content

Commit

Permalink
feat: split and run grpc_server, ota_core and otaclient main as separ…
Browse files Browse the repository at this point in the history
…ated processes (#431)

This PR introduces runtime re-architecture of otaclient, now at runtime each core otaclient components run as standalone processes, utilizing IPC to work together as a whole otaclient:

1. main process(by main module): the daemon process that first sets up the queue and shared_memory for each components to work together, and then brings up each component processes one by one. During otaclient running life-cycle, it will monitor each components status(active or exited) and handle signals(SIGTERM, SIGINT) accordingly.
2. ota_core process (by ota_core module): the process running the core implementation of OTA. It handles the OTA request and actually do the OTA, while reports its status via shared_memory all the time.
3. grpc_server process (by otaclient.grpc.api_v2 package): the prcoess running OTA Service API grpc server, it handles the OTA API requests, and translates it into otaclient internal form and dispatches to ota_core process via IPC.
4. ota_proxy process (by ota_proxy package): the process running ota_proxy server, will only be brought up when there is active OTA within the cluster.

A simple otaclient internal IPC interface is implemented using simple types defined in otaclient._types with queue between API grpc server and ota_core. grpc_server dispatches OTA requests down to ota_core(with op_queue) and get the response to the request from ota_core(with ack_queue).

A cros-process status report mechanism is implemented based on sharing hmac-protected(with one-time preshared key) pickled object with shared_memory. ota_core uses this mechanism to write its latest status into the shm, and grpc_server process read this shm to track the ota_core's internal status, and translate it into the OTA grpc API format.
  • Loading branch information
Bodong-Yang authored Dec 11, 2024
1 parent c1b956c commit e561d58
Show file tree
Hide file tree
Showing 21 changed files with 1,059 additions and 1,026 deletions.
140 changes: 140 additions & 0 deletions src/otaclient/_otaproxy_ctx.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# Copyright 2022 TIER IV, INC. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Control of the otaproxy server startup/shutdown.
The API exposed by this module is meant to be controlled by otaproxy managing thread only.
"""


from __future__ import annotations

import asyncio
import atexit
import logging
import multiprocessing as mp
import multiprocessing.context as mp_ctx
import shutil
import time
from functools import partial
from pathlib import Path

from ota_proxy import config as local_otaproxy_cfg
from ota_proxy import run_otaproxy
from ota_proxy.config import config as otaproxy_cfg
from otaclient._types import MultipleECUStatusFlags
from otaclient.configs.cfg import cfg, proxy_info
from otaclient_common.common import ensure_otaproxy_start

logger = logging.getLogger(__name__)

_otaproxy_p: mp_ctx.SpawnProcess | None = None
_global_shutdown: bool = False


def shutdown_otaproxy_server() -> None:
global _otaproxy_p, _global_shutdown
_global_shutdown = True
if _otaproxy_p:
_otaproxy_p.terminate()
_otaproxy_p.join()
_otaproxy_p = None


OTAPROXY_CHECK_INTERVAL = 3
OTAPROXY_MIN_STARTUP_TIME = 120
"""Keep otaproxy running at least 60 seconds after startup."""
OTA_CACHE_DIR_CHECK_INTERVAL = 60


def otaproxy_process(*, init_cache: bool) -> None:
from otaclient._logging import configure_logging

configure_logging()
logger.info("otaproxy process started")

external_cache_mnt_point = None
if cfg.OTAPROXY_ENABLE_EXTERNAL_CACHE:
external_cache_mnt_point = cfg.EXTERNAL_CACHE_DEV_MOUNTPOINT

host, port = (
str(proxy_info.local_ota_proxy_listen_addr),
proxy_info.local_ota_proxy_listen_port,
)

upper_proxy = str(proxy_info.upper_ota_proxy or "")
logger.info(f"will launch otaproxy at http://{host}:{port}, with {upper_proxy=}")
if upper_proxy:
logger.info(f"wait for {upper_proxy=} online...")
ensure_otaproxy_start(str(upper_proxy))

asyncio.run(
run_otaproxy(
host=host,
port=port,
init_cache=init_cache,
cache_dir=local_otaproxy_cfg.BASE_DIR,
cache_db_f=local_otaproxy_cfg.DB_FILE,
upper_proxy=upper_proxy,
enable_cache=proxy_info.enable_local_ota_proxy_cache,
enable_https=proxy_info.gateway_otaproxy,
external_cache_mnt_point=external_cache_mnt_point,
)
)


def otaproxy_control_thread(
ecu_status_flags: MultipleECUStatusFlags,
) -> None: # pragma: no cover
atexit.register(shutdown_otaproxy_server)

_mp_ctx = mp.get_context("spawn")

ota_cache_dir = Path(otaproxy_cfg.BASE_DIR)
next_ota_cache_dir_checkpoint = 0

global _otaproxy_p
while not _global_shutdown:
time.sleep(OTAPROXY_CHECK_INTERVAL)
_now = time.time()

_otaproxy_running = _otaproxy_p and _otaproxy_p.is_alive()
_otaproxy_should_run = ecu_status_flags.any_requires_network.is_set()
_all_success = ecu_status_flags.all_success.is_set()

if not _otaproxy_should_run and not _otaproxy_running:
if (
_now > next_ota_cache_dir_checkpoint
and _all_success
and ota_cache_dir.is_dir()
):
logger.info(
"all tracked ECUs are in SUCCESS OTA status, cleanup ota cache dir ..."
)
next_ota_cache_dir_checkpoint = _now + OTA_CACHE_DIR_CHECK_INTERVAL
shutil.rmtree(ota_cache_dir, ignore_errors=True)

elif _otaproxy_should_run and not _otaproxy_running:
# NOTE: always try to re-use cache. If the cache dir is empty, otaproxy
# will still init the cache even init_cache is False.
_otaproxy_p = _mp_ctx.Process(
target=partial(otaproxy_process, init_cache=False),
name="otaproxy",
)
_otaproxy_p.start()
next_ota_cache_dir_checkpoint = _now + OTAPROXY_MIN_STARTUP_TIME
time.sleep(OTAPROXY_MIN_STARTUP_TIME) # prevent pre-mature shutdown

elif _otaproxy_p and _otaproxy_running and not _otaproxy_should_run:
logger.info("shutting down otaproxy as not needed now ...")
shutdown_otaproxy_server()
89 changes: 69 additions & 20 deletions src/otaclient/_status_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from dataclasses import asdict, dataclass
from enum import Enum, auto
from threading import Thread
from typing import Union, cast
from typing import Literal, Union, cast

from otaclient._types import (
FailureType,
Expand All @@ -34,17 +34,25 @@
UpdateProgress,
UpdateTiming,
)
from otaclient._utils import SharedOTAClientStatusWriter
from otaclient_common.logging import BurstSuppressFilter

logger = logging.getLogger(__name__)
burst_suppressed_logger = logging.getLogger(f"{__name__}.shm_push")
# NOTE: for request_error, only allow max 6 lines of logging per 30 seconds
burst_suppressed_logger.addFilter(
BurstSuppressFilter(
f"{__name__}.shm_push",
upper_logger_name=__name__,
burst_round_length=30,
burst_max=6,
)
)

_otaclient_shutdown = False
_status_report_queue: queue.Queue | None = None


def _global_shutdown():
global _otaclient_shutdown
_otaclient_shutdown = True

if _status_report_queue:
_status_report_queue.put_nowait(TERMINATE_SENTINEL)

Expand Down Expand Up @@ -120,7 +128,7 @@ class StatusReport:
#
def _on_session_finished(
status_storage: OTAClientStatus, payload: OTAStatusChangeReport
):
) -> Literal[True]:
status_storage.session_id = ""
status_storage.update_phase = UpdatePhase.INITIALIZING
status_storage.update_meta = UpdateMeta()
Expand All @@ -137,10 +145,12 @@ def _on_session_finished(
status_storage.failure_reason = ""
status_storage.failure_traceback = ""

return True


def _on_new_ota_session(
status_storage: OTAClientStatus, payload: OTAStatusChangeReport
):
) -> Literal[True]:
status_storage.ota_status = payload.new_ota_status
status_storage.update_phase = UpdatePhase.INITIALIZING
status_storage.update_meta = UpdateMeta()
Expand All @@ -149,6 +159,8 @@ def _on_new_ota_session(
status_storage.failure_type = FailureType.NO_FAILURE
status_storage.failure_reason = ""

return True


def _on_update_phase_changed(
status_storage: OTAClientStatus, payload: OTAUpdatePhaseChangeReport
Expand All @@ -157,7 +169,7 @@ def _on_update_phase_changed(
logger.warning(
"attempt to update update_timing when no OTA update session on-going"
)
return
return False

phase, trigger_timestamp = payload.new_update_phase, payload.trigger_timestamp
if phase == UpdatePhase.PROCESSING_POSTUPDATE:
Expand All @@ -170,14 +182,17 @@ def _on_update_phase_changed(
update_timing.update_apply_start_timestamp = trigger_timestamp

status_storage.update_phase = phase
return True


def _on_update_progress(status_storage: OTAClientStatus, payload: UpdateProgressReport):
def _on_update_progress(
status_storage: OTAClientStatus, payload: UpdateProgressReport
) -> bool:
if (update_progress := status_storage.update_progress) is None:
logger.warning(
"attempt to update update_progress when no OTA update session on-going"
)
return
return False

op = payload.operation
if (
Expand All @@ -195,6 +210,7 @@ def _on_update_progress(status_storage: OTAClientStatus, payload: UpdateProgress
update_progress.downloading_errors += payload.errors
elif op == UpdateProgressReport.Type.APPLY_REMOVE_DELTA:
update_progress.removed_files_num += payload.processed_file_num
return True


def _on_update_meta(status_storage: OTAClientStatus, payload: SetUpdateMetaReport):
Expand All @@ -204,7 +220,7 @@ def _on_update_meta(status_storage: OTAClientStatus, payload: SetUpdateMetaRepor
logger.warning(
"attempt to update update_meta when no OTA update session on-going"
)
return
return False

_input = asdict(payload)
for k, v in _input.items():
Expand All @@ -213,31 +229,45 @@ def _on_update_meta(status_storage: OTAClientStatus, payload: SetUpdateMetaRepor
continue
if v:
setattr(update_meta, k, v)
return True


#
# ------ status monitor implementation ------ #
#

# A sentinel object to tell the thread stop
TERMINATE_SENTINEL = cast(StatusReport, object())
MIN_COLLECT_INTERVAL = 0.5 # seconds
SHM_PUSH_INTERVAL = 0.5 # seconds


class OTAClientStatusCollector:
"""NOTE: status_monitor will only be started once during whole otaclient lifecycle!"""

def __init__(
self,
msg_queue: queue.Queue[StatusReport],
shm_status: SharedOTAClientStatusWriter,
*,
min_collect_interval: int = 1,
min_push_interval: int = 1,
min_collect_interval: float = MIN_COLLECT_INTERVAL,
shm_push_interval: float = SHM_PUSH_INTERVAL,
max_traceback_size: int,
) -> None:
self.max_traceback_size = max_traceback_size
self.min_collect_interval = min_collect_interval
self.min_push_interval = min_push_interval
self.shm_push_interval = shm_push_interval

self._input_queue = msg_queue
global _status_report_queue
_status_report_queue = msg_queue

self._status = None
self._shm_status = shm_status

atexit.register(shm_status.atexit)

def load_report(self, report: StatusReport):
def load_report(self, report: StatusReport) -> bool:
if self._status is None:
self._status = OTAClientStatus()
status_storage = self._status
Expand All @@ -246,37 +276,56 @@ def load_report(self, report: StatusReport):
# ------ update otaclient meta ------ #
if isinstance(payload, SetOTAClientMetaReport):
status_storage.firmware_version = payload.firmware_version
return True

# ------ on session start/end ------ #
if isinstance(payload, OTAStatusChangeReport):
if (_traceback := payload.failure_traceback) and len(
_traceback
) > self.max_traceback_size:
payload.failure_traceback = _traceback[-self.max_traceback_size :]

new_ota_status = payload.new_ota_status
if new_ota_status in [OTAStatus.UPDATING, OTAStatus.ROLLBACKING]:
status_storage.session_id = report.session_id
return _on_new_ota_session(status_storage, payload)

status_storage.session_id = "" # clear session if we are not in an OTA
return _on_session_finished(status_storage, payload)

# ------ during OTA session ------ #
report_session_id = report.session_id
if report_session_id != status_storage.session_id:
logger.warning(f"drop reports from mismatched session: {report}")
return # drop invalid report
logger.warning(
f"drop reports from mismatched session (expect {status_storage.session_id=}): {report}"
)
return False
if isinstance(payload, OTAUpdatePhaseChangeReport):
return _on_update_phase_changed(status_storage, payload)
if isinstance(payload, UpdateProgressReport):
return _on_update_progress(status_storage, payload)
if isinstance(payload, SetUpdateMetaReport):
return _on_update_meta(status_storage, payload)
return False

def _status_collector_thread(self) -> None:
"""Main entry of status monitor working thread."""
while not _otaclient_shutdown:
_next_shm_push = 0
while True:
_now = time.time()
try:
report = self._input_queue.get_nowait()
if report is TERMINATE_SENTINEL:
break
self.load_report(report)

# ------ push status on load_report ------ #
if self.load_report(report) and self._status and _now > _next_shm_push:
try:
self._shm_status.write_msg(self._status)
_next_shm_push = _now + self.shm_push_interval
except Exception as e:
burst_suppressed_logger.debug(
f"failed to push status to shm: {e!r}"
)
except queue.Empty:
time.sleep(self.min_collect_interval)

Expand Down
Loading

1 comment on commit e561d58

@github-actions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage

Coverage Report
FileStmtsMissCoverMissing
src/ota_metadata/legacy
   __init__.py10100% 
   parser.py3354885%106, 170, 175, 211–212, 222–223, 226, 238, 289–291, 295–298, 324–327, 396, 399, 407–409, 422, 431–432, 435–436, 601–603, 653–654, 657, 685–686, 689–690, 692, 696, 698–699, 753, 756–758
   types.py841384%37, 40–42, 112–116, 122–125
src/ota_metadata/utils
   cert_store.py86890%58–59, 73, 87, 91, 102, 123, 127
src/ota_proxy
   __init__.py15660%48, 50, 52, 61, 71–72
   __main__.py880%16, 18–20, 22, 24–25, 27
   _consts.py170100% 
   cache_control_header.py68494%71, 91, 113, 121
   cache_streaming.py1441390%211, 225, 229–230, 265–266, 268, 280, 349, 367–370
   config.py200100% 
   db.py741875%110, 116, 154, 160–161, 164, 170, 172, 193–200, 202–203
   errors.py50100% 
   external_cache.py282028%31, 35, 40–42, 44–45, 48–49, 51–53, 60, 63–65, 69–72
   lru_cache_helper.py47295%84–85
   ota_cache.py2346472%71–72, 143, 146–147, 159–160, 192–193, 210, 231, 250–254, 258–260, 262, 264–271, 273–275, 278–279, 283–284, 288, 335, 343–345, 418, 445, 448–449, 471–473, 477–479, 485, 487–489, 494, 520–522, 557–559, 586, 592, 607
   server_app.py1413972%79, 82, 88, 107, 111, 170, 179, 221–222, 224–226, 229, 234–235, 238, 241–242, 245, 248, 251, 254, 267–268, 271–272, 274, 277, 303–306, 309, 323–325, 331–333
   utils.py140100% 
src/otaclient
   __init__.py5260%17, 19
   __main__.py110%16
   _logging.py513335%43–44, 46–47, 49–54, 56–57, 59–60, 62–65, 67, 77, 80–82, 84–86, 89–90, 92–96
   _otaproxy_ctx.py43430%20, 22–30, 32–37, 39, 41–42, 45, 47–51, 54–57, 60–61, 63–64, 66–68, 70, 75–79, 81
   _status_monitor.py1851492%56–57, 169, 172, 192, 195, 211–212, 220, 223, 286, 308, 325–326
   _types.py960100% 
   _utils.py30293%80–81
   errors.py120199%97
   main.py25250%17, 19–29, 31–33, 35, 37, 41–42, 44–46, 48–50
   ota_core.py34213959%121, 123–124, 128–129, 131–133, 137–138, 143–144, 150, 152, 211–214, 337, 369–370, 372, 381, 384, 389–390, 393, 399, 401–405, 412, 418, 453–456, 459–470, 473–476, 512–515, 531–532, 536–537, 605–612, 617, 620–627, 652–653, 659, 663–664, 670, 695–697, 699, 739, 761, 788–790, 799–805, 819–825, 827–828, 833–834, 842, 844, 850, 852, 858, 860, 864, 870, 872, 878, 881–883, 893–894, 905–907, 909–910, 912, 914–915, 920, 922, 927
src/otaclient/boot_control
   __init__.py40100% 
   _firmware_package.py932276%82, 86, 136, 180, 186, 209–210, 213–218, 220–221, 224–229, 231
   _grub.py41812769%214, 262–265, 271–275, 312–313, 320–325, 328–334, 337, 340–341, 346, 348–350, 359–365, 367–368, 370–372, 381–383, 385–387, 466–467, 471–472, 524, 530, 556, 578, 582–583, 598–600, 624–627, 639, 643–645, 647–649, 708–711, 736–739, 762–765, 777–778, 781–782, 817, 823, 843–844, 846, 871–873, 891–894, 919–922, 929–932, 937–945, 950–957
   _jetson_cboot.py2612610%20, 22–25, 27–29, 35–40, 42, 58–60, 62, 64–65, 71, 75, 134, 137, 139–140, 143, 150–151, 159–160, 163, 169–170, 178, 187–191, 193, 199, 202–203, 209, 212–213, 218–219, 221, 227–228, 231–232, 235–237, 239, 245, 250–252, 254–256, 261, 263–266, 268–269, 278–279, 282–283, 288–289, 292–296, 299–300, 305–306, 309, 312–316, 321–324, 327, 330–331, 334, 337–338, 341, 345–350, 354–355, 359, 362–363, 366, 369–372, 374, 377–378, 382, 385, 388–391, 393, 400, 404–405, 408–409, 415–416, 422, 424–425, 429, 431, 433–435, 438, 442, 445, 448–449, 451, 454, 462–463, 470, 480, 483, 491–492, 497–500, 502, 509, 511–513, 519–520, 524–525, 528, 532, 535, 537, 544–548, 550, 562–565, 568, 571, 573, 580, 590–592, 594, 596, 599, 602, 605, 607–608, 611–615, 619–621, 623, 631–635, 637, 640, 644, 647, 658–659, 664, 674, 677–683, 687–693, 697–706, 710–718, 722, 724, 726–728
   _jetson_common.py1724573%132, 140, 288–291, 294, 311, 319, 354, 359–364, 382, 408–409, 411–413, 417–420, 422–423, 425–429, 431, 438–439, 442–443, 453, 456–457, 460, 462, 506–507
   _jetson_uefi.py40427432%124–126, 131–132, 151–153, 158–161, 328, 446, 448–451, 455, 459–460, 462–470, 472, 484–485, 488–489, 492–493, 496–498, 502–503, 508–510, 514, 518–519, 522–523, 526–527, 531, 534–535, 537, 542–543, 547, 550–551, 556, 560–561, 564, 568–570, 572, 576–579, 581–582, 604–605, 609–610, 612, 616, 620–621, 624–625, 632, 635–637, 640, 642–643, 648–649, 652–655, 657–658, 663, 665–666, 674, 677–680, 682–683, 685, 689–690, 694, 702–706, 709–710, 712, 715–719, 722, 725–729, 733–734, 737–742, 745–746, 749–752, 754–755, 762–763, 773–776, 779, 782–785, 788–792, 795–796, 799, 802–805, 808, 810, 815–816, 819, 822–825, 827, 833, 838–839, 858–859, 862, 870–871, 878, 888, 891, 898–899, 904–907, 915–918, 926–927, 939–942, 944, 947, 950, 958, 969–971, 973–975, 977–981, 986–987, 989, 1002, 1006, 1009, 1019, 1024, 1032–1033, 1036, 1040, 1042–1044, 1050–1051, 1056, 1064–1069, 1074–1079, 1084–1092, 1097–1104, 1112–1114
   _ota_status_control.py1021189%117, 122, 127, 240, 244–245, 248, 255, 257–258, 273
   _rpi_boot.py28713353%53, 56, 120–121, 125, 133–136, 150–153, 158–159, 161–162, 167–168, 171–172, 181–182, 222, 228–232, 235, 253–255, 259–261, 266–268, 272–274, 284–285, 288, 291, 293–294, 296–297, 299–301, 307, 310–311, 321–324, 332–336, 338, 340–341, 346–347, 354, 357–362, 393, 395–398, 408–411, 415–416, 418–422, 450–453, 472–475, 501–504, 509–517, 522–529, 544–547, 554–557, 565–567
   _slot_mnt_helper.py100100% 
   configs.py510100% 
   protocol.py60100% 
   selecter.py412929%44–46, 49–50, 54–55, 58–60, 63, 65, 69, 77–79, 81–82, 84–85, 89, 91, 93–94, 96, 98–99, 101, 103
src/otaclient/configs
   __init__.py170100% 
   _cfg_configurable.py470100% 
   _cfg_consts.py47197%97
   _common.py80100% 
   _ecu_info.py56492%59, 64–65, 112
   _proxy_info.py50590%84, 86–87, 89, 100
   cfg.py190100% 
src/otaclient/create_standby
   __init__.py13192%36
   common.py2264480%59, 62–63, 67–69, 71, 75–76, 78, 126, 174–176, 178–180, 182, 185–188, 192, 203, 279–280, 282–287, 299, 339, 367, 370–372, 388–389, 403, 407, 429–430
   interface.py70100% 
   rebuild_mode.py1151091%98–100, 119, 150–155
src/otaclient/grpc/api_v2
   ecu_status.py1611093%75, 78, 81, 150, 175, 177, 307, 382–383, 419
   ecu_tracker.py54540%17, 19–22, 24–30, 32–33, 35, 46–47, 50, 52, 58–61, 63, 65, 67–70, 77, 81–84, 88–89, 91, 93, 95–103, 107–108, 110, 112–115
   main.py41410%17, 19–24, 26–27, 29, 32, 39, 41–42, 44–45, 47–48, 50–55, 57–59, 61, 64, 70, 72–73, 76–77, 79–82, 84–85, 87
   servicer.py1169518%57–61, 63–64, 66–67, 73–77, 81–82, 87, 90, 94–96, 100–102, 110–112, 115–119, 128–138, 145, 151, 154–156, 167–169, 172–174, 179, 186–189, 192, 196–197, 202, 205, 209–211, 215–217, 225–226, 229–233, 242–251, 258, 264, 267–269, 274–275, 278
   types.py44295%78–79
src/otaclient_api/v2
   api_caller.py39684%45–47, 83–85
   types.py2563287%61, 64, 67–70, 86, 89–92, 131, 209–210, 212, 259, 262–263, 506–508, 512–513, 515, 518–519, 522–523, 578, 585–586, 588
src/otaclient_common
   __init__.py341555%42–44, 61, 63, 68–77
   _io.py64198%41
   cmdhelper.py130100% 
   common.py1061090%148, 151–153, 168, 175–177, 271, 275
   downloader.py1991094%107–108, 126, 153, 369, 424, 428, 516–517, 526
   linux.py611575%51–53, 59, 69, 74, 76, 108–109, 133–134, 190, 195–196, 198
   logging.py29196%55
   persist_file_handling.py1181884%113, 118, 150–152, 163, 192–193, 228–232, 242–244, 246–247
   proto_streamer.py42880%33, 48, 66–67, 72, 81–82, 100
   proto_wrapper.py3985785%87, 134–141, 165, 172, 184–186, 189–190, 205, 210, 221, 257, 263, 268, 299, 303, 307, 402, 462, 469, 472, 492, 499, 501, 526, 532, 535, 537, 562, 568, 571, 573, 605, 609, 611, 625, 642, 669, 672, 676, 692, 707, 713, 762–763, 765, 803–805
   retry_task_map.py129993%134–135, 153–154, 207–208, 210, 230–231
   shm_status.py952177%79–80, 83–84, 105, 120–122, 134, 139, 156–160, 169–170, 172, 179, 192, 204
   typing.py31487%48, 97–98, 100
TOTAL6703187971% 

Tests Skipped Failures Errors Time
228 0 💤 0 ❌ 0 🔥 11m 51s ⏱️

Please sign in to comment.