diff --git a/pyproject.toml b/pyproject.toml index 2320d90..3d97b72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ pyannote-database = "pyannote.database.cli:main" [project.optional-dependencies] cli = [ + "textual>=8.0.0", "typer>=0.15.1", ] test = [ diff --git a/src/pyannote/database/cli.py b/src/pyannote/database/cli.py index 2e54ad8..bf6da17 100644 --- a/src/pyannote/database/cli.py +++ b/src/pyannote/database/cli.py @@ -31,7 +31,8 @@ import typer from enum import Enum import math -from typing import Text +from pathlib import Path +from typing import Optional, Text from pyannote.database import Database from pyannote.database import registry from pyannote.database.protocol import CollectionProtocol @@ -177,5 +178,19 @@ def iterate(): typer.echo(f" {len(speakers)} speakers") +@app.command("tui") +def tui_command( + registry_path: Optional[Path] = typer.Argument( + None, + metavar="DATABASE_YML", + help="Path to database.yml registry file.", + ), +): + """Launch interactive TUI to browse the registry""" + from pyannote.database.tui import RegistryApp + + RegistryApp(registry_path=registry_path).run() + + def main(): app() diff --git a/src/pyannote/database/tui.py b/src/pyannote/database/tui.py new file mode 100644 index 0000000..a4541fb --- /dev/null +++ b/src/pyannote/database/tui.py @@ -0,0 +1,402 @@ +import math +from collections import Counter +from pathlib import Path +from typing import Optional + +from textual.app import App, ComposeResult +from textual.containers import Horizontal, VerticalScroll +from textual.reactive import reactive +from textual import events, work +from textual.widgets import Header, Footer, Label, ListItem, ListView, Static +from textual.worker import get_current_worker + +from pyannote.database import registry as _registry +from pyannote.database.protocol import CollectionProtocol, SpeakerDiarizationProtocol +from pyannote.core import Annotation + + +def _duration_to_str(seconds: float) -> str: + hours = math.floor(seconds / 3600) + minutes = math.floor((seconds - 3600 * hours) / 60) + return f"{hours}h{minutes:02d}m" + + +_COUNT_COLORS = ["green", "yellow", "orange1", "red", "magenta"] +_WINDOW_DURATIONS = [10.0, 20.0, 30.0, 40.0, 50.0, 60.0] + + +def _speaker_color(n: int) -> str: + """Rich color name for N simultaneous speakers (n >= 1).""" + return _COUNT_COLORS[min(n - 1, len(_COUNT_COLORS) - 1)] + + +def _sweep_speaker_counts(annotation: Annotation) -> Counter: + """Sweep-line: return Counter mapping simultaneous speaker count → duration.""" + events: list[tuple[float, int]] = [] + for segment, _, _ in annotation.itertracks(yield_label=True): + events.append((segment.start, +1)) + events.append((segment.end, -1)) + # At equal times: process ends (-1) before starts (+1) so simultaneous + # handoffs don't create spurious overlap. + events.sort(key=lambda x: (x[0], x[1])) + + count_dur: Counter = Counter() + current_count = 0 + prev_time: float | None = None + + for time, delta in events: + if prev_time is not None and time > prev_time and current_count > 0: + count_dur[current_count] += time - prev_time + current_count += delta + prev_time = time + + return count_dur + + +def _sliding_window_speaker_dist( + annotation: Annotation, annotated: "Timeline", window: float = 20.0 +) -> Counter: + """Distinct-speaker count distribution over a sliding window. + + For each valid window start position t (i.e. [t, t+window] ⊆ an annotated + segment), counts how many distinct speakers have any speech in [t, t+window]. + + Uses a sweep-line over per-speaker *presence intervals*: speaker X is + present for window start t iff X has a speech segment [a, b] with + a − window ≤ t ≤ b, so each speech segment maps to presence interval + [a − window, b] clipped to valid window-start positions. + + Returns Counter: distinct_speaker_count → total duration. + """ + # Build per-speaker segment list + speaker_segs: dict = {} + for seg, _, label in annotation.itertracks(yield_label=True): + speaker_segs.setdefault(label, []).append(seg) + + count_dur: Counter = Counter() + + for ann_seg in annotated: + t_start = ann_seg.start + t_end = ann_seg.end - window + if t_end <= t_start: + continue # annotated segment shorter than window + + events: list[tuple[float, int]] = [] + for segs in speaker_segs.values(): + # Compute and merge presence intervals for this speaker + presence = [] + for seg in segs: + ps = max(t_start, seg.start - window) + pe = min(t_end, seg.end) + if ps < pe: + presence.append([ps, pe]) + if not presence: + continue + presence.sort() + merged = [presence[0][:]] + for ps, pe in presence[1:]: + if ps <= merged[-1][1]: + merged[-1][1] = max(merged[-1][1], pe) + else: + merged.append([ps, pe]) + for ps, pe in merged: + events.append((ps, +1)) + events.append((pe, -1)) + + if not events: + count_dur[0] += t_end - t_start + continue + + # Ends before starts at equal times → no spurious handoff overlap + events.sort(key=lambda x: (x[0], x[1])) + + current_count = 0 + prev_time = t_start + i = 0 + while i < len(events): + t, delta = events[i] + if t >= t_end: + break + if t > prev_time: + count_dur[current_count] += t - prev_time + prev_time = t + while i < len(events) and events[i][0] == t: + current_count += events[i][1] + i += 1 + if prev_time < t_end: + count_dur[current_count] += t_end - prev_time + + return count_dur + + +def _render_bar(items: list[tuple[str, str, float]], total: float, bar_width: int) -> str: + """Single-line colored bar using proportional cumulative rounding. + + items: list of (color, fill_char, duration) + """ + parts = [] + cumulative = 0.0 + for color, char, dur in items: + cumulative_new = cumulative + bar_width * dur / total + n_chars = round(cumulative_new) - round(cumulative) + cumulative = cumulative_new + if n_chars > 0: + parts.append(f"[{color}]{char * n_chars}[/{color}]") + return "".join(parts) + + +def _compute_subset_content(protocol_name: str, subset: str, panel_width: int = 50) -> str: + """Compute stats for one subset of a protocol. Runs in a thread.""" + p = _registry.get_protocol(protocol_name) + + if isinstance(p, SpeakerDiarizationProtocol): + skip_annotation = False + skip_annotated = False + elif isinstance(p, CollectionProtocol): + skip_annotation = True + skip_annotated = True + else: + return f"[red]Unsupported: {type(p).__name__}[/red]" + + num_files = 0 + speakers: set = set() + duration = 0.0 + count_dur: Counter = Counter() # simultaneous speaker count → total duration + window_dists: dict[float, Counter] = {w: Counter() for w in _WINDOW_DURATIONS} + speakers_per_file: list[int] = [] + + try: + for file in getattr(p, subset)(): + num_files += 1 + annotation = None + if not skip_annotation: + annotation = file.get("annotation") or Annotation(uri=file["uri"]) + file_speakers = annotation.labels() + speakers.update(file_speakers) + speakers_per_file.append(len(file_speakers)) + count_dur += _sweep_speaker_counts(annotation) + if not skip_annotated: + ann = file["annotated"] + duration += ann.duration() + if annotation is not None: + for _w in _WINDOW_DURATIONS: + window_dists[_w] += _sliding_window_speaker_dist(annotation, ann, _w) + except (AttributeError, NotImplementedError): + return "" + + if num_files == 0: + return "" + + speech = sum(count_dur.values()) + + lines = [] + + # One-line stats header + header = f" {num_files} files" + if not skip_annotated: + header += f" / {_duration_to_str(duration)}" + lines.append(header) + + # Speakers / file histogram + if speakers_per_file: + dist = Counter(speakers_per_file) + max_count = max(dist.values()) + spk_values = sorted(dist.keys()) + col_width = max(3, len(str(max(spk_values))) + 1) + bar_w = col_width - 1 + chart_height = 6 + bar_heights = {s: max(1, round(chart_height * dist[s] / max_count)) for s in spk_values} + lines.append("") + lines.append(" Number of speakers per file") + for row in range(chart_height): + threshold = chart_height - row + parts = [] + for s in spk_values: + if bar_heights[s] >= threshold: + parts.append(f"[blue]{'█' * bar_w}[/blue] ") + else: + parts.append(" " * col_width) + lines.append(f" {''.join(parts)}") + lines.append(f" {'─' * (col_width * len(spk_values))}") + lines.append(f" {''.join(str(s).ljust(col_width) for s in spk_values)}") + + # Speakers in sliding window + if any(window_dists[w] for w in _WINDOW_DURATIONS): + bar_width = max(10, panel_width - 13) + all_counts: set[int] = set() + for w in _WINDOW_DURATIONS: + all_counts.update(window_dists[w].keys()) + lines.append("") + lines.append(" Number of speakers per window") + for w in _WINDOW_DURATIONS: + window_dist = window_dists[w] + total_wd = sum(window_dist.values()) + if not total_wd: + continue + items = [ + ("dim" if n == 0 else _speaker_color(n), "░" if n == 0 else "█", window_dist[n]) + for n in sorted(window_dist.keys()) + ] + lines.append(f" {int(w):2d}s {_render_bar(items, total_wd, bar_width)}") + legend_parts = [ + f"[{'dim' if n == 0 else _speaker_color(n)}]≤{n}spk[/{'dim' if n == 0 else _speaker_color(n)}]" + for n in sorted(all_counts) + ] + lines.append(f" {' '.join(legend_parts)}") + + # Simultaneous speakers duration bar (last) + if not skip_annotation and not skip_annotated and duration > 0: + silence_dur = duration - speech + items = [("dim", "░", silence_dur)] + for n in sorted(count_dur): + items.append((_speaker_color(n), "█", count_dur[n])) + bar_width = max(10, panel_width - 9) + legend_parts = [] + if 100 * silence_dur / duration >= 0.5: + legend_parts.append("[dim]silence[/dim]") + for n in sorted(count_dur): + if 100 * count_dur[n] / duration >= 0.5: + legend_parts.append(f"[{_speaker_color(n)}]{n}spk[/{_speaker_color(n)}]") + lines.append("") + lines.append(" Number of simultaneous speakers") + lines.append(f" {_render_bar(items, duration, bar_width)}") + lines.append(f" {' '.join(legend_parts)}") + + return "\n".join(lines) + + +class DatabaseList(ListView): + def __init__(self, databases: list[str], **kwargs): + super().__init__(**kwargs) + self._databases = databases + + def compose(self) -> ComposeResult: + for db in self._databases: + yield ListItem(Label(db), name=db) + + def on_key(self, event: events.Key) -> None: + if event.key == "right": + target = self.app.query_one(ProtocolList) + target.focus() + target.index = None + if target.query(ListItem): + target.index = 0 + event.stop() + + def on_list_view_highlighted(self, event: ListView.Highlighted) -> None: + if event.item is None: + return + db_name = event.item.name + self.app.query_one(ProtocolList).database = db_name + db = _registry.get_database(db_name) + protocols = [ + f"{db_name}.{task}.{protocol}" + for task in db.get_tasks() + for protocol in db.get_protocols(task) + ] + for panel in self.app.query(SubsetPanel): + panel.protocol = None + panel.preload(protocols) + + +class ProtocolList(ListView): + database: reactive[Optional[str]] = reactive(None, recompose=True) + + def compose(self) -> ComposeResult: + if self.database is None: + return + db = _registry.get_database(self.database) + for task in db.get_tasks(): + for protocol in db.get_protocols(task): + full_name = f"{self.database}.{task}.{protocol}" + yield ListItem(Label(f"{task} / {protocol}"), name=full_name) + + def on_key(self, event: events.Key) -> None: + if event.key == "left": + target = self.app.query_one(DatabaseList) + target.focus() + target.index = None + if target.query(ListItem): + target.index = 0 + event.stop() + + def on_list_view_highlighted(self, event: ListView.Highlighted) -> None: + if event.item is None: + return + for panel in self.app.query(SubsetPanel): + panel.protocol = event.item.name + + +class SubsetPanel(VerticalScroll): + protocol: reactive[Optional[str]] = reactive(None, init=False) + + def __init__(self, subset: str, **kwargs): + super().__init__(**kwargs) + self._subset = subset + self._cache: dict[tuple[str, int], str] = {} + + def on_mount(self) -> None: + self.border_title = self._subset + + def compose(self) -> ComposeResult: + yield Static(classes="subset-content") + + def watch_protocol(self, protocol: Optional[str]) -> None: + content_widget = self.query_one(".subset-content", Static) + if protocol is None: + content_widget.update("") + return + width = self.size.width + if (protocol, width) in self._cache: + content_widget.update(self._cache[(protocol, width)]) + return + content_widget.update("Loading...") + self._fetch_stats(protocol, width) + + def preload(self, protocol_names: list[str]) -> None: + self._preload_stats(protocol_names, self.size.width) + + @work(thread=True, exclusive=True, group="active") + def _fetch_stats(self, protocol_name: str, panel_width: int) -> None: + content = _compute_subset_content(protocol_name, self._subset, panel_width) + self._cache[(protocol_name, panel_width)] = content + self.app.call_from_thread(self._show_if_current, protocol_name, panel_width, content) + + @work(thread=True, exclusive=True, group="preload") + def _preload_stats(self, protocol_names: list[str], panel_width: int) -> None: + worker = get_current_worker() + for protocol_name in protocol_names: + if worker.is_cancelled: + break + if (protocol_name, panel_width) in self._cache: + continue + content = _compute_subset_content(protocol_name, self._subset, panel_width) + self._cache[(protocol_name, panel_width)] = content + self.app.call_from_thread(self._show_if_current, protocol_name, panel_width, content) + + def _show_if_current(self, protocol_name: str, panel_width: int, content: str) -> None: + if self.protocol == protocol_name and self.size.width == panel_width: + self.query_one(".subset-content", Static).update(content) + + +class RegistryApp(App): + CSS_PATH = "tui.tcss" + TITLE = "pyannote.database" + BINDINGS = [("q", "quit", "Quit")] + + def __init__(self, registry_path: Optional[Path] = None, **kwargs): + super().__init__(**kwargs) + if registry_path is not None: + _registry.load_database(registry_path) + self._databases = list(_registry.databases) + + def compose(self) -> ComposeResult: + yield Header() + with Horizontal(id="top-row"): + yield DatabaseList(self._databases, id="databases") + yield ProtocolList(id="protocols") + with Horizontal(id="bottom-row"): + yield SubsetPanel("train", id="train") + yield SubsetPanel("development", id="development") + yield SubsetPanel("test", id="test") + yield Footer() diff --git a/src/pyannote/database/tui.tcss b/src/pyannote/database/tui.tcss new file mode 100644 index 0000000..e9f0816 --- /dev/null +++ b/src/pyannote/database/tui.tcss @@ -0,0 +1,30 @@ +Screen { + layout: vertical; +} + +#top-row { + height: 1fr; + border-bottom: solid $primary; +} + +#bottom-row { + height: 2fr; +} + +#databases { + width: 1fr; + border-right: solid $primary; +} + +#protocols { + width: 1fr; +} + +SubsetPanel { + width: 1fr; + border: solid $primary; +} + +.subset-content { + padding: 1 2; +} diff --git a/uv.lock b/uv.lock index 6b47fc0..5ec2b0e 100644 --- a/uv.lock +++ b/uv.lock @@ -469,6 +469,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2d/3b/829cd76bf70d19215004d5dc50bcaab0bea84f5f9a0cce4e60ad52bb32f8/kaldialign-0.9.2-cp313-cp313-win_amd64.whl", hash = "sha256:8cfc96622b90c2eda56a40b73b2274c5e94f1df12481f7cb5b46cb599d3a0be9", size = 74746 }, ] +[[package]] +name = "linkify-it-py" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/bb56c6828e4797ba5a4821eec7c43b8bf40f69cda4d4f5f8c8a2810ec96a/linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048", size = 27946 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820 }, +] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -481,6 +493,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, ] +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + [[package]] name = "markupsafe" version = "3.0.2" @@ -551,6 +568,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca", size = 9899 }, ] +[[package]] +name = "mdit-py-plugins" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205 }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -810,6 +839,7 @@ dependencies = [ [package.optional-dependencies] cli = [ + { name = "textual" }, { name = "typer" }, ] doc = [ @@ -837,6 +867,7 @@ requires-dist = [ { name = "pyyaml", specifier = ">=6.0.2" }, { name = "sphinx", marker = "extra == 'doc'", specifier = ">=8.1.3" }, { name = "sphinx-rtd-theme", marker = "extra == 'doc'", specifier = ">=3.0.2" }, + { name = "textual", marker = "extra == 'cli'", specifier = ">=8.0.0" }, { name = "typer", marker = "extra == 'cli'", specifier = ">=0.15.1" }, ] provides-extras = ["cli", "doc", "test", "transcription"] @@ -855,11 +886,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.1" +version = "2.19.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 } wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 }, ] [[package]] @@ -1053,16 +1084,15 @@ wheels = [ [[package]] name = "rich" -version = "13.9.4" +version = "14.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582 } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458 }, ] [[package]] @@ -1279,6 +1309,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521 }, ] +[[package]] +name = "textual" +version = "8.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/08/1e1f705825359590ddfaeda57653bd518c4ff7a96bb2c3239ba1b6fc4c51/textual-8.0.0.tar.gz", hash = "sha256:ce48f83a3d686c0fac0e80bf9136e1f8851c653aa6a4502e43293a151df18809", size = 1595895 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/be/e191c2a15da20530fde03564564e3e4b4220eb9d687d4014957e5c6a5e85/textual-8.0.0-py3-none-any.whl", hash = "sha256:8908f4ebe93a6b4f77ca7262197784a52162bc88b05f4ecf50ac93a92d49bb8f", size = 718904 }, +] + [[package]] name = "tomli" version = "2.2.1" @@ -1378,6 +1425,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0f/dd/84f10e23edd882c6f968c21c2434fe67bd4a528967067515feca9e611e5e/tzdata-2025.1-py2.py3-none-any.whl", hash = "sha256:7e127113816800496f027041c570f50bcd464a020098a3b6b199517772303639", size = 346762 }, ] +[[package]] +name = "uc-micro-py" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/7a/146a99696aee0609e3712f2b44c6274566bc368dfe8375191278045186b8/uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a", size = 6043 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229 }, +] + [[package]] name = "urllib3" version = "2.3.0"