Added loading window and window group switching

This commit is contained in:
2026-07-08 21:14:52 +01:00
parent 05bc8bdf25
commit 614c597d65
11 changed files with 472 additions and 43 deletions

BIN
assets/images/logo@300.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -8,6 +8,9 @@ dependencies = [
"dearpygui>=2.3.1", "dearpygui>=2.3.1",
"dpg-gauges", "dpg-gauges",
"dynalab-protocol", "dynalab-protocol",
"pathlib>=1.0.1",
"platformdirs>=4.10.0",
"tomli-w>=1.2.0",
] ]
[project.scripts] [project.scripts]

View File

@@ -11,11 +11,19 @@ import traceback
import dearpygui.dearpygui as dpg import dearpygui.dearpygui as dpg
import sys import sys
from dynalab.config import ConfigLoadError, load_config
from dynalab.data.json import json_thread_main from dynalab.data.json import json_thread_main
from dynalab.state import AppState 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 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 import dpg_gauges as dpgg
from dpg_gauges.exceptions import GaugeNotFoundError from dpg_gauges.exceptions import GaugeNotFoundError
@@ -56,24 +64,52 @@ def _asset_path(relative_path: str) -> str:
def _init_worker( def _init_worker(
state: AppState, stop_event: threading.Event, message_queue: Queue[str] state: AppState, stop_event: threading.Event, message_queue: Queue[str]
) -> None: ) -> 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") message_queue.put("Starting JSON data server")
# Init and start json connector thread # Init and start json connector thread
state.data_json_thread = Thread( state.data_json_thread = Thread(
target=json_thread_main, target=json_thread_main,
name="json-connector-network", name="json-connector-network",
daemon=True, daemon=True,
args=(state,), args=(state,),
) )
state.data_json_thread.start() state.data_json_thread.start()
time.sleep(0.5) time.sleep(0.5)
message_queue.put("JSON data server started") message_queue.put("JSON data server started")
time.sleep(0.5) time.sleep(0.5)
message_queue.put("DynaLab Ready") message_queue.put("DynaLab Ready")
time.sleep(1.0) 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: def run() -> None:
@@ -87,7 +123,9 @@ def run() -> None:
# Switch callback management to manual # Switch callback management to manual
dpg.configure_app(manual_callback_management=True) dpg.configure_app(manual_callback_management=True)
# Initialize viewport # 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 # Load fonts at 2x size for HiDPI scaling
with dpg.font_registry(): with dpg.font_registry():
@@ -101,17 +139,21 @@ def run() -> None:
) )
dpg.bind_font(app_font) dpg.bind_font(app_font)
_load_image(_asset_path("assets/images/logo@300.png"), "logo")
build_loading() build_loading()
build_error_modal()
# Open viewport # Open viewport
dpg.setup_dearpygui() dpg.setup_dearpygui()
dpg.show_viewport() dpg.show_viewport()
# Config main window size dpg.configure_item(
vp_w = dpg.get_viewport_client_width() "loading_window",
vp_h = dpg.get_viewport_client_height() pos=(0, 0),
dpg.configure_item("loading_window", pos=(0, 0), width=vp_w, height=vp_h) width=LOADING_WINDOW_WIDTH,
dpg.set_primary_window("loading_window", True) height=LOADING_WINDOW_HEIGHT,
)
# Init global app state # Init global app state
state: AppState = AppState() state: AppState = AppState()
@@ -128,12 +170,17 @@ def run() -> None:
while not loading_stop.is_set(): while not loading_stop.is_set():
# Fetch and execute callbacks # 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() jobs = dpg.get_callback_queue()
try: try:
dpg.run_callbacks(jobs) dpg.run_callbacks(jobs)
except Exception: except Exception:
traceback.print_exc() traceback.print_exc()
center_loading_content()
try: try:
msg = loading_msg_queue.get_nowait() msg = loading_msg_queue.get_nowait()
except Empty: except Empty:
@@ -145,9 +192,8 @@ def run() -> None:
dpg.delete_item("loading_window") dpg.delete_item("loading_window")
# Call ui building function # Call ui building function
dpg.set_viewport_height(720) _maximize_viewport()
dpg.set_viewport_width(1280) build_windows(state)
build_windows()
# Config main window size # Config main window size
vp_w = dpg.get_viewport_client_width() vp_w = dpg.get_viewport_client_width()
@@ -172,7 +218,7 @@ def run() -> None:
dpgg.digital_gauge( dpgg.digital_gauge(
tag=f"{new_signal.id}", tag=f"{new_signal.id}",
label=f"{new_signal.name}", 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 "", unit=new_signal.unit if new_signal.unit is not None else "",
value=0, value=0,
precision=1, precision=1,

132
src/dynalab/config.py Normal file
View File

@@ -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)

View File

@@ -121,10 +121,14 @@ async def _handle_connector(
async def _json_main(state: AppState) -> None: async def _json_main(state: AppState) -> None:
server = await asyncio.start_server( 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: async with server:
while not state.stop_event.is_set(): while not state.stop_event.is_set():

View File

@@ -8,6 +8,8 @@ from uuid import UUID
from dynalab_protocol.models import SignalDescriptor from dynalab_protocol.models import SignalDescriptor
from dynalab.config import AppConfig
@dataclass @dataclass
class AppState: class AppState:
@@ -19,3 +21,10 @@ class AppState:
live_data: dict[UUID, float] = field(default_factory=dict) live_data: dict[UUID, float] = field(default_factory=dict)
new_data_id: Queue[UUID] = Queue() 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)

View File

@@ -1,4 +1,15 @@
# Copyright (C) 2026 Hector van der Aa <hector@h3cx.dev> # Copyright (C) 2026 Hector van der Aa <hector@h3cx.dev>
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com> # Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
# SPDX-License-Identifier: GPL-3.0-or-later # 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" MENU_WINDOW_DATA_INPUT: str = "menu_window_data_input"

80
src/dynalab/ui/error.py Normal file
View File

@@ -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()

View File

@@ -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)

View File

@@ -1,36 +1,136 @@
# Copyright (C) 2026 Hector van der Aa <hector@h3cx.dev> # Copyright (C) 2026 Hector van der Aa <hector@h3cx.dev>
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com> # Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from os import wait
import dearpygui.dearpygui as dpg import dearpygui.dearpygui as dpg
import dpg_gauges as dpgg 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: 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.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.window(label="DynaLab", tag="main_window", no_collapse=True):
with dpg.menu_bar(): with dpg.menu_bar():
with dpg.menu(label="File"): 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"): 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( with dpg.child_window(
tag="content_area", autosize_x=True, height=0, border=False tag="content_area", autosize_x=True, height=0, border=False
): ):
# with dpg.group(tag=MENU_WINDOW_DATA_INPUT): # with dpg.group(tag=MENU_WINDOW_DATA_INPUT):
with dpgg.gauge_panel( with dpg.group(tag=WINDOW_GROUP_DATA_INPUT):
label="Autobreak narrow panel", autosize_x=True, autosize_y=True state.window_map[MENU_WINDOW_DATA_INPUT] = WINDOW_GROUP_DATA_INPUT
): with dpgg.gauge_panel(
with dpgg.gauge_grid( label="Autobreak narrow panel", autosize_x=True, autosize_y=True
columns="auto", min_column_width=180, tag=MENU_WINDOW_DATA_INPUT
): ):
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")

33
uv.lock generated
View File

@@ -42,6 +42,9 @@ dependencies = [
{ name = "dearpygui" }, { name = "dearpygui" },
{ name = "dpg-gauges" }, { name = "dpg-gauges" },
{ name = "dynalab-protocol" }, { name = "dynalab-protocol" },
{ name = "pathlib" },
{ name = "platformdirs" },
{ name = "tomli-w" },
] ]
[package.metadata] [package.metadata]
@@ -49,6 +52,9 @@ requires-dist = [
{ name = "dearpygui", specifier = ">=2.3.1" }, { name = "dearpygui", specifier = ">=2.3.1" },
{ name = "dpg-gauges", git = "https://git.h3cx.dev/h3cx/dpg-gauges.git" }, { name = "dpg-gauges", git = "https://git.h3cx.dev/h3cx/dpg-gauges.git" },
{ name = "dynalab-protocol", editable = "../dynalab-protocol" }, { 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]] [[package]]
@@ -62,6 +68,24 @@ dependencies = [
[package.metadata] [package.metadata]
requires-dist = [{ name = "pydantic", specifier = ">=2.13.4" }] 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]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.13.4" 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" }, { 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]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.16.0" version = "4.16.0"