diff --git a/assets/images/logo@300.png b/assets/images/logo@300.png new file mode 100644 index 0000000..dad997a Binary files /dev/null and b/assets/images/logo@300.png differ diff --git a/pyproject.toml b/pyproject.toml index 0866cae..ceaa1db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,9 @@ dependencies = [ "dearpygui>=2.3.1", "dpg-gauges", "dynalab-protocol", + "pathlib>=1.0.1", + "platformdirs>=4.10.0", + "tomli-w>=1.2.0", ] [project.scripts] diff --git a/src/dynalab/app.py b/src/dynalab/app.py index bc0cf5c..c7dadef 100644 --- a/src/dynalab/app.py +++ b/src/dynalab/app.py @@ -11,11 +11,19 @@ import traceback import dearpygui.dearpygui as dpg import sys +from dynalab.config import ConfigLoadError, load_config from dynalab.data.json import json_thread_main from dynalab.state import AppState -from dynalab.tags import MENU_WINDOW_DATA_INPUT +from dynalab.tags import WINDOW_DATA_INPUT_GAUGE_GRID +from dynalab.ui.error import build_error_modal, show_error_modal import dynalab.ui.windows -from dynalab.ui.windows import build_loading, build_windows +from dynalab.ui.windows import ( + LOADING_WINDOW_HEIGHT, + LOADING_WINDOW_WIDTH, + build_loading, + build_windows, + center_loading_content, +) import dpg_gauges as dpgg from dpg_gauges.exceptions import GaugeNotFoundError @@ -56,24 +64,52 @@ def _asset_path(relative_path: str) -> str: def _init_worker( state: AppState, stop_event: threading.Event, message_queue: Queue[str] ) -> None: - time.sleep(1.0) + try: + time.sleep(1.0) + message_queue.put("Fetching DynaLab config") + time.sleep(0.5) + state.app_config = load_config() + message_queue.put("Fetched DynaLab config") + time.sleep(0.5) - message_queue.put("Starting JSON data server") - # Init and start json connector thread - state.data_json_thread = Thread( - target=json_thread_main, - name="json-connector-network", - daemon=True, - args=(state,), - ) - state.data_json_thread.start() - time.sleep(0.5) - message_queue.put("JSON data server started") - time.sleep(0.5) - message_queue.put("DynaLab Ready") - time.sleep(1.0) + message_queue.put("Starting JSON data server") + # Init and start json connector thread + state.data_json_thread = Thread( + target=json_thread_main, + name="json-connector-network", + daemon=True, + args=(state,), + ) + state.data_json_thread.start() + time.sleep(0.5) + message_queue.put("JSON data server started") + time.sleep(0.5) + message_queue.put("DynaLab Ready") + time.sleep(1.0) - stop_event.set() + stop_event.set() + except ConfigLoadError as exc: + state.init_error = str(exc) + state.stop_init.set() + except Exception as exc: + state.init_error = f"Unexpected startup error: {exc}" + state.stop_init.set() + + +def _load_image(path: Path, tag: str) -> None: + width, height, channels, data = dpg.load_image(f"{path}") + with dpg.texture_registry(): + dpg.add_static_texture(width=width, height=height, default_value=data, tag=tag) + + +def _maximize_viewport() -> None: + maximize_viewport = getattr(dpg, "maximize_viewport", None) + if callable(maximize_viewport): + maximize_viewport() + return + + dpg.set_viewport_height(720) + dpg.set_viewport_width(1280) def run() -> None: @@ -87,7 +123,9 @@ def run() -> None: # Switch callback management to manual dpg.configure_app(manual_callback_management=True) # Initialize viewport - dpg.create_viewport(title="DynaLab", width=600, height=400) + dpg.create_viewport( + title="DynaLab", width=LOADING_WINDOW_WIDTH, height=LOADING_WINDOW_HEIGHT + ) # Load fonts at 2x size for HiDPI scaling with dpg.font_registry(): @@ -101,17 +139,21 @@ def run() -> None: ) dpg.bind_font(app_font) + _load_image(_asset_path("assets/images/logo@300.png"), "logo") + build_loading() + build_error_modal() # Open viewport dpg.setup_dearpygui() dpg.show_viewport() - # Config main window size - vp_w = dpg.get_viewport_client_width() - vp_h = dpg.get_viewport_client_height() - dpg.configure_item("loading_window", pos=(0, 0), width=vp_w, height=vp_h) - dpg.set_primary_window("loading_window", True) + dpg.configure_item( + "loading_window", + pos=(0, 0), + width=LOADING_WINDOW_WIDTH, + height=LOADING_WINDOW_HEIGHT, + ) # Init global app state state: AppState = AppState() @@ -128,12 +170,17 @@ def run() -> None: while not loading_stop.is_set(): # Fetch and execute callbacks + if state.stop_init.is_set(): + show_error_modal( + state.init_error if state.init_error is not None else "Fatal error" + ) jobs = dpg.get_callback_queue() try: dpg.run_callbacks(jobs) except Exception: traceback.print_exc() + center_loading_content() try: msg = loading_msg_queue.get_nowait() except Empty: @@ -145,9 +192,8 @@ def run() -> None: dpg.delete_item("loading_window") # Call ui building function - dpg.set_viewport_height(720) - dpg.set_viewport_width(1280) - build_windows() + _maximize_viewport() + build_windows(state) # Config main window size vp_w = dpg.get_viewport_client_width() @@ -172,7 +218,7 @@ def run() -> None: dpgg.digital_gauge( tag=f"{new_signal.id}", label=f"{new_signal.name}", - parent=MENU_WINDOW_DATA_INPUT, + parent=WINDOW_DATA_INPUT_GAUGE_GRID, unit=new_signal.unit if new_signal.unit is not None else "", value=0, precision=1, diff --git a/src/dynalab/config.py b/src/dynalab/config.py new file mode 100644 index 0000000..c3821b5 --- /dev/null +++ b/src/dynalab/config.py @@ -0,0 +1,132 @@ +from pathlib import Path +import tomllib +import tomli_w +from pydantic import BaseModel, Field, ValidationError +from platformdirs import user_config_dir + +APP_NAME = "DynaLab" +APP_AUTHOR = "Exergie" + + +class JsonConnectorConfig(BaseModel): + host: str = "127.0.0.1" + port: int = Field(default=8765, ge=1, le=65535) + + +class AppConfig(BaseModel): + json_connector: JsonConnectorConfig = JsonConnectorConfig() + + +class ConfigLoadError(Exception): + path: Path + message: str + line: int | None + column: int | None + + def __init__( + self, + path: Path, + message: str, + line: int | None = None, + column: int | None = None, + ) -> None: + self.path = path + self.message = message + self.line = line + self.column = column + super().__init__(path, message, line, column) + + def __str__(self) -> str: + if self.line is not None: + return f"{self.path}: line {self.line}: {self.message}" + + return f"{self.path}: {self.message}" + + +def get_config_path() -> Path: + config_dir = Path(user_config_dir(APP_NAME, APP_AUTHOR)) + return config_dir / "config.toml" + + +def find_toml_line(text: str, field_path: tuple[str, ...]) -> int | None: + if not field_path: + return None + + section = ".".join(field_path[:-1]) + key = field_path[-1] + current_section = "" + + for line_number, line in enumerate(text.splitlines(), start=1): + stripped = line.strip() + + if not stripped or stripped.startswith("#"): + continue + + if stripped.startswith("[") and stripped.endswith("]"): + current_section = stripped.strip("[]").strip() + continue + + if current_section != section or "=" not in stripped: + continue + + line_key = stripped.split("=", 1)[0].strip() + if line_key == key: + return line_number + + return None + + +def load_config() -> AppConfig: + path = get_config_path() + + if not path.exists(): + config = AppConfig() + save_config(config) + return config + + try: + text = path.read_text(encoding="utf-8") + except OSError as exc: + raise ConfigLoadError(path, f"Failed to read config: {exc}") from exc + + try: + data = tomllib.loads(text) + except tomllib.TOMLDecodeError as exc: + line = getattr(exc, "lineno", None) + column = getattr(exc, "colno", None) + message = getattr(exc, "msg", str(exc)) + + if line is None or column is None: + suffix = " (at line " + raw_message = str(exc) + location_start = raw_message.rfind(suffix) + if location_start != -1 and raw_message.endswith(")"): + location = raw_message[location_start + len(suffix) : -1] + parts = location.split(", column ", 1) + if len(parts) == 2: + try: + line = int(parts[0]) + column = int(parts[1]) + message = raw_message[:location_start] + except ValueError: + pass + + raise ConfigLoadError(path, message, line, column) from exc + + try: + return AppConfig.model_validate(data) + except ValidationError as exc: + error = exc.errors()[0] + field_path = tuple(str(part) for part in error["loc"]) + field_name = ".".join(field_path) + line = find_toml_line(text, field_path) + message = f"Invalid value for '{field_name}': {error['msg']}" + raise ConfigLoadError(path, message, line) from exc + + +def save_config(config: AppConfig) -> None: + path = get_config_path() + path.parent.mkdir(parents=True, exist_ok=True) + + with path.open("wb") as f: + tomli_w.dump(config.model_dump(), f) diff --git a/src/dynalab/data/json.py b/src/dynalab/data/json.py index 90a03df..bed78fd 100644 --- a/src/dynalab/data/json.py +++ b/src/dynalab/data/json.py @@ -121,10 +121,14 @@ async def _handle_connector( async def _json_main(state: AppState) -> None: server = await asyncio.start_server( - partial(_handle_connector, state), host="127.0.0.1", port=8765 + partial(_handle_connector, state), + host=state.app_config.json_connector.host, + port=state.app_config.json_connector.port, ) - print("Json connector server listening on 127.0.0.1:8765") + print( + f"Json connector server listening on {state.app_config.json_connector.host}:{state.app_config.json_connector.port}" + ) async with server: while not state.stop_event.is_set(): diff --git a/src/dynalab/state.py b/src/dynalab/state.py index 4866fb0..265c26b 100644 --- a/src/dynalab/state.py +++ b/src/dynalab/state.py @@ -8,6 +8,8 @@ from uuid import UUID from dynalab_protocol.models import SignalDescriptor +from dynalab.config import AppConfig + @dataclass class AppState: @@ -19,3 +21,10 @@ class AppState: live_data: dict[UUID, float] = field(default_factory=dict) new_data_id: Queue[UUID] = Queue() + + app_config: AppConfig = field(default_factory=AppConfig) + + stop_init = threading.Event() + init_error: str | None = None + + window_map: dict[str, str] = field(default_factory=dict) diff --git a/src/dynalab/tags.py b/src/dynalab/tags.py index 881d650..0d8993c 100644 --- a/src/dynalab/tags.py +++ b/src/dynalab/tags.py @@ -1,4 +1,15 @@ # Copyright (C) 2026 Hector van der Aa # Copyright (C) 2026 Association Exergie # SPDX-License-Identifier: GPL-3.0-or-later + +# Window groups +WINDOW_GROUP_DATA_INPUT: str = "window_group_data_input" +WINDOW_GROUP_RECORD: str = "window_group_record" + +WINDOW_DATA_INPUT_GAUGE_GRID: str = "window_data_input_gauge_grid" + +# Menu buttons +MENU_FILE_QUIT: str = "menu_file_quit" +MENU_RECORD_CONFIGURATION: str = "menu_record_configuration" +MENU_WINDOW_RECORD: str = "menu_window_record" MENU_WINDOW_DATA_INPUT: str = "menu_window_data_input" diff --git a/src/dynalab/ui/error.py b/src/dynalab/ui/error.py new file mode 100644 index 0000000..2c04daf --- /dev/null +++ b/src/dynalab/ui/error.py @@ -0,0 +1,80 @@ +import dearpygui.dearpygui as dpg + + +ERROR_MODAL = "error_modal" +ERROR_TEXT = "error_modal_text" +ERROR_BUTTON_ROW = "error_modal_button_row" +ERROR_BUTTON_LEFT_SPACER = "error_modal_button_left_spacer" +ERROR_OK_BUTTON = "error_modal_ok_button" +ERROR_QUIT_BUTTON = "error_modal_quit_button" +ERROR_MODAL_WIDTH = 420 +ERROR_TEXT_WRAP = 380 +ERROR_BUTTON_WIDTH = 100 +ERROR_BUTTON_SPACING = 8 + + +def build_error_modal() -> None: + with dpg.window( + label="Error", + tag=ERROR_MODAL, + modal=True, + show=False, + no_resize=True, + no_collapse=True, + no_close=True, + width=ERROR_MODAL_WIDTH, + ): + dpg.add_text("", tag=ERROR_TEXT, wrap=ERROR_TEXT_WRAP) + + dpg.add_spacer(height=10) + + with dpg.group(horizontal=True, tag=ERROR_BUTTON_ROW): + dpg.add_spacer(width=0, tag=ERROR_BUTTON_LEFT_SPACER) + dpg.add_button( + label="OK", + tag=ERROR_OK_BUTTON, + width=ERROR_BUTTON_WIDTH, + callback=lambda: dpg.hide_item(ERROR_MODAL), + ) + dpg.add_button( + label="Quit", + tag=ERROR_QUIT_BUTTON, + width=ERROR_BUTTON_WIDTH, + callback=lambda: exit(1), + ) + + +def _center_error_buttons() -> None: + if not dpg.does_item_exist(ERROR_BUTTON_LEFT_SPACER): + return + + modal_width = dpg.get_item_width(ERROR_MODAL) + content_width = ( + min(modal_width, ERROR_TEXT_WRAP) if modal_width > 0 else ERROR_TEXT_WRAP + ) + buttons_width = (ERROR_BUTTON_WIDTH * 2) + ERROR_BUTTON_SPACING + spacer_width = max( + (content_width - buttons_width) / 2 - ERROR_BUTTON_SPACING, + 0, + ) + dpg.set_item_width(ERROR_BUTTON_LEFT_SPACER, spacer_width) + + +def show_error_modal(message: str) -> None: + dpg.set_value(ERROR_TEXT, message) + dpg.show_item(ERROR_MODAL) + + # Optional: center it after showing + viewport_width = dpg.get_viewport_client_width() + viewport_height = dpg.get_viewport_client_height() + window_width = dpg.get_item_width(ERROR_MODAL) + window_height = dpg.get_item_height(ERROR_MODAL) + + dpg.set_item_pos( + ERROR_MODAL, + [ + (viewport_width - window_width) // 2, + (viewport_height - window_height) // 2, + ], + ) + _center_error_buttons() diff --git a/src/dynalab/ui/routines.py b/src/dynalab/ui/routines.py new file mode 100644 index 0000000..9a50582 --- /dev/null +++ b/src/dynalab/ui/routines.py @@ -0,0 +1,11 @@ +from dynalab.state import AppState + +import dearpygui.dearpygui as dpg + + +def switch_window(sender, app_data, user_data: AppState) -> None: + for button_tag, window_group_tag in user_data.window_map.items(): + if button_tag == sender: + dpg.show_item(window_group_tag) + else: + dpg.hide_item(window_group_tag) diff --git a/src/dynalab/ui/windows.py b/src/dynalab/ui/windows.py index 97830e4..ab158c0 100644 --- a/src/dynalab/ui/windows.py +++ b/src/dynalab/ui/windows.py @@ -1,36 +1,136 @@ # Copyright (C) 2026 Hector van der Aa # Copyright (C) 2026 Association Exergie # SPDX-License-Identifier: GPL-3.0-or-later -from os import wait import dearpygui.dearpygui as dpg import dpg_gauges as dpgg -from dynalab.tags import MENU_WINDOW_DATA_INPUT +from dynalab.state import AppState +from dynalab.tags import ( + MENU_FILE_QUIT, + MENU_RECORD_CONFIGURATION, + MENU_WINDOW_DATA_INPUT, + MENU_WINDOW_RECORD, + WINDOW_DATA_INPUT_GAUGE_GRID, + WINDOW_GROUP_DATA_INPUT, + WINDOW_GROUP_RECORD, +) +from dynalab.ui.routines import switch_window + + +LOADING_WINDOW_WIDTH = 600 +LOADING_WINDOW_HEIGHT = 400 + + +def _loading_content_layout( + viewport_size: tuple[int, int], + logo_size: tuple[int, int], + text_size: tuple[int, int], + bottom_margin: int = 24, +) -> tuple[list[float], list[float], float]: + viewport_width, viewport_height = viewport_size + logo_width, logo_height = logo_size + text_width, text_height = text_size + + logo_pos = [ + (viewport_width - logo_width) / 2, + (viewport_height - logo_height) / 2, + ] + text_row_pos = [ + 0, + max(viewport_height - text_height - bottom_margin, 0), + ] + text_spacer_width = max((viewport_width - text_width) / 2, 0) + + return logo_pos, text_row_pos, text_spacer_width def build_loading() -> None: - with dpg.window(label="DynaLab", tag="loading_window", no_collapse=True): + with dpg.window( + label="DynaLab", + tag="loading_window", + width=LOADING_WINDOW_WIDTH, + height=LOADING_WINDOW_HEIGHT, + no_collapse=True, + no_resize=True, + no_move=True, + no_title_bar=True, + ): dpg.set_global_font_scale(0.5) - dpg.add_text(default_value="Loading DynaLab", tag="loading_text") + + with dpg.group(horizontal=False, tag="loading_logo_group"): + dpg.add_image(texture_tag="logo", tag="loading_logo") + + with dpg.group(horizontal=True, tag="loading_text_row"): + dpg.add_spacer(width=0, tag="loading_text_left_spacer") + dpg.add_text( + default_value="Loading DynaLab", + tag="loading_text", + color=(150, 150, 150, 255), + ) -def build_windows() -> None: +def center_loading_content() -> None: + if not dpg.does_item_exist("loading_window"): + return + + dpg.set_item_pos("loading_window", [0, 0]) + dpg.set_item_width("loading_window", LOADING_WINDOW_WIDTH) + dpg.set_item_height("loading_window", LOADING_WINDOW_HEIGHT) + + # These may be unavailable before the first rendered frame + logo_size = dpg.get_item_rect_size("loading_logo") + text_size = dpg.get_item_rect_size("loading_text") + + if logo_size is None or text_size is None: + return + + logo_pos, text_row_pos, text_spacer_width = _loading_content_layout( + (LOADING_WINDOW_WIDTH, LOADING_WINDOW_HEIGHT), logo_size, text_size + ) + + dpg.set_item_pos("loading_logo_group", logo_pos) + dpg.set_item_pos("loading_text_row", text_row_pos) + dpg.set_item_width("loading_text_left_spacer", text_spacer_width) + + +def build_windows(state: AppState) -> None: with dpg.window(label="DynaLab", tag="main_window", no_collapse=True): with dpg.menu_bar(): with dpg.menu(label="File"): - dpg.add_menu_item(label="Quit", enabled=True) + dpg.add_menu_item(tag=MENU_FILE_QUIT, label="Quit", enabled=True) + with dpg.menu(label="Record"): + dpg.add_menu_item(tag=MENU_RECORD_CONFIGURATION, label="Configuration") with dpg.menu(label="Window"): - dpg.add_menu_item(label="Data Input") + dpg.add_menu_item( + tag=MENU_WINDOW_RECORD, + label="Record", + callback=switch_window, + user_data=state, + ) + dpg.add_menu_item( + tag=MENU_WINDOW_DATA_INPUT, + label="Data Input", + callback=switch_window, + user_data=state, + ) with dpg.child_window( tag="content_area", autosize_x=True, height=0, border=False ): # with dpg.group(tag=MENU_WINDOW_DATA_INPUT): - with dpgg.gauge_panel( - label="Autobreak narrow panel", autosize_x=True, autosize_y=True - ): - with dpgg.gauge_grid( - columns="auto", min_column_width=180, tag=MENU_WINDOW_DATA_INPUT + with dpg.group(tag=WINDOW_GROUP_DATA_INPUT): + state.window_map[MENU_WINDOW_DATA_INPUT] = WINDOW_GROUP_DATA_INPUT + with dpgg.gauge_panel( + label="Autobreak narrow panel", autosize_x=True, autosize_y=True ): - pass + with dpgg.gauge_grid( + columns="auto", + min_column_width=180, + tag=WINDOW_DATA_INPUT_GAUGE_GRID, + ): + pass + + with dpg.group(tag=WINDOW_GROUP_RECORD, show=False): + state.window_map[MENU_WINDOW_RECORD] = WINDOW_GROUP_RECORD + dpg.add_text("Some text") diff --git a/uv.lock b/uv.lock index 4233189..b3f0650 100644 --- a/uv.lock +++ b/uv.lock @@ -42,6 +42,9 @@ dependencies = [ { name = "dearpygui" }, { name = "dpg-gauges" }, { name = "dynalab-protocol" }, + { name = "pathlib" }, + { name = "platformdirs" }, + { name = "tomli-w" }, ] [package.metadata] @@ -49,6 +52,9 @@ requires-dist = [ { name = "dearpygui", specifier = ">=2.3.1" }, { name = "dpg-gauges", git = "https://git.h3cx.dev/h3cx/dpg-gauges.git" }, { name = "dynalab-protocol", editable = "../dynalab-protocol" }, + { name = "pathlib", specifier = ">=1.0.1" }, + { name = "platformdirs", specifier = ">=4.10.0" }, + { name = "tomli-w", specifier = ">=1.2.0" }, ] [[package]] @@ -62,6 +68,24 @@ dependencies = [ [package.metadata] requires-dist = [{ name = "pydantic", specifier = ">=2.13.4" }] +[[package]] +name = "pathlib" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/aa/9b065a76b9af472437a0059f77e8f962fe350438b927cb80184c32f075eb/pathlib-1.0.1.tar.gz", hash = "sha256:6940718dfc3eff4258203ad5021090933e5c04707d5ca8cc9e73c94a7894ea9f", size = 49298, upload-time = "2014-09-03T15:41:57.18Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/f9/690a8600b93c332de3ab4a344a4ac34f00c8f104917061f779db6a918ed6/pathlib-1.0.1-py3-none-any.whl", hash = "sha256:f35f95ab8b0f59e6d354090350b44a80a80635d22efdedfa84c7ad1cf0a74147", size = 14363, upload-time = "2022-05-04T13:37:20.585Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" @@ -133,6 +157,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, ] +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0"