From 614c597d6572a82b53ac09d8f3344cebc862831c Mon Sep 17 00:00:00 2001 From: Hector van der Aa Date: Wed, 8 Jul 2026 21:14:52 +0100 Subject: [PATCH] Added loading window and window group switching --- assets/images/logo@300.png | Bin 0 -> 18209 bytes pyproject.toml | 3 + src/dynalab/app.py | 102 ++++++++++++++++++++-------- src/dynalab/config.py | 132 +++++++++++++++++++++++++++++++++++++ src/dynalab/data/json.py | 8 ++- src/dynalab/state.py | 9 +++ src/dynalab/tags.py | 11 ++++ src/dynalab/ui/error.py | 80 ++++++++++++++++++++++ src/dynalab/ui/routines.py | 11 ++++ src/dynalab/ui/windows.py | 126 +++++++++++++++++++++++++++++++---- uv.lock | 33 ++++++++++ 11 files changed, 472 insertions(+), 43 deletions(-) create mode 100644 assets/images/logo@300.png create mode 100644 src/dynalab/config.py create mode 100644 src/dynalab/ui/error.py create mode 100644 src/dynalab/ui/routines.py diff --git a/assets/images/logo@300.png b/assets/images/logo@300.png new file mode 100644 index 0000000000000000000000000000000000000000..dad997a3b081132a5d39a97fc82a25ea5ee217be GIT binary patch literal 18209 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 literal 0 HcmV?d00001 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"