From 8d0f709680d847beae4abc4ac81768e94bbd74c4 Mon Sep 17 00:00:00 2001 From: Alain Uyidi <107195562+auyidi1@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:05:16 +0000 Subject: [PATCH 01/68] feat(skills): add tts-voiceover skill for Azure Speech SDK voice-over generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add generate_voiceover.py with SSML acronym aliases, dry-run mode, and Entra ID auth - add embed_audio.py for PPTX audio embedding via python-pptx - add fuzz harness for apply_acronym_aliases, wrap_ssml, and load_acronyms - register skill in experimental and hve-core-all collections - regenerate plugin outputs 🎙️ - Generated by Copilot --- .cspell/general-technical.txt | 1 + .../experimental/tts-voiceover/SKILL.md | 175 ++++++ .../experimental/tts-voiceover/pyproject.toml | 31 + .../tts-voiceover/scripts/embed_audio.py | 132 ++++ .../scripts/generate_voiceover.py | 310 ++++++++++ .../tts-voiceover/tests/fuzz_harness.py | 169 ++++++ .../skills/experimental/tts-voiceover/uv.lock | 570 ++++++++++++++++++ collections/experimental.collection.yml | 2 + collections/hve-core-all.collection.yml | 4 + .../skills/experimental/tts-voiceover | 1 + .../skills/experimental/tts-voiceover | 1 + 11 files changed, 1396 insertions(+) create mode 100644 .github/skills/experimental/tts-voiceover/SKILL.md create mode 100644 .github/skills/experimental/tts-voiceover/pyproject.toml create mode 100644 .github/skills/experimental/tts-voiceover/scripts/embed_audio.py create mode 100644 .github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py create mode 100644 .github/skills/experimental/tts-voiceover/tests/fuzz_harness.py create mode 100644 .github/skills/experimental/tts-voiceover/uv.lock create mode 120000 plugins/experimental/skills/experimental/tts-voiceover create mode 120000 plugins/hve-core-all/skills/experimental/tts-voiceover diff --git a/.cspell/general-technical.txt b/.cspell/general-technical.txt index 97898cf16..38841ee9b 100644 --- a/.cspell/general-technical.txt +++ b/.cspell/general-technical.txt @@ -1595,3 +1595,4 @@ LASTEXITCODE scriptblock DSSE intoto +SSML diff --git a/.github/skills/experimental/tts-voiceover/SKILL.md b/.github/skills/experimental/tts-voiceover/SKILL.md new file mode 100644 index 000000000..69cce88ff --- /dev/null +++ b/.github/skills/experimental/tts-voiceover/SKILL.md @@ -0,0 +1,175 @@ +--- +name: tts-voiceover +description: 'Text-to-speech voice-over generation from YAML speaker notes using Azure Speech SDK with SSML pronunciation control - Brought to you by microsoft/hve-core' +--- + +# TTS Voice Over Skill + +Generates per-slide WAV voice-over files from YAML `speaker_notes` using Azure Speech SDK with SSML pronunciation control. + +## Overview + +This skill reads `content.yaml` files from a PowerPoint skill content directory, extracts `speaker_notes` fields, applies SSML acronym aliases for correct pronunciation of technical terms, and produces one WAV file per slide. Supports dry-run mode for SSML template verification without Azure credentials. + +## Prerequisites + +- **Azure Speech resource** — Free tier provides 500K characters per month. +- **Authentication** — Key-based (`SPEECH_KEY`) or Microsoft Entra ID (`SPEECH_RESOURCE_ID`). +- **Python 3.11+** with `uv` for virtual environment management. + +### Key-Based Auth + +```bash +export SPEECH_KEY="your-speech-key" +export SPEECH_REGION="eastus" +``` + +### Microsoft Entra ID Auth + +Requires a custom domain on the Speech resource and `Cognitive Services Speech User` role. + +```bash +export SPEECH_RESOURCE_ID="/subscriptions/.../Microsoft.CognitiveServices/accounts/your-resource" +export SPEECH_REGION="eastus" +``` + +Install dependencies: + +```bash +cd .github/skills/experimental/tts-voiceover +uv sync +``` + +## Quick Start + +Verify SSML templates without generating audio: + +```bash +uv run scripts/generate_voiceover.py --dry-run --content-dir path/to/content +``` + +Generate voice-over WAV files: + +```bash +uv run scripts/generate_voiceover.py --content-dir path/to/content --output-dir voice-over +``` + +Embed audio into a PPTX deck: + +```bash +uv run scripts/embed_audio.py --input deck.pptx --audio-dir voice-over --output deck-narrated.pptx +``` + +## Parameters Reference + +### generate_voiceover.py + +| Parameter | Type | Default | Description | +|:----------------|:-------|:------------------------------------|:----------------------------------------------| +| `--dry-run` | flag | `false` | Print SSML templates without generating audio | +| `--voice` | string | `en-US-Andrew:DragonHDLatestNeural` | Azure TTS voice name | +| `--rate` | string | `+10%` | Speech prosody rate | +| `--content-dir` | path | `content` | Path to slide content directory | +| `--output-dir` | path | `voice-over` | Path to WAV output directory | +| `--lexicon` | path | *(auto-detect)* | Custom acronyms.yaml path | + +### embed_audio.py + +| Parameter | Type | Default | Description | +|:--------------|:-----|:------------------|:-----------------------------| +| `--input` | path | *(required)* | Source PPTX file path | +| `--audio-dir` | path | `voice-over` | Directory with slide-NNN.wav | +| `--output` | path | `*-narrated.pptx` | Output PPTX file path | + +## Script Reference + +Generate with custom voice and rate: + +```bash +uv run scripts/generate_voiceover.py \ + --content-dir content \ + --output-dir voice-over \ + --voice "en-US-Jenny:DragonHDLatestNeural" \ + --rate "+5%" +``` + +Use a custom lexicon: + +```bash +uv run scripts/generate_voiceover.py \ + --content-dir content \ + --lexicon custom-acronyms.yaml +``` + +Embed generated audio: + +```bash +uv run scripts/embed_audio.py \ + --input slide-deck/presentation.pptx \ + --audio-dir voice-over \ + --output slide-deck/presentation-narrated.pptx +``` + +## Acronym Lexicon + +The lexicon controls SSML `` replacements for acronyms and technical terms. Create an `acronyms.yaml` file: + +```yaml +acronyms: + HVE-Core: "H V E Core" + OWASP: "Oh wasp" + SBOM: "S Bomb" + SLSA: "Salsa" + CI/CD: "C I C D" +``` + +Lexicon resolution order: + +1. Path specified via `--lexicon` argument. +2. `acronyms.yaml` in the content directory. +3. Built-in defaults covering common technical acronyms. + +## SSML Template + +Each slide produces an SSML document: + +```xml + + + + Text with OWASP aliases applied. + + + +``` + +## Integration with PowerPoint Skill + +This skill reads from the PowerPoint skill's content directory structure: + +```text +content/ +├── slide-001/ +│ └── content.yaml # Must include speaker_notes: field +├── slide-002/ +│ └── content.yaml +└── ... +``` + +Each `content.yaml` should contain a `speaker_notes:` field with the narration text. The generated WAV files are named `slide-NNN.wav` matching the directory names. + +## Troubleshooting + +| Issue | Solution | +|:-----------------------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------| +| `Set SPEECH_KEY ... or SPEECH_RESOURCE_ID` | Export `SPEECH_KEY` (key auth) or `SPEECH_RESOURCE_ID` (Entra ID) with `SPEECH_REGION`. | +| 401 with Entra ID auth | Verify custom domain on the Speech resource and `Cognitive Services Speech User` role. RBAC propagation takes up to 5 minutes. | +| Empty WAV files or skipped slides | Verify `speaker_notes:` is present and non-empty in `content.yaml`. | +| Mispronounced acronyms | Add entries to `acronyms.yaml` with phonetic aliases. | +| `azure-cognitiveservices-speech package is required` | Run `uv sync` in the skill directory. | +| Audio icon visible in PPTX | Reposition or resize the audio object in PowerPoint after embedding. | + +> Brought to you by microsoft/hve-core + +*🤖 Crafted with precision by ✨Copilot following brilliant human instruction, then carefully refined by our team of discerning human reviewers.* diff --git a/.github/skills/experimental/tts-voiceover/pyproject.toml b/.github/skills/experimental/tts-voiceover/pyproject.toml new file mode 100644 index 000000000..6975d88f2 --- /dev/null +++ b/.github/skills/experimental/tts-voiceover/pyproject.toml @@ -0,0 +1,31 @@ +[project] +name = "tts-voiceover-skill" +version = "0.0.0" +requires-python = ">=3.11" +dependencies = [ + "azure-cognitiveservices-speech", + "azure-identity", + "python-pptx", + "pyyaml", +] + +[dependency-groups] +dev = [ + "pytest>=9.0", + "ruff>=0.15", +] +fuzz = [ + "atheris>=3.0", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["scripts"] +python_files = ["test_*.py", "fuzz_harness.py"] + +[tool.ruff] +line-length = 88 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "W"] diff --git a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py new file mode 100644 index 000000000..d3e8bdf44 --- /dev/null +++ b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT +"""Embed per-slide WAV voice-over files into a PowerPoint deck. + +Reads slide-NNN.wav files from an audio directory and adds them as embedded +media objects in the corresponding slides of a PPTX file. + +Usage: + python embed_audio.py --input deck.pptx --audio-dir voice-over + python embed_audio.py --input deck.pptx --audio-dir voice-over \ + --output deck-narrated.pptx + +Note: python-pptx has limited audio embedding support. The audio is added via +``add_movie()`` with a small off-screen icon. Manual PowerPoint audio +configuration may produce better auto-play results. +""" + +from __future__ import annotations + +import argparse +import logging +import sys +from pathlib import Path + +from pptx import Presentation +from pptx.util import Inches + +logger = logging.getLogger(__name__) + +EXIT_SUCCESS = 0 +EXIT_FAILURE = 1 +EXIT_ERROR = 2 + +AUDIO_MIME_TYPE = "audio/wav" +ICON_SIZE = Inches(0.1) + + +def embed_slide_audio(slide: object, wav_path: Path) -> bool: + """Embed a WAV file into a slide as a media object. + + Returns True on success, False on failure. + """ + try: + slide.shapes.add_movie( + str(wav_path), + left=0, + top=0, + width=ICON_SIZE, + height=ICON_SIZE, + mime_type=AUDIO_MIME_TYPE, + ) + return True + except Exception: + logger.exception("Failed to embed audio %s", wav_path.name) + return False + + +def create_parser() -> argparse.ArgumentParser: + """Create and configure the argument parser.""" + parser = argparse.ArgumentParser( + description="Embed per-slide WAV voice-over files into a PPTX deck" + ) + parser.add_argument( + "--input", + type=Path, + required=True, + help="Source PPTX file path", + ) + parser.add_argument( + "--audio-dir", + type=Path, + default=Path("voice-over"), + help="Directory containing slide-NNN.wav files (default: voice-over)", + ) + parser.add_argument( + "--output", + type=Path, + default=None, + help="Output PPTX file path (default: input stem + '-narrated.pptx')", + ) + return parser + + +def main() -> int: + """Entry point for audio embedding.""" + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + parser = create_parser() + args = parser.parse_args() + + input_path: Path = args.input + audio_dir: Path = args.audio_dir + + if not input_path.is_file(): + logger.error("Input PPTX not found: %s", input_path) + return EXIT_FAILURE + + if not audio_dir.is_dir(): + logger.error("Audio directory not found: %s", audio_dir) + return EXIT_FAILURE + + output_path: Path = args.output or input_path.with_name( + f"{input_path.stem}-narrated.pptx" + ) + + prs = Presentation(str(input_path)) + embedded_count = 0 + + for idx, slide in enumerate(prs.slides, start=1): + wav_name = f"slide-{idx:03d}.wav" + wav_path = audio_dir / wav_name + if not wav_path.is_file(): + logger.info("SKIP slide %d: %s not found", idx, wav_name) + continue + + if embed_slide_audio(slide, wav_path): + embedded_count += 1 + logger.info("Embedded %s into slide %d", wav_name, idx) + else: + logger.error("FAILED to embed %s into slide %d", wav_name, idx) + + output_path.parent.mkdir(parents=True, exist_ok=True) + prs.save(str(output_path)) + logger.info( + "Saved %s with %d embedded audio files", output_path, embedded_count + ) + + return EXIT_SUCCESS + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py new file mode 100644 index 000000000..7f22b7d2c --- /dev/null +++ b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT +"""Generate per-slide TTS voice-over from YAML speaker notes via Azure Speech SDK. + +Part of the tts-voiceover skill. Reads content.yaml files from each slide +directory, extracts ``speaker_notes``, applies SSML acronym aliases, and +produces one WAV file per slide. + +Usage: + python generate_voiceover.py --dry-run --content-dir content + python generate_voiceover.py --content-dir content --output-dir voice-over + python generate_voiceover.py --lexicon custom-acronyms.yaml --content-dir content +""" + +from __future__ import annotations + +import argparse +import logging +import os +import sys +import time +import xml.sax.saxutils +from pathlib import Path + +import yaml + +logger = logging.getLogger(__name__) + +EXIT_SUCCESS = 0 +EXIT_FAILURE = 1 +EXIT_ERROR = 2 + +DEFAULT_VOICE = "en-US-Andrew:DragonHDLatestNeural" +DEFAULT_RATE = "+10%" + +_DEFAULT_ACRONYMS: dict[str, str] = { + "HVE-Core": "H V E Core", + "OWASP": "Oh wasp", + "SSSC": "S S S C", + "SPDX": "S P D X", + "SBOM": "S Bomb", + "SLSA": "Salsa", + "SARIF": "Sareef", + "CI/CD": "C I C D", + "STRIDE": "STRIDE", + "RAI": "R A I", + "GSN": "G S N", + "RPI": "R P I", + "ISE": "I S E", + "AST": "A S T", + "MCP": "M C P", +} + + +def load_acronyms(path: Path) -> dict[str, str]: + """Load acronym aliases from YAML, falling back to built-in defaults.""" + if path.is_file(): + data = yaml.safe_load(path.read_text(encoding="utf-8")) + acronyms = data.get("acronyms") if isinstance(data, dict) else None + if isinstance(acronyms, dict): + logger.info("Loaded %d acronyms from %s", len(acronyms), path) + return acronyms + logger.warning("Invalid acronyms format in %s; using defaults", path) + return dict(_DEFAULT_ACRONYMS) + + +def apply_acronym_aliases(text: str, acronyms: dict[str, str]) -> str: + """Replace acronyms with SSML ```` elements. + + Processes longest acronyms first to avoid partial matches. + """ + for acronym, alias in sorted(acronyms.items(), key=lambda x: -len(x[0])): + if acronym in text: + replacement = ( + f'' + f"{xml.sax.saxutils.escape(acronym)}" + ) + text = text.replace(acronym, replacement) + return text + + +def wrap_ssml(text: str, voice: str, rate: str) -> str: + """Wrap processed text in a full SSML document.""" + return ( + '\n' + f' \n' + f' \n' + f" {text}\n" + " \n" + " \n" + "" + ) + + +def generate_audio( + ssml: str, output_path: Path, speech_config: object +) -> float | None: + """Generate a WAV file from SSML. Returns duration in seconds or ``None``.""" + import azure.cognitiveservices.speech as speechsdk + + audio_config = speechsdk.audio.AudioOutputConfig(filename=str(output_path)) + synthesizer = speechsdk.SpeechSynthesizer( + speech_config=speech_config, audio_config=audio_config + ) + result = synthesizer.speak_ssml_async(ssml).get() + if result.reason == speechsdk.ResultReason.SynthesizingAudioCompleted: + return result.audio_duration.total_seconds() + cancellation = result.cancellation_details + logger.error( + "Synthesis failed: %s — %s", cancellation.reason, cancellation.error_details + ) + return None + + +def _make_entra_config( + speechsdk: object, + credential: object, + resource_id: str, + region: str, +) -> tuple: + """Create a SpeechConfig with a fresh Entra ID token. + + Returns (config, expires_at). + """ + token_obj = credential.get_token( + "https://cognitiveservices.azure.com/.default" + ) + auth_token = f"aad#{resource_id}#{token_obj.token}" + config = speechsdk.SpeechConfig(auth_token=auth_token, region=region) + config.set_speech_synthesis_output_format( + speechsdk.SpeechSynthesisOutputFormat.Riff24Khz16BitMonoPcm + ) + return config, token_obj.expires_on + + +def _resolve_lexicon(args_lexicon: Path | None, content_dir: Path) -> Path: + """Resolve the acronym lexicon path from argument, content dir, or defaults.""" + if args_lexicon is not None: + return args_lexicon + content_lexicon = content_dir / "acronyms.yaml" + if content_lexicon.is_file(): + return content_lexicon + return Path("acronyms.yaml") # falls through to built-in defaults + + +def create_parser() -> argparse.ArgumentParser: + """Create and configure the argument parser.""" + parser = argparse.ArgumentParser( + description="Generate per-slide TTS voice-over from YAML speaker notes" + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print SSML templates without generating audio", + ) + parser.add_argument( + "--voice", + default=DEFAULT_VOICE, + help=f"Azure TTS voice name (default: {DEFAULT_VOICE})", + ) + parser.add_argument( + "--rate", + default=DEFAULT_RATE, + help=f"Speech prosody rate (default: {DEFAULT_RATE})", + ) + parser.add_argument( + "--content-dir", + type=Path, + default=Path("content"), + help="Path to slide content directory (default: content)", + ) + parser.add_argument( + "--output-dir", + type=Path, + default=Path("voice-over"), + help="Path to WAV output directory (default: voice-over)", + ) + parser.add_argument( + "--lexicon", + type=Path, + default=None, + help="Path to custom acronyms.yaml lexicon file", + ) + return parser + + +def main() -> int: + """Entry point for TTS voice-over generation.""" + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + parser = create_parser() + args = parser.parse_args() + + content_dir: Path = args.content_dir + output_dir: Path = args.output_dir + + if not content_dir.is_dir(): + logger.error("Content directory not found: %s", content_dir) + return EXIT_FAILURE + + output_dir.mkdir(parents=True, exist_ok=True) + + lexicon_path = _resolve_lexicon(args.lexicon, content_dir) + acronyms = load_acronyms(lexicon_path) + + speech_config = None + credential = None + token_expires_at = 0 + if not args.dry_run: + try: + import azure.cognitiveservices.speech as speechsdk + except ImportError: + logger.error( + "azure-cognitiveservices-speech package is required" + " for audio generation" + ) + return EXIT_FAILURE + + speech_key = os.environ.get("SPEECH_KEY") + speech_region = os.environ.get("SPEECH_REGION", "eastus") + speech_resource_id = os.environ.get("SPEECH_RESOURCE_ID") + + if speech_key: + speech_config = speechsdk.SpeechConfig( + subscription=speech_key, region=speech_region + ) + speech_config.set_speech_synthesis_output_format( + speechsdk.SpeechSynthesisOutputFormat.Riff24Khz16BitMonoPcm + ) + elif speech_resource_id: + try: + from azure.identity import DefaultAzureCredential + except ImportError: + logger.error( + "azure-identity package is required for Entra ID auth" + ) + return EXIT_FAILURE + credential = DefaultAzureCredential() + speech_config, token_expires_at = _make_entra_config( + speechsdk, credential, speech_resource_id, speech_region + ) + else: + logger.error( + "Set SPEECH_KEY (key auth) or SPEECH_RESOURCE_ID (Entra ID auth)" + " with SPEECH_REGION" + ) + return EXIT_ERROR + + total_duration = 0.0 + slide_count = 0 + + for slide_dir in sorted(content_dir.glob("slide-*")): + content_file = slide_dir / "content.yaml" + if not content_file.is_file(): + continue + + data = yaml.safe_load(content_file.read_text(encoding="utf-8")) + notes = data.get("speaker_notes", "").strip() + title = data.get("title", slide_dir.name) + + if not notes: + logger.info("SKIP %s: no speaker notes", slide_dir.name) + continue + + safe_notes = xml.sax.saxutils.escape(notes) + processed = apply_acronym_aliases(safe_notes, acronyms) + ssml = wrap_ssml(processed, args.voice, args.rate) + slide_count += 1 + + if args.dry_run: + print(f"\n=== {slide_dir.name}: {title} ===") + print(ssml) + continue + + # Refresh Entra ID token before expiry. + if ( + speech_resource_id + and not speech_key + and time.time() > token_expires_at - 300 + ): + speech_config, token_expires_at = _make_entra_config( + speechsdk, credential, speech_resource_id, speech_region + ) + logger.info("Refreshed Entra ID token") + + wav_path = output_dir / f"{slide_dir.name}.wav" + logger.info("Generating %s: %s ...", slide_dir.name, title) + duration = generate_audio(ssml, wav_path, speech_config) + if duration is not None: + total_duration += duration + logger.info(" %s — %.1fs", wav_path.name, duration) + else: + logger.error(" FAILED: %s", wav_path.name) + + if args.dry_run: + print(f"\n--- Dry run complete: {slide_count} slides processed ---") + else: + logger.info( + "Total narration: %.1fs (%.1f min) across %d slides", + total_duration, + total_duration / 60, + slide_count, + ) + + return EXIT_SUCCESS + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/skills/experimental/tts-voiceover/tests/fuzz_harness.py b/.github/skills/experimental/tts-voiceover/tests/fuzz_harness.py new file mode 100644 index 000000000..72814ee6c --- /dev/null +++ b/.github/skills/experimental/tts-voiceover/tests/fuzz_harness.py @@ -0,0 +1,169 @@ +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT +"""Polyglot fuzz harness for TTS voice-over skill modules. + +Runs as a pytest test when Atheris is not installed (CI default). +Runs as an Atheris coverage-guided fuzz target when executed directly. +""" + +from __future__ import annotations + +import sys +import tempfile +from contextlib import suppress +from pathlib import Path + +try: + import atheris + + FUZZING = True +except ImportError: + FUZZING = False + +from generate_voiceover import ( + _DEFAULT_ACRONYMS, + apply_acronym_aliases, + load_acronyms, + wrap_ssml, +) + +# --------------------------------------------------------------------------- +# Fuzz targets — pure functions exercised by both modes +# --------------------------------------------------------------------------- + + +def fuzz_apply_acronym_aliases(data): + """Fuzz apply_acronym_aliases with random text and the default acronym dict.""" + fdp = atheris.FuzzedDataProvider(data) + text = fdp.ConsumeUnicodeNoSurrogates(500) + with suppress(ValueError, TypeError): + apply_acronym_aliases(text, dict(_DEFAULT_ACRONYMS)) + + +def fuzz_wrap_ssml(data): + """Fuzz wrap_ssml with random text, voice, and rate strings.""" + fdp = atheris.FuzzedDataProvider(data) + text = fdp.ConsumeUnicodeNoSurrogates(200) + voice = fdp.ConsumeUnicodeNoSurrogates(50) + rate = fdp.ConsumeUnicodeNoSurrogates(10) + with suppress(ValueError, TypeError): + wrap_ssml(text, voice, rate) + + +def fuzz_load_acronyms(data): + """Fuzz load_acronyms with random YAML content written to a temp file.""" + fdp = atheris.FuzzedDataProvider(data) + content = fdp.ConsumeUnicodeNoSurrogates(300) + with tempfile.NamedTemporaryFile( + mode="w", suffix=".yaml", delete=False, encoding="utf-8" + ) as tmp: + tmp.write(content) + tmp_path = Path(tmp.name) + try: + with suppress(Exception): + load_acronyms(tmp_path) + finally: + tmp_path.unlink(missing_ok=True) + + +FUZZ_TARGETS = [ + fuzz_apply_acronym_aliases, + fuzz_wrap_ssml, + fuzz_load_acronyms, +] + + +def fuzz_dispatch(data): + """Route Atheris input to one of the registered fuzz targets.""" + if len(data) < 2: + return + idx = data[0] % len(FUZZ_TARGETS) + FUZZ_TARGETS[idx](data[1:]) + + +# --------------------------------------------------------------------------- +# pytest mode — property-based tests for the same targets +# --------------------------------------------------------------------------- + + +class TestFuzzApplyAcronymAliases: + """Property tests for apply_acronym_aliases.""" + + def test_known_acronym_replaced(self): + result = apply_acronym_aliases("Check OWASP guidelines", _DEFAULT_ACRONYMS) + assert "Oh wasp" in result + assert "") + + +class TestFuzzLoadAcronyms: + """Property tests for load_acronyms.""" + + def test_nonexistent_file_returns_defaults(self): + result = load_acronyms(Path("/nonexistent/acronyms.yaml")) + assert result == _DEFAULT_ACRONYMS + + def test_valid_yaml_file(self, tmp_path): + lexicon = tmp_path / "acronyms.yaml" + lexicon.write_text('acronyms:\n FOO: "bar"\n', encoding="utf-8") + result = load_acronyms(lexicon) + assert result == {"FOO": "bar"} + + def test_invalid_format_returns_defaults(self, tmp_path): + lexicon = tmp_path / "acronyms.yaml" + lexicon.write_text("acronyms: not-a-dict\n", encoding="utf-8") + result = load_acronyms(lexicon) + assert result == _DEFAULT_ACRONYMS + + def test_empty_file_returns_defaults(self, tmp_path): + lexicon = tmp_path / "acronyms.yaml" + lexicon.write_text("", encoding="utf-8") + result = load_acronyms(lexicon) + assert result == _DEFAULT_ACRONYMS + + +# --------------------------------------------------------------------------- +# Atheris entry point — only runs when executed directly with Atheris installed +# --------------------------------------------------------------------------- + +if __name__ == "__main__" and FUZZING: + atheris.instrument_all() + atheris.Setup(sys.argv, fuzz_dispatch) + atheris.Fuzz() diff --git a/.github/skills/experimental/tts-voiceover/uv.lock b/.github/skills/experimental/tts-voiceover/uv.lock new file mode 100644 index 000000000..05192cc20 --- /dev/null +++ b/.github/skills/experimental/tts-voiceover/uv.lock @@ -0,0 +1,570 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "atheris" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/58/5965955898e16bee17c8379eae12194993bf641c4629016991248b862069/atheris-3.0.0.tar.gz", hash = "sha256:1f0929c7bc3040f3fe4102e557718734190cf2d7718bbb8e3ce6d3eb56ef5bb3", size = 373239, upload-time = "2025-11-24T23:54:02.15Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/15/cf109e2e8696a54c8c4bc3ef79a79bec32361eceb64eaa36690a682e83a9/atheris-3.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8a5c8a781467c187da40fd29139784193e2647058831f837f675d0bb8cbd8746", size = 34805555, upload-time = "2025-11-24T23:53:53.477Z" }, + { url = "https://files.pythonhosted.org/packages/85/8c/e9960b996e70e5f6a523670431166b2b238de52fef094955515dcf854da1/atheris-3.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:510e502c57b6dc615fb174066407af620d4c7f73cf08a782c86e7761bf12c4eb", size = 34907016, upload-time = "2025-11-24T23:53:56.535Z" }, + { url = "https://files.pythonhosted.org/packages/db/48/df670f75f458cc7c1752a01a394fd59c830b08172dd59cf29d73f31050f9/atheris-3.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a402cdca8a650d1371050b1f9552eb4cdc488d2db64950d603c4560318365eac", size = 34858525, upload-time = "2025-11-24T23:53:59.925Z" }, +] + +[[package]] +name = "azure-cognitiveservices-speech" +version = "1.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c1/22cefd6ceb89656098d4b353eb073267ff9cbbe2f9325a224ee83cb6c87e/azure_cognitiveservices_speech-1.49.0-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:a545140ee81b2691d79e7d918d2e59e622ac8c6aaa67a82475d888a3cf0e36d4", size = 3445919, upload-time = "2026-04-07T18:21:57.792Z" }, + { url = "https://files.pythonhosted.org/packages/da/e1/413cb8189e8d5d0e9ebb580af82bc964f506477e1436992db9b9738b532f/azure_cognitiveservices_speech-1.49.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6829fa8667da697ac6d0e007fef0990fbb97a4a54a89906bfed4f6f8abd7c937", size = 3244891, upload-time = "2026-04-07T18:22:00.022Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4d/5165e31fa55bfb2dd76decd18b81e62b9678efb17824c9f643f63449ff23/azure_cognitiveservices_speech-1.49.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:3aa556354ae0663d0cfea0c5a4fd72ba203191cfd678be71058336ec560f8b44", size = 2773301, upload-time = "2026-04-07T18:22:01.915Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b1/2cb152e10cbeb9f03eb4ae03b91b85634c1e73621df97ac514c1fa6cceda/azure_cognitiveservices_speech-1.49.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:e4cb429763c24bd069589b2736013c93d02282b0058c3afb2fc0c240cc5df92b", size = 2707645, upload-time = "2026-04-07T18:22:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/96/ba/a1beb6053e60d396f56765ec6194e76a339b53a2973bd6319922e5d507cc/azure_cognitiveservices_speech-1.49.0-py3-none-win_amd64.whl", hash = "sha256:48b00ab6ce982dcc3a0835ee232d667a869ed5c56cca74ad0f5e2c53d51b1b3f", size = 2587841, upload-time = "2026-04-07T18:22:05.592Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/bc2c90b3867c9331bdbaec92ccc29f147f1f6e2dcbdb093cba3f9624f55f/azure_cognitiveservices_speech-1.49.0-py3-none-win_arm64.whl", hash = "sha256:e2d23a535f2756bd855c62ff815cd5a6f36c004c056548c4eeb3bd3ede7e0347", size = 2340202, upload-time = "2026-04-07T18:22:07.119Z" }, +] + +[[package]] +name = "azure-core" +version = "1.39.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/83/bbde3faa84ddcb8eb0eca4b3ffb3221252281db4ce351300fe248c5c70b1/azure_core-1.39.0.tar.gz", hash = "sha256:8a90a562998dd44ce84597590fff6249701b98c0e8797c95fcdd695b54c35d74", size = 367531, upload-time = "2026-03-19T01:31:29.461Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d6/8ebcd05b01a580f086ac9a97fb9fac65c09a4b012161cc97c21a336e880b/azure_core-1.39.0-py3-none-any.whl", hash = "sha256:4ac7b70fab5438c3f68770649a78daf97833caa83827f91df9c14e0e0ea7d34f", size = 218318, upload-time = "2026-03-19T01:31:31.25Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "lxml" +version = "6.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/08/1217ca4043f55c3c92993b283a7dbfa456a2058d8b57bbb416cc96b6efff/lxml-6.0.4.tar.gz", hash = "sha256:4137516be2a90775f99d8ef80ec0283f8d78b5d8bd4630ff20163b72e7e9abf2", size = 4237780, upload-time = "2026-04-12T16:28:24.182Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/93/5145f2c9210bf99c01f2f54d364be805f556f2cb13af21d3c2d80e0780bb/lxml-6.0.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3602d57fdb6f744f4c5d0bd49513fe5abbced08af85bba345fc354336667cd47", size = 8525003, upload-time = "2026-04-12T16:23:34.045Z" }, + { url = "https://files.pythonhosted.org/packages/93/19/9d61560a53ac1b26aec1a83ae51fadbe0cc0b6534e2c753ad5af854f231b/lxml-6.0.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b8c7976c384dcab4bca42f371449fb711e20f1bfce99c135c9b25614aed80e55", size = 4594697, upload-time = "2026-04-12T16:23:36.403Z" }, + { url = "https://files.pythonhosted.org/packages/93/1a/0db40884f959c94ede238507ea0967dd47527ab11d130c5a571088637e78/lxml-6.0.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:579e20c120c3d231e53f0376058e4e1926b71ca4f7b77a7a75f82aea7a9b501e", size = 4922365, upload-time = "2026-04-12T16:23:38.709Z" }, + { url = "https://files.pythonhosted.org/packages/04/db/4136fab3201087bd5a4db433b9a36e50808d8af759045e7d7af757b46178/lxml-6.0.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f32a27be5fb286febd16c0d13d4a3aee474d34417bd172e64d76c6a28e2dc14", size = 5066748, upload-time = "2026-04-12T16:23:41.048Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/aad543afc57e6268200332ebe695be0320fdd2219b175d34a52027aa1bad/lxml-6.0.4-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d53b7cdaa961a4343312964f6c5a150d075a55e95e1338078d413bf38eba8c0", size = 5000464, upload-time = "2026-04-12T16:23:42.946Z" }, + { url = "https://files.pythonhosted.org/packages/ab/92/14cc575b97dedf02eb8de96af8d977f06b9f2500213805165606ff06c011/lxml-6.0.4-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d4cc697347f6c61764b58767109e270d0b4a92aba4a8053a967ed9de23a5ea9", size = 5201395, upload-time = "2026-04-12T16:23:45.227Z" }, + { url = "https://files.pythonhosted.org/packages/a7/72/0ff17f32a737a9c2840f781aee4bbd5cec947b966ff0c74c5dec56098beb/lxml-6.0.4-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:108b8d6da624133eaa1a6a5bbcb1f116b878ea9fd050a1724792d979251706fb", size = 5329108, upload-time = "2026-04-12T16:23:48.094Z" }, + { url = "https://files.pythonhosted.org/packages/f7/f7/3b1f43e0db54462b5f1ebd96ee43b240388e3b9bf372546694175bec2d41/lxml-6.0.4-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c087d643746489df06fe3ac03460d235b4b3ae705e25838257510c79f834e50f", size = 4658132, upload-time = "2026-04-12T16:23:50.279Z" }, + { url = "https://files.pythonhosted.org/packages/94/cb/90513445e4f08c500f953543aadf18501e5438b31bc816d0ce9a5e09cc5c/lxml-6.0.4-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2063c486f80c32a576112201c93269a09ebeca5b663092112c5fb39b32556340", size = 5264665, upload-time = "2026-04-12T16:23:52.397Z" }, + { url = "https://files.pythonhosted.org/packages/17/d2/c1fa939ea0fa75190dd452d9246f97c16372e2d593fe9f4684cae5c37dda/lxml-6.0.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ff016e86ec14ae96253a3834302e0e89981956b73e4e74617eeba4a6a81da08b", size = 5043801, upload-time = "2026-04-12T16:23:55.634Z" }, + { url = "https://files.pythonhosted.org/packages/22/d4/01cdd3c367045526a376cc1eadacf647f193630db3f902b8842a76b3eb2e/lxml-6.0.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:0e9ba5bcd75efb8cb4613463e6cfb55b5a76d4143e4cfa06ea027bc6cc696a3e", size = 4711416, upload-time = "2026-04-12T16:23:57.647Z" }, + { url = "https://files.pythonhosted.org/packages/8d/77/f6af805c6e23b9a12970c8c38891b087ffd884c2d4df6069e63ff1623fd6/lxml-6.0.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:9a69668bef9268f54a92f2254917df530ca4630a621027437f0e948eb1937e7b", size = 5251326, upload-time = "2026-04-12T16:23:59.901Z" }, + { url = "https://files.pythonhosted.org/packages/2b/bb/bcd429655f6d12845d91f17e3977d63de22cde5fa77f7d4eef7669a80e8c/lxml-6.0.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280f8e7398bdc48c7366ad375a5586692cd73b269d9e82e6898f9ada70dc0bcb", size = 5224752, upload-time = "2026-04-12T16:24:02.002Z" }, + { url = "https://files.pythonhosted.org/packages/69/cd/0342c5a3663115560899a0529789969a72bc5209c8f0084e5b0598cda94d/lxml-6.0.4-cp311-cp311-win32.whl", hash = "sha256:a8eddf3c705e00738db695a9a77830f8d57f7d21a54954fbef23a1b8806384ed", size = 3592977, upload-time = "2026-04-12T16:24:03.847Z" }, + { url = "https://files.pythonhosted.org/packages/92/c1/386ee2e8a8008cccc4903435f19aaffd16d9286186106752d08be2bd7ccb/lxml-6.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:b74d5b391fc49fc3cc213c930f87a7dedf2b4b0755aae4638e91e4501e278430", size = 4023718, upload-time = "2026-04-12T16:24:06.135Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a0/19f5072fdc7c73d44004506172dba4b7e3d179d9b3a387efce9c30365afd/lxml-6.0.4-cp311-cp311-win_arm64.whl", hash = "sha256:2f0cf04bafc14b0eebfbc3b5b73b296dd76b5d7640d098c02e75884bb0a70f2b", size = 3666955, upload-time = "2026-04-12T16:24:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/18/4732abab49bbb041b1ded9dd913ca89735a0dcca038eacec64c44ba02163/lxml-6.0.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:af0b8459c4e21a8417db967b2e453d1855022dac79c79b61fb8214f3da50f17e", size = 8570033, upload-time = "2026-04-12T16:24:10.728Z" }, + { url = "https://files.pythonhosted.org/packages/72/7e/38523ec7178ca35376551911455d1b2766bc9d98bcc18f606a167fa9ecbb/lxml-6.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e0cdcea2affa53fa17dc4bf5cefc0edf72583eac987d669493a019998a623fa3", size = 4623270, upload-time = "2026-04-12T16:24:13.2Z" }, + { url = "https://files.pythonhosted.org/packages/f1/cf/f9b6c9bf9d8c63d923ef893915141767cea4cea71774f20c36d0c14e1585/lxml-6.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8da4d4840c1bc07da6fcd647784f7fbaf538eeb7a57ce6b2487acc54c5e33330", size = 4929471, upload-time = "2026-04-12T16:24:15.453Z" }, + { url = "https://files.pythonhosted.org/packages/e5/53/3117f988c9e20be4156d2b8e1bda82ae06878d11aeb820dea111a7cfa4e3/lxml-6.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fb04a997588c3980894ded9172c10c5a3e45d3f1c5410472733626d268683806", size = 5092355, upload-time = "2026-04-12T16:24:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ca/05c6ac773a2bd3edb48fa8a5c5101e927ce044c4a8aed1a85ff00fab20a5/lxml-6.0.4-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca449642a08a6ceddf6e6775b874b6aee1b6242ed80aea84124497aba28e5384", size = 5004520, upload-time = "2026-04-12T16:24:20.184Z" }, + { url = "https://files.pythonhosted.org/packages/f1/db/d8aa5aa3a51d0aa6706ef85f85027f7c972cd840fe69ba058ecaf32d093d/lxml-6.0.4-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35b3ccdd137e62033662787dd4d2b8be900c686325d6b91e3b1ff6213d05ba11", size = 5629961, upload-time = "2026-04-12T16:24:22.242Z" }, + { url = "https://files.pythonhosted.org/packages/9d/75/8fff4444e0493aeb15ab0f4a55c767b5baed9074cf67a1835dc1161f3a1f/lxml-6.0.4-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:45dc690c54b1341fec01743caed02e5f1ea49d7cfb81e3ba48903e5e844ed68a", size = 5237561, upload-time = "2026-04-12T16:24:24.572Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9f/6d6cd73014f2dbf47a8aa7accd9712726f46ef4891e1c126bc285cfb94e4/lxml-6.0.4-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:15ae922e8f74b05798a0e88cee46c0244aaec6a66b5e00be7d18648fed8c432e", size = 5349197, upload-time = "2026-04-12T16:24:26.805Z" }, + { url = "https://files.pythonhosted.org/packages/2d/43/e3e9a126e166234d1659d1dd9004dc1dd50cdc3c68575b071b0a1524b4de/lxml-6.0.4-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:ebd816653707fbf10c65e3dee3bc24dac6b691654c21533b1ae49287433f4db0", size = 4693123, upload-time = "2026-04-12T16:24:28.812Z" }, + { url = "https://files.pythonhosted.org/packages/6c/98/b146dd123a4a7b69b571ff23ea8e8c68de8d8c1b03e23d01c6374d4fd835/lxml-6.0.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:21284cf36b95dd8be774eb06c304b440cf49ee811800a30080ce6d93700f0383", size = 5242967, upload-time = "2026-04-12T16:24:30.811Z" }, + { url = "https://files.pythonhosted.org/packages/7e/60/8c275584452b55a902c883e8ab63d755c5ef35d7ad1f06f9e6559095521d/lxml-6.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c08a2a9d0c4028ef5fc5a513b2e1e51af069a83c5b4206139edd08b3b8c2926", size = 5046810, upload-time = "2026-04-12T16:24:33.289Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/19ec216147e1105e5403fe73657c693a6e91bde855a13242dd6031e829e5/lxml-6.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1bc2f0f417112cf1a428599dd58125ab74d8e1c66893efd9b907cbb4a5db6e44", size = 4776383, upload-time = "2026-04-12T16:24:36.008Z" }, + { url = "https://files.pythonhosted.org/packages/41/c8/90afdb838705a736268fcffd2698c05e9a129144ce215d5e14db3bdfc295/lxml-6.0.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c0d86e328405529bc93913add9ff377e8b8ea9be878e611f19dbac7766a84483", size = 5643497, upload-time = "2026-04-12T16:24:38.276Z" }, + { url = "https://files.pythonhosted.org/packages/32/ec/1135261ec9822dafb90be0ff6fb0ec79cee0b7fe878833dfe5f2b8c393bd/lxml-6.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3cce9420fe8f91eae5d457582599d282195c958cb670aa4bea313a79103ba33f", size = 5232185, upload-time = "2026-04-12T16:24:40.516Z" }, + { url = "https://files.pythonhosted.org/packages/13/f2/7380b11cae6943720f525e5a28ad9dbead96ac710417e556b7c03f3a8af3/lxml-6.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:96214985ec194ce97b9028414e179cfb21230cba4e2413aee7e249461bb84f4d", size = 5259968, upload-time = "2026-04-12T16:24:42.917Z" }, + { url = "https://files.pythonhosted.org/packages/65/8f/141734f2c456f2253fed4237d8d4b241e3d701129cf6f0b135ccf241a75a/lxml-6.0.4-cp312-cp312-win32.whl", hash = "sha256:b2209b310e7ed1d4cd1c00d405ec9c49722fce731c7036abc1d876bf8df78139", size = 3594958, upload-time = "2026-04-12T16:24:45.039Z" }, + { url = "https://files.pythonhosted.org/packages/b7/a9/c6d3531c6d8814af0919fbdb9bda43c9e8b5deffcb70c8534017db233512/lxml-6.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:03affcacfba4671ebc305813b02bfaf34d80b6a7c5b23eafc5d6da14a1a6e623", size = 3995897, upload-time = "2026-04-12T16:24:46.98Z" }, + { url = "https://files.pythonhosted.org/packages/03/5d/1dabeddf762e5a315a31775b2bca39811d7e7a15fc3e677d044b9da973fe/lxml-6.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:af9678e3a2a047465515d95a61690109af7a4c9486f708249119adcef7861049", size = 3658607, upload-time = "2026-04-12T16:24:49.19Z" }, + { url = "https://files.pythonhosted.org/packages/78/f6/550a1ed9afde66e24bfcf9892446ea9779152df336062c6df0f7733151a2/lxml-6.0.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ecc3d55ed756ee6c3447748862a97e1f5392d2c5d7f474bace9382345e4fc274", size = 8559522, upload-time = "2026-04-12T16:24:51.563Z" }, + { url = "https://files.pythonhosted.org/packages/11/93/3f687c14d2b4d24b60fe13fd5482c8853f82a10bb87f2b577123e342ed1a/lxml-6.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7d5a627a368a0e861350ccc567a70ec675d2bc4d8b3b54f48995ae78d8d530e", size = 4617380, upload-time = "2026-04-12T16:24:54.042Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ed/91e443366063d3fb7640ae2badd5d7b65be4095ac6d849788e39c043baae/lxml-6.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d385141b186cc39ebe4863c1e41936282c65df19b2d06a701dedc2a898877d6a", size = 4922791, upload-time = "2026-04-12T16:24:56.381Z" }, + { url = "https://files.pythonhosted.org/packages/30/4b/2243260b70974aca9ba0cc71bd668c0c3a79644d80ddcabbfbdb4b131848/lxml-6.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0132bb040e9bb5a199302e12bf942741defbc52922a2a06ce9ff7be0d0046483", size = 5080972, upload-time = "2026-04-12T16:24:58.823Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c3/54c53c4f772341bc12331557f8b0882a426f53133926306cbe6d7f0ee7e4/lxml-6.0.4-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26aee5321e4aa1f07c9090a35f6ab8b703903fb415c6c823cfdb20ee0d779855", size = 4992236, upload-time = "2026-04-12T16:25:01.099Z" }, + { url = "https://files.pythonhosted.org/packages/be/0f/416de42e22f287585abee610eb0d1c2638c9fe24cee7e15136e0b5e138f8/lxml-6.0.4-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b5652455de198ff76e02cfa57d5efc5f834fa45521aaf3fcc13d6b5a88bde23d", size = 5612398, upload-time = "2026-04-12T16:25:03.517Z" }, + { url = "https://files.pythonhosted.org/packages/7d/63/29a3fa79b8a182f5bd5b5bdcb6f625f49f08f41d60a26ca25482820a1b99/lxml-6.0.4-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:75842801fb48aea73f4c281b923a010dfb39bad75edf8ceb2198ec30c27f01cc", size = 5227480, upload-time = "2026-04-12T16:25:06.119Z" }, + { url = "https://files.pythonhosted.org/packages/7c/4a/44d1843de599b1c6dbe578e4248c2f15e7fac90c5c86eb26775eaeac0fe0/lxml-6.0.4-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:94a1f74607a5a049ff6ff8de429fec922e643e32b5b08ec7a4fe49e8de76e17c", size = 5341001, upload-time = "2026-04-12T16:25:08.563Z" }, + { url = "https://files.pythonhosted.org/packages/0d/52/c8aebde49f169e4e3452e7756be35be1cb2903e30d961cb57aa65a27055f/lxml-6.0.4-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:173cc246d3d3b6d3b6491f0b3aaf22ebdf2eed616879482acad8bd84d73eb231", size = 4699105, upload-time = "2026-04-12T16:25:10.757Z" }, + { url = "https://files.pythonhosted.org/packages/78/60/76fc3735c31c28b70220d99452fb72052e84b618693ca2524da96f0131d8/lxml-6.0.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f0f2ee1be1b72e9890da87e4e422f2f703ff4638fd5ec5383055db431e8e30e9", size = 5231095, upload-time = "2026-04-12T16:25:13.305Z" }, + { url = "https://files.pythonhosted.org/packages/e5/60/448f01c52110102f23df5f07b3f4fde57c8e13e497e182a743d125324c0b/lxml-6.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c51a274b7e8b9ce394c3f8b471eb0b23c1914eec64fdccf674e082daf72abf11", size = 5042411, upload-time = "2026-04-12T16:25:15.541Z" }, + { url = "https://files.pythonhosted.org/packages/4a/2a/90612a001fa4fa0ff0443ebb0256a542670fe35473734c559720293e7aff/lxml-6.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:210ea934cba1a1ec42f88c4190c4d5c67b2d14321a8faed9b39e8378198ff99d", size = 4768431, upload-time = "2026-04-12T16:25:17.581Z" }, + { url = "https://files.pythonhosted.org/packages/84/d8/572845a7d741c8a8ffeaf928185263e14d97fbd355de164677340951d7a5/lxml-6.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:14fe654a59eebe16368c51778caeb0c8fda6f897adcd9afe828d87d13b5d5e51", size = 5634972, upload-time = "2026-04-12T16:25:20.111Z" }, + { url = "https://files.pythonhosted.org/packages/d7/1d/392b8c9f8cf1d502bbec50dee137c7af3dd5def5e5cd84572fbf0ba0541c/lxml-6.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ec160a2b7e2b3cb71ec35010b19a1adea05785d19ba5c9c5f986b64b78fef564", size = 5222909, upload-time = "2026-04-12T16:25:22.243Z" }, + { url = "https://files.pythonhosted.org/packages/21/ab/949fc96f825cf083612aee65d5a02eacc5eaeb2815561220e33e1e160677/lxml-6.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d305b86ef10b23cf3a6d62a2ad23fa296f76495183ee623f64d2600f65ffe09c", size = 5249096, upload-time = "2026-04-12T16:25:24.781Z" }, + { url = "https://files.pythonhosted.org/packages/56/e8/fbe44df79ede5ff760401cc3c49c4204f49f0f529cc6b27d0af7b63f5472/lxml-6.0.4-cp313-cp313-win32.whl", hash = "sha256:a2f31380aa9a9b52591e79f1c1d3ac907688fbeb9d883ba28be70f2eb5db2277", size = 3595808, upload-time = "2026-04-12T16:25:26.747Z" }, + { url = "https://files.pythonhosted.org/packages/f8/df/e873abb881092256520edf0d67d686e36f3c86b3cf289f01b6458272dede/lxml-6.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:b8efa9f681f15043e497293d58a4a63199564b253ed2291887d92bb3f74f59ab", size = 3994635, upload-time = "2026-04-12T16:25:28.828Z" }, + { url = "https://files.pythonhosted.org/packages/23/a8/9c56c8914b9b18d89face5a7472445002baf309167f7af65d988842129fd/lxml-6.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:905abe6a5888129be18f85f2aea51f0c9863fa0722fb8530dfbb687d2841d221", size = 3657374, upload-time = "2026-04-12T16:25:30.901Z" }, + { url = "https://files.pythonhosted.org/packages/10/18/36e28a809c509a67496202771f545219ac5a2f1cd61aae325991fcf5ab91/lxml-6.0.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:569d3b18340863f603582d2124e742a68e85755eff5e47c26a55e298521e3a01", size = 8575045, upload-time = "2026-04-12T16:25:33.57Z" }, + { url = "https://files.pythonhosted.org/packages/11/38/a168c820e3b08d3b4fa0f4e6b53b3930086b36cc11e428106d38c36778cd/lxml-6.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3b6245ee5241342d45e1a54a4a8bc52ef322333ada74f24aa335c4ab36f20161", size = 4622963, upload-time = "2026-04-12T16:25:36.818Z" }, + { url = "https://files.pythonhosted.org/packages/53/e0/2c9d6abdd82358cea3c0d8d6ca272a6af0f38156abce7827efb6d5b62d17/lxml-6.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79a1173ba3213a3693889a435417d4e9f3c07d96e30dc7cc3a712ed7361015fe", size = 4948832, upload-time = "2026-04-12T16:25:39.104Z" }, + { url = "https://files.pythonhosted.org/packages/96/d7/f2202852e91d7baf3a317f4523a9c14834145301e5b0f2e80c01c4bfbd49/lxml-6.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dc18bb975666b443ba23aedd2fcf57e9d0d97546b52a1de97a447c4061ba4110", size = 5085865, upload-time = "2026-04-12T16:25:41.226Z" }, + { url = "https://files.pythonhosted.org/packages/09/57/abee549324496e92708f71391c6060a164d3c95369656a1a15e9f20d8162/lxml-6.0.4-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2079f5dc83291ac190a52f8354b78648f221ecac19fb2972a2d056b555824de7", size = 5030001, upload-time = "2026-04-12T16:25:43.695Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f8/432da7178c5917a16468af6c5da68fef7cf3357d4bd0e6f50272ec9a59b5/lxml-6.0.4-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3eda02da4ca16e9ca22bbe5654470c17fa1abcd967a52e4c2e50ff278221e351", size = 5646303, upload-time = "2026-04-12T16:25:46.577Z" }, + { url = "https://files.pythonhosted.org/packages/82/f9/e1c04ef667a6bf9c9dbd3bf04c50fa51d7ee25b258485bb748b27eb9a1c7/lxml-6.0.4-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3787cdc3832b70e21ac2efafea2a82a8ccb5e85bec110dc68b26023e9d3caae", size = 5237940, upload-time = "2026-04-12T16:25:49.157Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f0/cdea60d92df731725fc3c4f33e387b100f210acd45c92969e42d2ba993fa/lxml-6.0.4-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:3f276d49c23103565d39440b9b3f4fc08fa22f5a96395ea4b4d4fea4458b1505", size = 5350050, upload-time = "2026-04-12T16:25:52.027Z" }, + { url = "https://files.pythonhosted.org/packages/2e/15/bf52c7a70b6081bb9e00d37cc90fcf60aa84468d9d173ad2fade38ec34c5/lxml-6.0.4-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:fdfdad73736402375b11b3a137e48cd09634177516baf5fc0bd80d1ca85f3cda", size = 4696409, upload-time = "2026-04-12T16:25:55.141Z" }, + { url = "https://files.pythonhosted.org/packages/c5/69/9bade267332cc06f9a9aa773b5a11bdfb249af485df9e142993009ea1fc4/lxml-6.0.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75912421456946931daba0ec3cedfa824c756585d05bde97813a17992bfbd013", size = 5249072, upload-time = "2026-04-12T16:25:57.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/ca/043bcacb096d6ed291cbbc58724e9625a453069d6edeb840b0bf18038d05/lxml-6.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:48cd5a88da67233fd82f2920db344503c2818255217cd6ea462c9bb8254ba7cb", size = 5083779, upload-time = "2026-04-12T16:26:00.018Z" }, + { url = "https://files.pythonhosted.org/packages/04/89/f5fb18d76985969e84af13682e489acabee399bb54738a363925ea6e7390/lxml-6.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:87af86a8fa55b9ff1e6ee4233d762296f2ce641ba948af783fb995c5a8a3371b", size = 4736953, upload-time = "2026-04-12T16:26:02.289Z" }, + { url = "https://files.pythonhosted.org/packages/84/ba/d1d7284bb4ba951f188c3fc0455943c1fcbd1c33d1324d6d57b7d4a45be6/lxml-6.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a743714cd656ba7ccb29d199783906064c7b5ba3c0e2a79f0244ea0badc6a98c", size = 5669605, upload-time = "2026-04-12T16:26:04.694Z" }, + { url = "https://files.pythonhosted.org/packages/72/05/1463e55f2de27bb60feddc894dd7c0833bd501f8861392ed416291b38db5/lxml-6.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e31c76bd066fb4f81d9a32e5843bffdf939ab27afb1ffc1c924e749bfbdb00e3", size = 5236886, upload-time = "2026-04-12T16:26:07.659Z" }, + { url = "https://files.pythonhosted.org/packages/fe/fb/0b6ee9194ce3ac49db4cadaa8a9158f04779fc768b6c27c4e2945d71a99d/lxml-6.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f185fd6e7d550e9917d7103dccf51be589aba953e15994fb04646c1730019685", size = 5263382, upload-time = "2026-04-12T16:26:10.067Z" }, + { url = "https://files.pythonhosted.org/packages/9a/93/ec18a08e98dd82cac39f1d2511ee2bed5affb94d228356d8ef165a4ec3b9/lxml-6.0.4-cp314-cp314-win32.whl", hash = "sha256:774660028f8722a598400430d2746fb0075949f84a9a5cd9767d9152e3baaac5", size = 3656164, upload-time = "2026-04-12T16:26:59.568Z" }, + { url = "https://files.pythonhosted.org/packages/15/86/52507316abfc7150bf6bb191e39a12e301ee80334610a493884ae2f9d20d/lxml-6.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:fbd7d14349413f5609c0b537b1a48117d6ccef1af37986af6b03766ad05bf43e", size = 4062512, upload-time = "2026-04-12T16:27:02.212Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d5/09c593a2ef2234b8cd6cf059e2dc212e0654bf05c503f0ef2daf05adb680/lxml-6.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:a61a01ec3fbfd5b73a69a7bf513271051fd6c5795d82fc5daa0255934cd8db3d", size = 3740745, upload-time = "2026-04-12T16:27:04.444Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3c/42a98bf6693938bf7b285ec7f70ba2ae9d785d0e5b2cdb85d2ee29e287eb/lxml-6.0.4-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:504edb62df33cea502ea6e73847c647ba228623ca3f80a228be5723a70984dd5", size = 8826437, upload-time = "2026-04-12T16:26:12.911Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c2/ad13f39b2db8709788aa2dcb6e90b81da76db3b5b2e7d35e0946cf984960/lxml-6.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f01b7b0316d4c0926d49a7f003b2d30539f392b140a3374bb788bad180bc8478", size = 4734892, upload-time = "2026-04-12T16:26:15.871Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6d/c559d7b5922c5b0380fc2cb5ac134b6a3f9d79d368347a624ee5d68b0816/lxml-6.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab999933e662501efe4b16e6cfb7c9f9deca7d072cd1788b99c8defde78c0dfb", size = 4969173, upload-time = "2026-04-12T16:26:18.335Z" }, + { url = "https://files.pythonhosted.org/packages/c7/78/ca521e36157f38e3e1a29276855cdf48d213138fc0c8365693ff5c876ca7/lxml-6.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67c3f084389fe75932c39b6869a377f6c8e21e818f31ae8a30c71dd2e59360e2", size = 5103134, upload-time = "2026-04-12T16:26:20.612Z" }, + { url = "https://files.pythonhosted.org/packages/28/a7/7d62d023bacaa0aaf60af8c0a77c6c05f84327396d755f3aa64b788678a9/lxml-6.0.4-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:377ea1d654f76ed6205c87d14920f829c9f4d31df83374d3cbcbdaae804d37b2", size = 5027205, upload-time = "2026-04-12T16:26:22.981Z" }, + { url = "https://files.pythonhosted.org/packages/34/be/51b194b81684f2e85e5d992771c45d70cb22ac6f7291ac6bc7b255830afe/lxml-6.0.4-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e60cd0bcacbfd1a96d63516b622183fb2e3f202300df9eb5533391a8a939dbfa", size = 5594461, upload-time = "2026-04-12T16:26:25.316Z" }, + { url = "https://files.pythonhosted.org/packages/39/24/8850f38fbf89dd072ff31ba22f9e40347aeada7cadf710ecb04b8d9f32d4/lxml-6.0.4-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e9e30fd63d41dd0bbdb020af5cdfffd5d9b554d907cb210f18e8fcdc8eac013", size = 5223378, upload-time = "2026-04-12T16:26:28.68Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9b/595239ba8c719b0fdc7bc9ebdb7564459c9a6b24b8b363df4a02674aeece/lxml-6.0.4-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:1fb4a1606bb68c533002e7ed50d7e55e58f0ef1696330670281cb79d5ab2050d", size = 5311415, upload-time = "2026-04-12T16:26:31.513Z" }, + { url = "https://files.pythonhosted.org/packages/be/cb/aa27ac8d041acf34691577838494ad08df78e83fdfdb66948d2903e9291e/lxml-6.0.4-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:695c7708438e449d57f404db8cc1b769e77ad5b50655f32f8175686ba752f293", size = 4637953, upload-time = "2026-04-12T16:26:33.806Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f2/f19114fd86825c2d1ce41cd99daad218d30cfdd2093d4de9273986fb4d68/lxml-6.0.4-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d49c35ae1e35ee9b569892cf8f8f88db9524f28d66e9daee547a5ef9f3c5f468", size = 5231532, upload-time = "2026-04-12T16:26:36.518Z" }, + { url = "https://files.pythonhosted.org/packages/9a/0e/c3fa354039ec0b6b09f40fbe1129efc572ac6239faa4906de42d5ce87c0a/lxml-6.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5801072f8967625e6249d162065d0d6011ef8ce3d0efb8754496b5246b81a74b", size = 5083767, upload-time = "2026-04-12T16:26:39.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4b/1a0dbb6d6ffae16e54a8a3796ded0ad2f9c3bc1ff3728bde33456f4e1d63/lxml-6.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cbf768541526eba5ef1a49f991122e41b39781eafd0445a5a110fc09947a20b5", size = 4758079, upload-time = "2026-04-12T16:26:42.138Z" }, + { url = "https://files.pythonhosted.org/packages/a9/01/a246cf5f80f96766051de4b305d6552f80bdaefb37f04e019e42af0aba69/lxml-6.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eecce87cc09233786fc31c230268183bf6375126cfec1c8b3673fcdc8767b560", size = 5618686, upload-time = "2026-04-12T16:26:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1f/b072a92369039ebef11b0a654be5134fcf3ed04c0f437faf9435ac9ba845/lxml-6.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:07dce892881179e11053066faca2da17b0eeb0bb7298f11bcf842a86db207dbd", size = 5227259, upload-time = "2026-04-12T16:26:47.083Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/dc97034f9d4c0c4d30875147d81fd2c0c7f3d261b109db36ed746bf8ab1d/lxml-6.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e4f97aee337b947e6699e5574c90d087d3e2ce517016241c07e7e98a28dca885", size = 5246190, upload-time = "2026-04-12T16:26:49.468Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ef/85cb69835113583c2516fee07d0ffb4d824b557424b06ba5872c20ba6078/lxml-6.0.4-cp314-cp314t-win32.whl", hash = "sha256:064477c0d4c695aa1ea4b9c1c4ee9043ab740d12135b74c458cc658350adcd86", size = 3896005, upload-time = "2026-04-12T16:26:52.163Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5e/2231f34cc54b8422b793593138d86d3fa4588fb2297d4ea0472390f25627/lxml-6.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:25bad2d8438f4ef5a7ad4a8d8bcaadde20c0daced8bdb56d46236b0a7d1cbdd0", size = 4391037, upload-time = "2026-04-12T16:26:54.398Z" }, + { url = "https://files.pythonhosted.org/packages/39/53/8ba3cd5984f8363635450c93f63e541a0721b362bb32ae0d8237d9674aee/lxml-6.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:1dcd9e6cb9b7df808ea33daebd1801f37a8f50e8c075013ed2a2343246727838", size = 3816184, upload-time = "2026-04-12T16:26:57.011Z" }, + { url = "https://files.pythonhosted.org/packages/41/25/260b86340ec5aadda5e18ed39df0eea61ef8781fb0fcc16c847cdb9dfdff/lxml-6.0.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b29bcca95e82cd201d16c2101085faa2669838f4697fd914b7124a6c77032f80", size = 3929209, upload-time = "2026-04-12T16:28:07.628Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cc/b2157461584525fb0ceb7f4c3b6c1b276f6c7dd34858d78075ae8973bf3d/lxml-6.0.4-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a95e29710ecdf99b446990144598f6117271cb2ec19fd45634aa087892087077", size = 4209535, upload-time = "2026-04-12T16:28:10.071Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fa/7fdcd1eb31ec0d5871a4a0b1587e78a331f59941ff3af59bed064175499e/lxml-6.0.4-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:13085e0174e9c9fa4eb5a6bdfb81646d1f7be07e5895c958e89838afb77630c6", size = 4316979, upload-time = "2026-04-12T16:28:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/53/0c/dab9f5855e7d2e51c8eb461713ada38a7d4eb3ab07fec8d13c46ed353ad6/lxml-6.0.4-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e205c4869a28ec4447375333072978356cd0eeadd0412c643543238e638b89a3", size = 4249929, upload-time = "2026-04-12T16:28:15.739Z" }, + { url = "https://files.pythonhosted.org/packages/a4/88/39e8e4ca7ee1bc9e7cd2f6b311279624afa70a375eef8727f0bb83db2936/lxml-6.0.4-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aec26080306a66ad5c62fad0053dd2170899b465137caca7eac4b72bda3588bf", size = 4399464, upload-time = "2026-04-12T16:28:18.397Z" }, + { url = "https://files.pythonhosted.org/packages/66/54/14c518cc9ce5151fcd1fa95a1c2396799a505dca2c4f0acdf85fb23fe293/lxml-6.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3912221f41d96283b10a7232344351c8511e31f18734c752ed4798c12586ea35", size = 3507404, upload-time = "2026-04-12T16:28:21.188Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, + { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, + { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, + { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, + { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, + { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, + { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, + { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, + { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, + { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "python-pptx" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, + { name = "pillow" }, + { name = "typing-extensions" }, + { name = "xlsxwriter" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/a9/0c0db8d37b2b8a645666f7fd8accea4c6224e013c42b1d5c17c93590cd06/python_pptx-1.0.2.tar.gz", hash = "sha256:479a8af0eaf0f0d76b6f00b0887732874ad2e3188230315290cd1f9dd9cc7095", size = 10109297, upload-time = "2024-08-07T17:33:37.772Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/4f/00be2196329ebbff56ce564aa94efb0fbc828d00de250b1980de1a34ab49/python_pptx-1.0.2-py3-none-any.whl", hash = "sha256:160838e0b8565a8b1f67947675886e9fea18aa5e795db7ae531606d68e785cba", size = 472788, upload-time = "2024-08-07T17:33:28.192Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" }, + { url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" }, + { url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" }, + { url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" }, + { url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" }, + { url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" }, + { url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" }, + { url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" }, + { url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" }, + { url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" }, + { url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" }, + { url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" }, + { url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" }, + { url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" }, +] + +[[package]] +name = "tts-voiceover-skill" +version = "0.0.0" +source = { virtual = "." } +dependencies = [ + { name = "azure-cognitiveservices-speech" }, + { name = "python-pptx" }, + { name = "pyyaml" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] +fuzz = [ + { name = "atheris" }, +] + +[package.metadata] +requires-dist = [ + { name = "azure-cognitiveservices-speech" }, + { name = "python-pptx" }, + { name = "pyyaml" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.0" }, + { name = "ruff", specifier = ">=0.15" }, +] +fuzz = [{ name = "atheris", specifier = ">=3.0" }] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "xlsxwriter" +version = "3.2.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/2c/c06ef49dc36e7954e55b802a8b231770d286a9758b3d936bd1e04ce5ba88/xlsxwriter-3.2.9.tar.gz", hash = "sha256:254b1c37a368c444eac6e2f867405cc9e461b0ed97a3233b2ac1e574efb4140c", size = 215940, upload-time = "2025-09-16T00:16:21.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/0c/3662f4a66880196a590b202f0db82d919dd2f89e99a27fadef91c4a33d41/xlsxwriter-3.2.9-py3-none-any.whl", hash = "sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3", size = 175315, upload-time = "2025-09-16T00:16:20.108Z" }, +] diff --git a/collections/experimental.collection.yml b/collections/experimental.collection.yml index eba69bc46..914b173fd 100644 --- a/collections/experimental.collection.yml +++ b/collections/experimental.collection.yml @@ -18,6 +18,8 @@ items: kind: skill - path: .github/skills/experimental/powerpoint kind: skill + - path: .github/skills/experimental/tts-voiceover + kind: skill - path: .github/skills/experimental/video-to-gif kind: skill - path: .github/skills/experimental/vscode-playwright diff --git a/collections/hve-core-all.collection.yml b/collections/hve-core-all.collection.yml index f60020053..3e5f6bbf8 100644 --- a/collections/hve-core-all.collection.yml +++ b/collections/hve-core-all.collection.yml @@ -529,6 +529,8 @@ items: - path: .github/instructions/rai-planning/rai-standards.instructions.md kind: instruction maturity: experimental +- path: .github/instructions/security-planning/mcp-security.instructions.md + kind: instruction - path: .github/instructions/security/backlog-handoff.instructions.md kind: instruction maturity: experimental @@ -575,6 +577,8 @@ items: kind: skill - path: .github/skills/experimental/powerpoint kind: skill +- path: .github/skills/experimental/tts-voiceover + kind: skill - path: .github/skills/experimental/video-to-gif kind: skill - path: .github/skills/experimental/vscode-playwright diff --git a/plugins/experimental/skills/experimental/tts-voiceover b/plugins/experimental/skills/experimental/tts-voiceover new file mode 120000 index 000000000..d9bde3a11 --- /dev/null +++ b/plugins/experimental/skills/experimental/tts-voiceover @@ -0,0 +1 @@ +../../../../.github/skills/experimental/tts-voiceover \ No newline at end of file diff --git a/plugins/hve-core-all/skills/experimental/tts-voiceover b/plugins/hve-core-all/skills/experimental/tts-voiceover new file mode 120000 index 000000000..d9bde3a11 --- /dev/null +++ b/plugins/hve-core-all/skills/experimental/tts-voiceover @@ -0,0 +1 @@ +../../../../.github/skills/experimental/tts-voiceover \ No newline at end of file From 24fe74bf1b176a27a08329452ac30362c7a21d2c Mon Sep 17 00:00:00 2001 From: Alain Uyidi <107195562+auyidi1@users.noreply.github.com> Date: Tue, 21 Apr 2026 16:07:06 +0000 Subject: [PATCH 02/68] fix(skills): address PR review findings for tts-voiceover skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - escape voice and rate params in wrap_ssml with quoteattr() - pin minimum dependency versions in pyproject.toml - add bash and PowerShell wrapper scripts for cross-platform support - fix broken docstring in embed_audio.py - remove unrelated mcp-security entry from hve-core-all collection 🔧 - Generated by Copilot --- .../experimental/tts-voiceover/SKILL.md | 64 ++++-- .../experimental/tts-voiceover/pyproject.toml | 8 +- .../scripts/Invoke-EmbedAudio.ps1 | 134 ++++++++++++ .../scripts/Invoke-GenerateVoiceover.ps1 | 159 ++++++++++++++ .../tts-voiceover/scripts/embed-audio.sh | 79 +++++++ .../tts-voiceover/scripts/embed_audio.py | 6 +- .../scripts/generate-voiceover.sh | 82 +++++++ .../scripts/generate_voiceover.py | 18 +- .../skills/experimental/tts-voiceover/uv.lock | 202 +++++++++++++++++- collections/hve-core-all.collection.yml | 2 - 10 files changed, 707 insertions(+), 47 deletions(-) create mode 100644 .github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 create mode 100644 .github/skills/experimental/tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 create mode 100644 .github/skills/experimental/tts-voiceover/scripts/embed-audio.sh create mode 100644 .github/skills/experimental/tts-voiceover/scripts/generate-voiceover.sh diff --git a/.github/skills/experimental/tts-voiceover/SKILL.md b/.github/skills/experimental/tts-voiceover/SKILL.md index 69cce88ff..0a4410d67 100644 --- a/.github/skills/experimental/tts-voiceover/SKILL.md +++ b/.github/skills/experimental/tts-voiceover/SKILL.md @@ -64,22 +64,42 @@ uv run scripts/embed_audio.py --input deck.pptx --audio-dir voice-over --output ### generate_voiceover.py -| Parameter | Type | Default | Description | -|:----------------|:-------|:------------------------------------|:----------------------------------------------| -| `--dry-run` | flag | `false` | Print SSML templates without generating audio | -| `--voice` | string | `en-US-Andrew:DragonHDLatestNeural` | Azure TTS voice name | -| `--rate` | string | `+10%` | Speech prosody rate | -| `--content-dir` | path | `content` | Path to slide content directory | -| `--output-dir` | path | `voice-over` | Path to WAV output directory | -| `--lexicon` | path | *(auto-detect)* | Custom acronyms.yaml path | +| Parameter | Type | Default | Description | +|:----------------|:-------|:-------------------------------------|:-----------------------------------------------| +| `--dry-run` | flag | `false` | Print SSML templates without generating audio | +| `--voice` | string | `en-US-Andrew:DragonHDLatestNeural` | Azure TTS voice name | +| `--rate` | string | `+10%` | Speech prosody rate | +| `--content-dir` | path | `content` | Path to slide content directory | +| `--output-dir` | path | `voice-over` | Path to WAV output directory | +| `--lexicon` | path | *(auto-detect)* | Custom acronyms.yaml path | ### embed_audio.py -| Parameter | Type | Default | Description | -|:--------------|:-----|:------------------|:-----------------------------| -| `--input` | path | *(required)* | Source PPTX file path | -| `--audio-dir` | path | `voice-over` | Directory with slide-NNN.wav | -| `--output` | path | `*-narrated.pptx` | Output PPTX file path | +| Parameter | Type | Default | Description | +|:---------------|:-----|:---------------------|:---------------------------------| +| `--input` | path | *(required)* | Source PPTX file path | +| `--audio-dir` | path | `voice-over` | Directory with slide-NNN.wav | +| `--output` | path | `*-narrated.pptx` | Output PPTX file path | + +## Cross-Platform Wrappers + +Bash and PowerShell wrappers handle virtual environment setup and delegate to the Python scripts. + +### Bash + +```bash +./scripts/generate-voiceover.sh --dry-run --content-dir content +./scripts/embed-audio.sh --input deck.pptx --audio-dir voice-over +``` + +### PowerShell + +```powershell +./scripts/Invoke-GenerateVoiceover.ps1 -DryRun -ContentDir content +./scripts/Invoke-EmbedAudio.ps1 -InputPath deck.pptx -AudioDir voice-over +``` + +Both wrappers accept a `--skip-venv-setup` / `-SkipVenvSetup` flag to skip `uv sync` when the environment is already initialized. ## Script Reference @@ -161,15 +181,13 @@ Each `content.yaml` should contain a `speaker_notes:` field with the narration t ## Troubleshooting -| Issue | Solution | -|:-----------------------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------| -| `Set SPEECH_KEY ... or SPEECH_RESOURCE_ID` | Export `SPEECH_KEY` (key auth) or `SPEECH_RESOURCE_ID` (Entra ID) with `SPEECH_REGION`. | -| 401 with Entra ID auth | Verify custom domain on the Speech resource and `Cognitive Services Speech User` role. RBAC propagation takes up to 5 minutes. | -| Empty WAV files or skipped slides | Verify `speaker_notes:` is present and non-empty in `content.yaml`. | -| Mispronounced acronyms | Add entries to `acronyms.yaml` with phonetic aliases. | -| `azure-cognitiveservices-speech package is required` | Run `uv sync` in the skill directory. | -| Audio icon visible in PPTX | Reposition or resize the audio object in PowerPoint after embedding. | +| Issue | Solution | +|:------|:---------| +| `Set SPEECH_KEY ... or SPEECH_RESOURCE_ID` | Export `SPEECH_KEY` (key auth) or `SPEECH_RESOURCE_ID` (Entra ID) with `SPEECH_REGION`. | +| 401 with Entra ID auth | Verify custom domain on the Speech resource and `Cognitive Services Speech User` role. RBAC propagation takes up to 5 minutes. | +| Empty WAV files or skipped slides | Verify `speaker_notes:` is present and non-empty in `content.yaml`. | +| Mispronounced acronyms | Add entries to `acronyms.yaml` with phonetic aliases. | +| `azure-cognitiveservices-speech package is required` | Run `uv sync` in the skill directory. | +| Audio icon visible in PPTX | Reposition or resize the audio object in PowerPoint after embedding. | > Brought to you by microsoft/hve-core - -*🤖 Crafted with precision by ✨Copilot following brilliant human instruction, then carefully refined by our team of discerning human reviewers.* diff --git a/.github/skills/experimental/tts-voiceover/pyproject.toml b/.github/skills/experimental/tts-voiceover/pyproject.toml index 6975d88f2..c93d6c065 100644 --- a/.github/skills/experimental/tts-voiceover/pyproject.toml +++ b/.github/skills/experimental/tts-voiceover/pyproject.toml @@ -3,10 +3,10 @@ name = "tts-voiceover-skill" version = "0.0.0" requires-python = ">=3.11" dependencies = [ - "azure-cognitiveservices-speech", - "azure-identity", - "python-pptx", - "pyyaml", + "azure-cognitiveservices-speech>=1.41", + "azure-identity>=1.19", + "python-pptx>=1.0", + "pyyaml>=6.0", ] [dependency-groups] diff --git a/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 b/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 new file mode 100644 index 000000000..91506424f --- /dev/null +++ b/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 @@ -0,0 +1,134 @@ +#!/usr/bin/env pwsh +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT +#Requires -Version 7.0 + +<# +.SYNOPSIS + Embeds per-slide WAV voice-over files into a PowerPoint deck. + +.DESCRIPTION + Manages the Python virtual environment and invokes embed_audio.py to add + WAV files as embedded media objects in the corresponding slides of a PPTX file. + +.PARAMETER InputPath + Source PPTX file path. Required. + +.PARAMETER AudioDir + Directory containing slide-NNN.wav files. Defaults to voice-over. + +.PARAMETER OutputPath + Output PPTX file path. Defaults to input stem + '-narrated.pptx'. + +.PARAMETER SkipVenvSetup + Skip virtual environment creation and dependency installation. + +.EXAMPLE + ./Invoke-EmbedAudio.ps1 -InputPath deck.pptx -AudioDir voice-over + +.EXAMPLE + ./Invoke-EmbedAudio.ps1 -InputPath deck.pptx -AudioDir voice-over -OutputPath deck-narrated.pptx +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$InputPath, + + [Parameter()] + [string]$AudioDir, + + [Parameter()] + [string]$OutputPath, + + [Parameter()] + [switch]$SkipVenvSetup +) + +$ErrorActionPreference = 'Stop' + +$ScriptDir = $PSScriptRoot +$SkillRoot = Split-Path $ScriptDir +$VenvDir = Join-Path $SkillRoot '.venv' + +#region Environment Setup + +function Test-UvAvailability { + <# + .SYNOPSIS + Verifies uv is available on PATH. + .OUTPUTS + [string] The resolved uv command path. + #> + [CmdletBinding()] + [OutputType([string])] + param() + + $resolved = Get-Command 'uv' -ErrorAction SilentlyContinue + if ($resolved) { + return $resolved.Source + } + throw 'uv is required but was not found on PATH. Install with: curl -LsSf https://astral.sh/uv/install.sh | sh' +} + +function Initialize-PythonEnvironment { + <# + .SYNOPSIS + Syncs the Python virtual environment and dependencies via uv. + #> + [CmdletBinding()] + [OutputType([void])] + param() + + Write-Host 'Syncing Python environment via uv...' + & uv sync --directory $SkillRoot + if ($LASTEXITCODE -ne 0) { + throw 'Failed to sync Python environment via uv.' + } + Write-Host 'Environment synchronized.' +} + +function Get-VenvPythonPath { + <# + .SYNOPSIS + Returns the path to the venv Python executable. + .OUTPUTS + [string] Absolute path to the venv python binary. + #> + [CmdletBinding()] + [OutputType([string])] + param() + + if ($IsWindows) { + return Join-Path $VenvDir 'Scripts/python.exe' + } + return Join-Path $VenvDir 'bin/python' +} + +#endregion + +#region Main + +$null = Test-UvAvailability + +if (-not $SkipVenvSetup) { + Initialize-PythonEnvironment +} + +$python = Get-VenvPythonPath +if (-not (Test-Path $python)) { + throw "Python not found at $python. Run without -SkipVenvSetup to initialize." +} + +$script = Join-Path $ScriptDir 'embed_audio.py' +$args_ = @('--input', $InputPath) + +if ($AudioDir) { $args_ += '--audio-dir', $AudioDir } +if ($OutputPath) { $args_ += '--output', $OutputPath } + +& $python $script @args_ +if ($LASTEXITCODE -ne 0) { + throw "embed_audio.py exited with code $LASTEXITCODE" +} + +#endregion diff --git a/.github/skills/experimental/tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 b/.github/skills/experimental/tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 new file mode 100644 index 000000000..5f580f7a1 --- /dev/null +++ b/.github/skills/experimental/tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 @@ -0,0 +1,159 @@ +#!/usr/bin/env pwsh +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT +#Requires -Version 7.0 + +<# +.SYNOPSIS + Generates per-slide TTS voice-over from YAML speaker notes via Azure Speech SDK. + +.DESCRIPTION + Manages the Python virtual environment and invokes generate_voiceover.py to + produce per-slide WAV files from YAML speaker notes with SSML acronym aliases. + +.PARAMETER DryRun + Print SSML templates without generating audio. + +.PARAMETER Voice + Azure TTS voice name. Defaults to en-US-Andrew:DragonHDLatestNeural. + +.PARAMETER Rate + Speech prosody rate. Defaults to +10%. + +.PARAMETER ContentDir + Path to slide content directory. Defaults to content. + +.PARAMETER OutputDir + Path to WAV output directory. Defaults to voice-over. + +.PARAMETER Lexicon + Path to custom acronyms.yaml lexicon file. + +.PARAMETER SkipVenvSetup + Skip virtual environment creation and dependency installation. + +.EXAMPLE + ./Invoke-GenerateVoiceover.ps1 -DryRun -ContentDir content + +.EXAMPLE + ./Invoke-GenerateVoiceover.ps1 -ContentDir content -OutputDir voice-over + +.EXAMPLE + ./Invoke-GenerateVoiceover.ps1 -ContentDir content -Voice "en-US-Jenny:DragonHDLatestNeural" -Rate "+5%" +#> + +[CmdletBinding()] +param( + [Parameter()] + [switch]$DryRun, + + [Parameter()] + [string]$Voice, + + [Parameter()] + [string]$Rate, + + [Parameter()] + [string]$ContentDir, + + [Parameter()] + [string]$OutputDir, + + [Parameter()] + [string]$Lexicon, + + [Parameter()] + [switch]$SkipVenvSetup +) + +$ErrorActionPreference = 'Stop' + +$ScriptDir = $PSScriptRoot +$SkillRoot = Split-Path $ScriptDir +$VenvDir = Join-Path $SkillRoot '.venv' + +#region Environment Setup + +function Test-UvAvailability { + <# + .SYNOPSIS + Verifies uv is available on PATH. + .OUTPUTS + [string] The resolved uv command path. + #> + [CmdletBinding()] + [OutputType([string])] + param() + + $resolved = Get-Command 'uv' -ErrorAction SilentlyContinue + if ($resolved) { + return $resolved.Source + } + throw 'uv is required but was not found on PATH. Install with: curl -LsSf https://astral.sh/uv/install.sh | sh' +} + +function Initialize-PythonEnvironment { + <# + .SYNOPSIS + Syncs the Python virtual environment and dependencies via uv. + #> + [CmdletBinding()] + [OutputType([void])] + param() + + Write-Host 'Syncing Python environment via uv...' + & uv sync --directory $SkillRoot + if ($LASTEXITCODE -ne 0) { + throw 'Failed to sync Python environment via uv.' + } + Write-Host 'Environment synchronized.' +} + +function Get-VenvPythonPath { + <# + .SYNOPSIS + Returns the path to the venv Python executable. + .OUTPUTS + [string] Absolute path to the venv python binary. + #> + [CmdletBinding()] + [OutputType([string])] + param() + + if ($IsWindows) { + return Join-Path $VenvDir 'Scripts/python.exe' + } + return Join-Path $VenvDir 'bin/python' +} + +#endregion + +#region Main + +$null = Test-UvAvailability + +if (-not $SkipVenvSetup) { + Initialize-PythonEnvironment +} + +$python = Get-VenvPythonPath +if (-not (Test-Path $python)) { + throw "Python not found at $python. Run without -SkipVenvSetup to initialize." +} + +$script = Join-Path $ScriptDir 'generate_voiceover.py' +$args_ = @() + +if ($DryRun) { $args_ += '--dry-run' } +if ($Voice) { $args_ += '--voice', $Voice } +if ($Rate) { $args_ += '--rate', $Rate } +if ($ContentDir) { $args_ += '--content-dir', $ContentDir } +if ($OutputDir) { $args_ += '--output-dir', $OutputDir } +if ($Lexicon) { $args_ += '--lexicon', $Lexicon } + +& $python $script @args_ +if ($LASTEXITCODE -ne 0) { + throw "generate_voiceover.py exited with code $LASTEXITCODE" +} + +#endregion diff --git a/.github/skills/experimental/tts-voiceover/scripts/embed-audio.sh b/.github/skills/experimental/tts-voiceover/scripts/embed-audio.sh new file mode 100644 index 000000000..0c263d044 --- /dev/null +++ b/.github/skills/experimental/tts-voiceover/scripts/embed-audio.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT +# +# embed-audio.sh +# Wrapper for embed_audio.py — embeds per-slide WAV voice-over files +# into a PowerPoint deck. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_ROOT="$(dirname "${SCRIPT_DIR}")" + +err() { + printf "ERROR: %s\n" "$1" >&2 + exit 1 +} + +usage() { + cat < Source PPTX file path (required) + --audio-dir Directory containing slide-NNN.wav files (default: voice-over) + --output Output PPTX file path (default: input stem + '-narrated.pptx') + --skip-venv-setup Skip virtual environment setup + -h, --help Show this help message +EOF + exit 0 +} + +test_uv_availability() { + if ! command -v uv &>/dev/null; then + err "uv is required but was not found on PATH. Install with: curl -LsSf https://astral.sh/uv/install.sh | sh" + fi +} + +initialize_python_environment() { + echo "Syncing Python environment via uv..." + uv sync --directory "${SKILL_ROOT}" + echo "Environment synchronized." +} + +get_venv_python_path() { + echo "${SKILL_ROOT}/.venv/bin/python" +} + +main() { + local -a passthrough_args=() + local skip_venv_setup=false + + while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) usage ;; + --skip-venv-setup) skip_venv_setup=true; shift ;; + *) passthrough_args+=("$1"); shift ;; + esac + done + + test_uv_availability + + if [[ "${skip_venv_setup}" == "false" ]]; then + initialize_python_environment + fi + + local python + python="$(get_venv_python_path)" + + if [[ ! -x "${python}" ]]; then + err "Python not found at ${python}. Run without --skip-venv-setup to initialize." + fi + + "${python}" "${SCRIPT_DIR}/embed_audio.py" "${passthrough_args[@]}" +} + +main "$@" diff --git a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py index d3e8bdf44..d63534bfa 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py +++ b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py @@ -37,7 +37,7 @@ def embed_slide_audio(slide: object, wav_path: Path) -> bool: - """Embed a WAV file into a slide as a media object. + """Embed a WAV file into a PowerPoint slide. Returns True on success, False on failure. """ @@ -121,9 +121,7 @@ def main() -> int: output_path.parent.mkdir(parents=True, exist_ok=True) prs.save(str(output_path)) - logger.info( - "Saved %s with %d embedded audio files", output_path, embedded_count - ) + logger.info("Saved %s with %d embedded audio files", output_path, embedded_count) return EXIT_SUCCESS diff --git a/.github/skills/experimental/tts-voiceover/scripts/generate-voiceover.sh b/.github/skills/experimental/tts-voiceover/scripts/generate-voiceover.sh new file mode 100644 index 000000000..b70bc1dd7 --- /dev/null +++ b/.github/skills/experimental/tts-voiceover/scripts/generate-voiceover.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT +# +# generate-voiceover.sh +# Wrapper for generate_voiceover.py — generates per-slide TTS voice-over +# from YAML speaker notes via Azure Speech SDK. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_ROOT="$(dirname "${SCRIPT_DIR}")" + +err() { + printf "ERROR: %s\n" "$1" >&2 + exit 1 +} + +usage() { + cat < Azure TTS voice name (default: en-US-Andrew:DragonHDLatestNeural) + --rate Speech prosody rate (default: +10%) + --content-dir Path to slide content directory (default: content) + --output-dir Path to WAV output directory (default: voice-over) + --lexicon Path to custom acronyms.yaml lexicon file + --skip-venv-setup Skip virtual environment setup + -h, --help Show this help message +EOF + exit 0 +} + +test_uv_availability() { + if ! command -v uv &>/dev/null; then + err "uv is required but was not found on PATH. Install with: curl -LsSf https://astral.sh/uv/install.sh | sh" + fi +} + +initialize_python_environment() { + echo "Syncing Python environment via uv..." + uv sync --directory "${SKILL_ROOT}" + echo "Environment synchronized." +} + +get_venv_python_path() { + echo "${SKILL_ROOT}/.venv/bin/python" +} + +main() { + local -a passthrough_args=() + local skip_venv_setup=false + + while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) usage ;; + --skip-venv-setup) skip_venv_setup=true; shift ;; + *) passthrough_args+=("$1"); shift ;; + esac + done + + test_uv_availability + + if [[ "${skip_venv_setup}" == "false" ]]; then + initialize_python_environment + fi + + local python + python="$(get_venv_python_path)" + + if [[ ! -x "${python}" ]]; then + err "Python not found at ${python}. Run without --skip-venv-setup to initialize." + fi + + "${python}" "${SCRIPT_DIR}/generate_voiceover.py" "${passthrough_args[@]}" +} + +main "$@" diff --git a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py index 7f22b7d2c..7c9787ff6 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py +++ b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py @@ -82,11 +82,13 @@ def apply_acronym_aliases(text: str, acronyms: dict[str, str]) -> str: def wrap_ssml(text: str, voice: str, rate: str) -> str: """Wrap processed text in a full SSML document.""" + safe_voice = xml.sax.saxutils.quoteattr(voice) + safe_rate = xml.sax.saxutils.quoteattr(rate) return ( '\n' - f' \n' - f' \n' + f" \n" + f" \n" f" {text}\n" " \n" " \n" @@ -94,9 +96,7 @@ def wrap_ssml(text: str, voice: str, rate: str) -> str: ) -def generate_audio( - ssml: str, output_path: Path, speech_config: object -) -> float | None: +def generate_audio(ssml: str, output_path: Path, speech_config: object) -> float | None: """Generate a WAV file from SSML. Returns duration in seconds or ``None``.""" import azure.cognitiveservices.speech as speechsdk @@ -124,9 +124,7 @@ def _make_entra_config( Returns (config, expires_at). """ - token_obj = credential.get_token( - "https://cognitiveservices.azure.com/.default" - ) + token_obj = credential.get_token("https://cognitiveservices.azure.com/.default") auth_token = f"aad#{resource_id}#{token_obj.token}" config = speechsdk.SpeechConfig(auth_token=auth_token, region=region) config.set_speech_synthesis_output_format( @@ -232,9 +230,7 @@ def main() -> int: try: from azure.identity import DefaultAzureCredential except ImportError: - logger.error( - "azure-identity package is required for Entra ID auth" - ) + logger.error("azure-identity package is required for Entra ID auth") return EXIT_FAILURE credential = DefaultAzureCredential() speech_config, token_expires_at = _make_entra_config( diff --git a/.github/skills/experimental/tts-voiceover/uv.lock b/.github/skills/experimental/tts-voiceover/uv.lock index 05192cc20..6e56cbd5b 100644 --- a/.github/skills/experimental/tts-voiceover/uv.lock +++ b/.github/skills/experimental/tts-voiceover/uv.lock @@ -42,6 +42,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/d6/8ebcd05b01a580f086ac9a97fb9fac65c09a4b012161cc97c21a336e880b/azure_core-1.39.0-py3-none-any.whl", hash = "sha256:4ac7b70fab5438c3f68770649a78daf97833caa83827f91df9c14e0e0ea7d34f", size = 218318, upload-time = "2026-03-19T01:31:31.25Z" }, ] +[[package]] +name = "azure-identity" +version = "1.25.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "cryptography" }, + { name = "msal" }, + { name = "msal-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/0e/3a63efb48aa4a5ae2cfca61ee152fbcb668092134d3eb8bfda472dd5c617/azure_identity-1.25.3.tar.gz", hash = "sha256:ab23c0d63015f50b630ef6c6cf395e7262f439ce06e5d07a64e874c724f8d9e6", size = 286304, upload-time = "2026-03-13T01:12:20.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/9a/417b3a533e01953a7c618884df2cb05a71e7b68bdbce4fbdb62349d2a2e8/azure_identity-1.25.3-py3-none-any.whl", hash = "sha256:f4d0b956a8146f30333e071374171f3cfa7bdb8073adb8c3814b65567aa7447c", size = 192138, upload-time = "2026-03-13T01:12:22.951Z" }, +] + [[package]] name = "certifi" version = "2026.2.25" @@ -51,6 +67,76 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.7" @@ -149,6 +235,65 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "46.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, + { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, + { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, + { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, + { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, + { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, + { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, + { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, + { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, + { url = "https://files.pythonhosted.org/packages/63/0c/dca8abb64e7ca4f6b2978769f6fea5ad06686a190cec381f0a796fdcaaba/cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", size = 3476879, upload-time = "2026-04-08T01:57:38.664Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" }, + { url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" }, + { url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -269,6 +414,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/66/54/14c518cc9ce5151fcd1fa95a1c2396799a505dca2c4f0acdf85fb23fe293/lxml-6.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3912221f41d96283b10a7232344351c8511e31f18734c752ed4798c12586ea35", size = 3507404, upload-time = "2026-04-12T16:28:21.188Z" }, ] +[[package]] +name = "msal" +version = "1.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/cb/b02b0f748ac668922364ccb3c3bff5b71628a05f5adfec2ba2a5c3031483/msal-1.36.0.tar.gz", hash = "sha256:3f6a4af2b036b476a4215111c4297b4e6e236ed186cd804faefba23e4990978b", size = 174217, upload-time = "2026-04-09T10:20:33.525Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/d3/414d1f0a5f6f4fe5313c2b002c54e78a3332970feb3f5fed14237aa17064/msal-1.36.0-py3-none-any.whl", hash = "sha256:36ecac30e2ff4322d956029aabce3c82301c29f0acb1ad89b94edcabb0e58ec4", size = 121547, upload-time = "2026-04-09T10:20:32.336Z" }, +] + +[[package]] +name = "msal-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/99/5d239b6156eddf761a636bded1118414d161bd6b7b37a9335549ed159396/msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4", size = 23315, upload-time = "2025-03-14T23:51:03.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583, upload-time = "2025-03-14T23:51:03.016Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -374,6 +545,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -383,6 +563,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyjwt" +version = "2.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pytest" version = "9.0.3" @@ -515,6 +709,7 @@ version = "0.0.0" source = { virtual = "." } dependencies = [ { name = "azure-cognitiveservices-speech" }, + { name = "azure-identity" }, { name = "python-pptx" }, { name = "pyyaml" }, ] @@ -530,9 +725,10 @@ fuzz = [ [package.metadata] requires-dist = [ - { name = "azure-cognitiveservices-speech" }, - { name = "python-pptx" }, - { name = "pyyaml" }, + { name = "azure-cognitiveservices-speech", specifier = ">=1.41" }, + { name = "azure-identity", specifier = ">=1.19" }, + { name = "python-pptx", specifier = ">=1.0" }, + { name = "pyyaml", specifier = ">=6.0" }, ] [package.metadata.requires-dev] diff --git a/collections/hve-core-all.collection.yml b/collections/hve-core-all.collection.yml index 3e5f6bbf8..c0af2f99d 100644 --- a/collections/hve-core-all.collection.yml +++ b/collections/hve-core-all.collection.yml @@ -529,8 +529,6 @@ items: - path: .github/instructions/rai-planning/rai-standards.instructions.md kind: instruction maturity: experimental -- path: .github/instructions/security-planning/mcp-security.instructions.md - kind: instruction - path: .github/instructions/security/backlog-handoff.instructions.md kind: instruction maturity: experimental From 91a52335089d15a686ae92ab6c7e1861ae48c8a4 Mon Sep 17 00:00:00 2001 From: Alain Uyidi <107195562+auyidi1@users.noreply.github.com> Date: Tue, 21 Apr 2026 19:20:25 +0000 Subject: [PATCH 03/68] fix(skills): resolve second PR review for tts-voiceover skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - escape alias double-quotes in apply_acronym_aliases with " - initialize speech_key, speech_region, speech_resource_id before dry-run block - extract shared PowerShell helpers to Modules/TtsVoiceoverHelpers.psm1 - narrow slide type hint from object to pptx.slide.Slide 🔒 - Generated by Copilot --- .../scripts/Invoke-EmbedAudio.ps1 | 60 +-------------- .../scripts/Invoke-GenerateVoiceover.ps1 | 60 +-------------- .../scripts/Modules/TtsVoiceoverHelpers.psm1 | 77 +++++++++++++++++++ .../tts-voiceover/scripts/embed_audio.py | 3 +- .../scripts/generate_voiceover.py | 24 ++++-- 5 files changed, 101 insertions(+), 123 deletions(-) create mode 100644 .github/skills/experimental/tts-voiceover/scripts/Modules/TtsVoiceoverHelpers.psm1 diff --git a/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 b/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 index 91506424f..7d9061cad 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 +++ b/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 @@ -51,71 +51,17 @@ $ScriptDir = $PSScriptRoot $SkillRoot = Split-Path $ScriptDir $VenvDir = Join-Path $SkillRoot '.venv' -#region Environment Setup - -function Test-UvAvailability { - <# - .SYNOPSIS - Verifies uv is available on PATH. - .OUTPUTS - [string] The resolved uv command path. - #> - [CmdletBinding()] - [OutputType([string])] - param() - - $resolved = Get-Command 'uv' -ErrorAction SilentlyContinue - if ($resolved) { - return $resolved.Source - } - throw 'uv is required but was not found on PATH. Install with: curl -LsSf https://astral.sh/uv/install.sh | sh' -} - -function Initialize-PythonEnvironment { - <# - .SYNOPSIS - Syncs the Python virtual environment and dependencies via uv. - #> - [CmdletBinding()] - [OutputType([void])] - param() - - Write-Host 'Syncing Python environment via uv...' - & uv sync --directory $SkillRoot - if ($LASTEXITCODE -ne 0) { - throw 'Failed to sync Python environment via uv.' - } - Write-Host 'Environment synchronized.' -} - -function Get-VenvPythonPath { - <# - .SYNOPSIS - Returns the path to the venv Python executable. - .OUTPUTS - [string] Absolute path to the venv python binary. - #> - [CmdletBinding()] - [OutputType([string])] - param() - - if ($IsWindows) { - return Join-Path $VenvDir 'Scripts/python.exe' - } - return Join-Path $VenvDir 'bin/python' -} - -#endregion +Import-Module (Join-Path $ScriptDir 'Modules/TtsVoiceoverHelpers.psm1') -Force #region Main $null = Test-UvAvailability if (-not $SkipVenvSetup) { - Initialize-PythonEnvironment + Initialize-PythonEnvironment -SkillRoot $SkillRoot } -$python = Get-VenvPythonPath +$python = Get-VenvPythonPath -VenvDir $VenvDir if (-not (Test-Path $python)) { throw "Python not found at $python. Run without -SkipVenvSetup to initialize." } diff --git a/.github/skills/experimental/tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 b/.github/skills/experimental/tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 index 5f580f7a1..6457a9882 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 +++ b/.github/skills/experimental/tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 @@ -72,71 +72,17 @@ $ScriptDir = $PSScriptRoot $SkillRoot = Split-Path $ScriptDir $VenvDir = Join-Path $SkillRoot '.venv' -#region Environment Setup - -function Test-UvAvailability { - <# - .SYNOPSIS - Verifies uv is available on PATH. - .OUTPUTS - [string] The resolved uv command path. - #> - [CmdletBinding()] - [OutputType([string])] - param() - - $resolved = Get-Command 'uv' -ErrorAction SilentlyContinue - if ($resolved) { - return $resolved.Source - } - throw 'uv is required but was not found on PATH. Install with: curl -LsSf https://astral.sh/uv/install.sh | sh' -} - -function Initialize-PythonEnvironment { - <# - .SYNOPSIS - Syncs the Python virtual environment and dependencies via uv. - #> - [CmdletBinding()] - [OutputType([void])] - param() - - Write-Host 'Syncing Python environment via uv...' - & uv sync --directory $SkillRoot - if ($LASTEXITCODE -ne 0) { - throw 'Failed to sync Python environment via uv.' - } - Write-Host 'Environment synchronized.' -} - -function Get-VenvPythonPath { - <# - .SYNOPSIS - Returns the path to the venv Python executable. - .OUTPUTS - [string] Absolute path to the venv python binary. - #> - [CmdletBinding()] - [OutputType([string])] - param() - - if ($IsWindows) { - return Join-Path $VenvDir 'Scripts/python.exe' - } - return Join-Path $VenvDir 'bin/python' -} - -#endregion +Import-Module (Join-Path $ScriptDir 'Modules/TtsVoiceoverHelpers.psm1') -Force #region Main $null = Test-UvAvailability if (-not $SkipVenvSetup) { - Initialize-PythonEnvironment + Initialize-PythonEnvironment -SkillRoot $SkillRoot } -$python = Get-VenvPythonPath +$python = Get-VenvPythonPath -VenvDir $VenvDir if (-not (Test-Path $python)) { throw "Python not found at $python. Run without -SkipVenvSetup to initialize." } diff --git a/.github/skills/experimental/tts-voiceover/scripts/Modules/TtsVoiceoverHelpers.psm1 b/.github/skills/experimental/tts-voiceover/scripts/Modules/TtsVoiceoverHelpers.psm1 new file mode 100644 index 000000000..d0cffe819 --- /dev/null +++ b/.github/skills/experimental/tts-voiceover/scripts/Modules/TtsVoiceoverHelpers.psm1 @@ -0,0 +1,77 @@ +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT +#Requires -Version 7.0 + +<# +.SYNOPSIS + Shared helper functions for tts-voiceover skill PowerShell wrappers. + +.DESCRIPTION + Provides uv availability checking, Python virtual environment setup, + and venv Python path resolution used by both Invoke-GenerateVoiceover.ps1 + and Invoke-EmbedAudio.ps1. +#> + +function Test-UvAvailability { + <# + .SYNOPSIS + Verifies uv is available on PATH. + .OUTPUTS + [string] The resolved uv command path. + #> + [CmdletBinding()] + [OutputType([string])] + param() + + $resolved = Get-Command 'uv' -ErrorAction SilentlyContinue + if ($resolved) { + return $resolved.Source + } + throw 'uv is required but was not found on PATH. Install with: curl -LsSf https://astral.sh/uv/install.sh | sh' +} + +function Initialize-PythonEnvironment { + <# + .SYNOPSIS + Syncs the Python virtual environment and dependencies via uv. + .PARAMETER SkillRoot + Root directory of the skill containing pyproject.toml. + #> + [CmdletBinding()] + [OutputType([void])] + param( + [Parameter(Mandatory = $true)] + [string]$SkillRoot + ) + + Write-Host 'Syncing Python environment via uv...' + & uv sync --directory $SkillRoot + if ($LASTEXITCODE -ne 0) { + throw 'Failed to sync Python environment via uv.' + } + Write-Host 'Environment synchronized.' +} + +function Get-VenvPythonPath { + <# + .SYNOPSIS + Returns the path to the venv Python executable. + .PARAMETER VenvDir + Path to the .venv directory. + .OUTPUTS + [string] Absolute path to the venv python binary. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [string]$VenvDir + ) + + if ($IsWindows) { + return Join-Path $VenvDir 'Scripts/python.exe' + } + return Join-Path $VenvDir 'bin/python' +} + +Export-ModuleMember -Function Test-UvAvailability, Initialize-PythonEnvironment, Get-VenvPythonPath diff --git a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py index d63534bfa..38f38772d 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py +++ b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py @@ -24,6 +24,7 @@ from pathlib import Path from pptx import Presentation +from pptx.slide import Slide from pptx.util import Inches logger = logging.getLogger(__name__) @@ -36,7 +37,7 @@ ICON_SIZE = Inches(0.1) -def embed_slide_audio(slide: object, wav_path: Path) -> bool: +def embed_slide_audio(slide: Slide, wav_path: Path) -> bool: """Embed a WAV file into a PowerPoint slide. Returns True on success, False on failure. diff --git a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py index 7c9787ff6..b55b80307 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py +++ b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py @@ -72,8 +72,9 @@ def apply_acronym_aliases(text: str, acronyms: dict[str, str]) -> str: """ for acronym, alias in sorted(acronyms.items(), key=lambda x: -len(x[0])): if acronym in text: + alias_escaped = xml.sax.saxutils.escape(alias, {'"': """}) replacement = ( - f'' + f'' f"{xml.sax.saxutils.escape(acronym)}" ) text = text.replace(acronym, replacement) @@ -82,13 +83,11 @@ def apply_acronym_aliases(text: str, acronyms: dict[str, str]) -> str: def wrap_ssml(text: str, voice: str, rate: str) -> str: """Wrap processed text in a full SSML document.""" - safe_voice = xml.sax.saxutils.quoteattr(voice) - safe_rate = xml.sax.saxutils.quoteattr(rate) return ( '\n' - f" \n" - f" \n" + f' \n' + f' \n' f" {text}\n" " \n" " \n" @@ -96,7 +95,9 @@ def wrap_ssml(text: str, voice: str, rate: str) -> str: ) -def generate_audio(ssml: str, output_path: Path, speech_config: object) -> float | None: +def generate_audio( + ssml: str, output_path: Path, speech_config: object +) -> float | None: """Generate a WAV file from SSML. Returns duration in seconds or ``None``.""" import azure.cognitiveservices.speech as speechsdk @@ -124,7 +125,9 @@ def _make_entra_config( Returns (config, expires_at). """ - token_obj = credential.get_token("https://cognitiveservices.azure.com/.default") + token_obj = credential.get_token( + "https://cognitiveservices.azure.com/.default" + ) auth_token = f"aad#{resource_id}#{token_obj.token}" config = speechsdk.SpeechConfig(auth_token=auth_token, region=region) config.set_speech_synthesis_output_format( @@ -205,6 +208,9 @@ def main() -> int: speech_config = None credential = None token_expires_at = 0 + speech_key: str | None = None + speech_region: str = "eastus" + speech_resource_id: str | None = None if not args.dry_run: try: import azure.cognitiveservices.speech as speechsdk @@ -230,7 +236,9 @@ def main() -> int: try: from azure.identity import DefaultAzureCredential except ImportError: - logger.error("azure-identity package is required for Entra ID auth") + logger.error( + "azure-identity package is required for Entra ID auth" + ) return EXIT_FAILURE credential = DefaultAzureCredential() speech_config, token_expires_at = _make_entra_config( From 456eaf32be41d2f6b36cbe21c880a415fbb6cd6c Mon Sep 17 00:00:00 2001 From: Alain Uyidi <107195562+auyidi1@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:02:06 +0000 Subject: [PATCH 04/68] style(skills): format long lines in tts-voiceover Python scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🎨 - Generated by Copilot --- .../experimental/tts-voiceover/scripts/embed_audio.py | 6 ++++-- .../tts-voiceover/scripts/generate_voiceover.py | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py index 38f38772d..ea7640280 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py +++ b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py @@ -85,7 +85,8 @@ def create_parser() -> argparse.ArgumentParser: def main() -> int: """Entry point for audio embedding.""" - logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + logging.basicConfig(level=logging.INFO, + format="%(levelname)s: %(message)s") parser = create_parser() args = parser.parse_args() @@ -122,7 +123,8 @@ def main() -> int: output_path.parent.mkdir(parents=True, exist_ok=True) prs.save(str(output_path)) - logger.info("Saved %s with %d embedded audio files", output_path, embedded_count) + logger.info("Saved %s with %d embedded audio files", + output_path, embedded_count) return EXIT_SUCCESS diff --git a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py index b55b80307..0f96b4b98 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py +++ b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py @@ -189,7 +189,8 @@ def create_parser() -> argparse.ArgumentParser: def main() -> int: """Entry point for TTS voice-over generation.""" - logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + logging.basicConfig(level=logging.INFO, + format="%(levelname)s: %(message)s") parser = create_parser() args = parser.parse_args() From 84f00eb25f5ea53c1c48fa253a6c4ca0adcb485e Mon Sep 17 00:00:00 2001 From: Alain Uyidi <107195562+auyidi1@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:18:15 +0000 Subject: [PATCH 05/68] fix(skills): revert external formatter and apply ruff format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reformat Python scripts with ruff to fix CI lint check - apply table formatting to SKILL.md from plugin postprocessing 🎨 - Generated by Copilot --- .../experimental/tts-voiceover/SKILL.md | 42 +++++++++---------- .../tts-voiceover/scripts/embed_audio.py | 6 +-- .../scripts/generate_voiceover.py | 18 +++----- 3 files changed, 28 insertions(+), 38 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/SKILL.md b/.github/skills/experimental/tts-voiceover/SKILL.md index 0a4410d67..6e0e0c3b6 100644 --- a/.github/skills/experimental/tts-voiceover/SKILL.md +++ b/.github/skills/experimental/tts-voiceover/SKILL.md @@ -64,22 +64,22 @@ uv run scripts/embed_audio.py --input deck.pptx --audio-dir voice-over --output ### generate_voiceover.py -| Parameter | Type | Default | Description | -|:----------------|:-------|:-------------------------------------|:-----------------------------------------------| -| `--dry-run` | flag | `false` | Print SSML templates without generating audio | -| `--voice` | string | `en-US-Andrew:DragonHDLatestNeural` | Azure TTS voice name | -| `--rate` | string | `+10%` | Speech prosody rate | -| `--content-dir` | path | `content` | Path to slide content directory | -| `--output-dir` | path | `voice-over` | Path to WAV output directory | -| `--lexicon` | path | *(auto-detect)* | Custom acronyms.yaml path | +| Parameter | Type | Default | Description | +|:----------------|:-------|:------------------------------------|:----------------------------------------------| +| `--dry-run` | flag | `false` | Print SSML templates without generating audio | +| `--voice` | string | `en-US-Andrew:DragonHDLatestNeural` | Azure TTS voice name | +| `--rate` | string | `+10%` | Speech prosody rate | +| `--content-dir` | path | `content` | Path to slide content directory | +| `--output-dir` | path | `voice-over` | Path to WAV output directory | +| `--lexicon` | path | *(auto-detect)* | Custom acronyms.yaml path | ### embed_audio.py -| Parameter | Type | Default | Description | -|:---------------|:-----|:---------------------|:---------------------------------| -| `--input` | path | *(required)* | Source PPTX file path | -| `--audio-dir` | path | `voice-over` | Directory with slide-NNN.wav | -| `--output` | path | `*-narrated.pptx` | Output PPTX file path | +| Parameter | Type | Default | Description | +|:--------------|:-----|:------------------|:-----------------------------| +| `--input` | path | *(required)* | Source PPTX file path | +| `--audio-dir` | path | `voice-over` | Directory with slide-NNN.wav | +| `--output` | path | `*-narrated.pptx` | Output PPTX file path | ## Cross-Platform Wrappers @@ -181,13 +181,13 @@ Each `content.yaml` should contain a `speaker_notes:` field with the narration t ## Troubleshooting -| Issue | Solution | -|:------|:---------| -| `Set SPEECH_KEY ... or SPEECH_RESOURCE_ID` | Export `SPEECH_KEY` (key auth) or `SPEECH_RESOURCE_ID` (Entra ID) with `SPEECH_REGION`. | -| 401 with Entra ID auth | Verify custom domain on the Speech resource and `Cognitive Services Speech User` role. RBAC propagation takes up to 5 minutes. | -| Empty WAV files or skipped slides | Verify `speaker_notes:` is present and non-empty in `content.yaml`. | -| Mispronounced acronyms | Add entries to `acronyms.yaml` with phonetic aliases. | -| `azure-cognitiveservices-speech package is required` | Run `uv sync` in the skill directory. | -| Audio icon visible in PPTX | Reposition or resize the audio object in PowerPoint after embedding. | +| Issue | Solution | +|:-----------------------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------| +| `Set SPEECH_KEY ... or SPEECH_RESOURCE_ID` | Export `SPEECH_KEY` (key auth) or `SPEECH_RESOURCE_ID` (Entra ID) with `SPEECH_REGION`. | +| 401 with Entra ID auth | Verify custom domain on the Speech resource and `Cognitive Services Speech User` role. RBAC propagation takes up to 5 minutes. | +| Empty WAV files or skipped slides | Verify `speaker_notes:` is present and non-empty in `content.yaml`. | +| Mispronounced acronyms | Add entries to `acronyms.yaml` with phonetic aliases. | +| `azure-cognitiveservices-speech package is required` | Run `uv sync` in the skill directory. | +| Audio icon visible in PPTX | Reposition or resize the audio object in PowerPoint after embedding. | > Brought to you by microsoft/hve-core diff --git a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py index ea7640280..38f38772d 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py +++ b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py @@ -85,8 +85,7 @@ def create_parser() -> argparse.ArgumentParser: def main() -> int: """Entry point for audio embedding.""" - logging.basicConfig(level=logging.INFO, - format="%(levelname)s: %(message)s") + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") parser = create_parser() args = parser.parse_args() @@ -123,8 +122,7 @@ def main() -> int: output_path.parent.mkdir(parents=True, exist_ok=True) prs.save(str(output_path)) - logger.info("Saved %s with %d embedded audio files", - output_path, embedded_count) + logger.info("Saved %s with %d embedded audio files", output_path, embedded_count) return EXIT_SUCCESS diff --git a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py index 0f96b4b98..88d5453a0 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py +++ b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py @@ -74,8 +74,7 @@ def apply_acronym_aliases(text: str, acronyms: dict[str, str]) -> str: if acronym in text: alias_escaped = xml.sax.saxutils.escape(alias, {'"': """}) replacement = ( - f'' - f"{xml.sax.saxutils.escape(acronym)}" + f'{xml.sax.saxutils.escape(acronym)}' ) text = text.replace(acronym, replacement) return text @@ -95,9 +94,7 @@ def wrap_ssml(text: str, voice: str, rate: str) -> str: ) -def generate_audio( - ssml: str, output_path: Path, speech_config: object -) -> float | None: +def generate_audio(ssml: str, output_path: Path, speech_config: object) -> float | None: """Generate a WAV file from SSML. Returns duration in seconds or ``None``.""" import azure.cognitiveservices.speech as speechsdk @@ -125,9 +122,7 @@ def _make_entra_config( Returns (config, expires_at). """ - token_obj = credential.get_token( - "https://cognitiveservices.azure.com/.default" - ) + token_obj = credential.get_token("https://cognitiveservices.azure.com/.default") auth_token = f"aad#{resource_id}#{token_obj.token}" config = speechsdk.SpeechConfig(auth_token=auth_token, region=region) config.set_speech_synthesis_output_format( @@ -189,8 +184,7 @@ def create_parser() -> argparse.ArgumentParser: def main() -> int: """Entry point for TTS voice-over generation.""" - logging.basicConfig(level=logging.INFO, - format="%(levelname)s: %(message)s") + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") parser = create_parser() args = parser.parse_args() @@ -237,9 +231,7 @@ def main() -> int: try: from azure.identity import DefaultAzureCredential except ImportError: - logger.error( - "azure-identity package is required for Entra ID auth" - ) + logger.error("azure-identity package is required for Entra ID auth") return EXIT_FAILURE credential = DefaultAzureCredential() speech_config, token_expires_at = _make_entra_config( From 6df83d14b0415eea9f70a94b549eb62d4632aa3d Mon Sep 17 00:00:00 2001 From: Alain Uyidi <107195562+auyidi1@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:40:37 +0000 Subject: [PATCH 06/68] fix(skills): add pytest-cov, fuzz corpus, and fix plugin freshness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add pytest-cov to dev dependencies for CI coverage support - create tests/corpus/ seed files for Atheris fuzz testing - remove stale mcp-security reference from hve-core-all plugin README 🔧 - Generated by Copilot --- .../experimental/tts-voiceover/pyproject.toml | 9 +- .../tts-voiceover/tests/corpus/0_acronym_text | 1 + .../tts-voiceover/tests/corpus/0_empty | 0 .../tests/corpus/0_ssml_fragment | 1 + .../tts-voiceover/tests/corpus/0_valid_yaml | 2 + .../skills/experimental/tts-voiceover/uv.lock | 182 +++++++++++++++++- 6 files changed, 187 insertions(+), 8 deletions(-) create mode 100644 .github/skills/experimental/tts-voiceover/tests/corpus/0_acronym_text create mode 100644 .github/skills/experimental/tts-voiceover/tests/corpus/0_empty create mode 100644 .github/skills/experimental/tts-voiceover/tests/corpus/0_ssml_fragment create mode 100644 .github/skills/experimental/tts-voiceover/tests/corpus/0_valid_yaml diff --git a/.github/skills/experimental/tts-voiceover/pyproject.toml b/.github/skills/experimental/tts-voiceover/pyproject.toml index c93d6c065..f275c2a59 100644 --- a/.github/skills/experimental/tts-voiceover/pyproject.toml +++ b/.github/skills/experimental/tts-voiceover/pyproject.toml @@ -3,15 +3,16 @@ name = "tts-voiceover-skill" version = "0.0.0" requires-python = ">=3.11" dependencies = [ - "azure-cognitiveservices-speech>=1.41", - "azure-identity>=1.19", - "python-pptx>=1.0", - "pyyaml>=6.0", + "azure-cognitiveservices-speech", + "azure-identity", + "python-pptx", + "pyyaml", ] [dependency-groups] dev = [ "pytest>=9.0", + "pytest-cov>=7.0", "ruff>=0.15", ] fuzz = [ diff --git a/.github/skills/experimental/tts-voiceover/tests/corpus/0_acronym_text b/.github/skills/experimental/tts-voiceover/tests/corpus/0_acronym_text new file mode 100644 index 000000000..7a1e47b3b --- /dev/null +++ b/.github/skills/experimental/tts-voiceover/tests/corpus/0_acronym_text @@ -0,0 +1 @@ +HVE-Core uses OWASP \ No newline at end of file diff --git a/.github/skills/experimental/tts-voiceover/tests/corpus/0_empty b/.github/skills/experimental/tts-voiceover/tests/corpus/0_empty new file mode 100644 index 000000000..e69de29bb diff --git a/.github/skills/experimental/tts-voiceover/tests/corpus/0_ssml_fragment b/.github/skills/experimental/tts-voiceover/tests/corpus/0_ssml_fragment new file mode 100644 index 000000000..cef598b78 --- /dev/null +++ b/.github/skills/experimental/tts-voiceover/tests/corpus/0_ssml_fragment @@ -0,0 +1 @@ +OWASP \ No newline at end of file diff --git a/.github/skills/experimental/tts-voiceover/tests/corpus/0_valid_yaml b/.github/skills/experimental/tts-voiceover/tests/corpus/0_valid_yaml new file mode 100644 index 000000000..392a2a1af --- /dev/null +++ b/.github/skills/experimental/tts-voiceover/tests/corpus/0_valid_yaml @@ -0,0 +1,2 @@ +acronyms: + HVE: "H V E" diff --git a/.github/skills/experimental/tts-voiceover/uv.lock b/.github/skills/experimental/tts-voiceover/uv.lock index 6e56cbd5b..e4eb4d9d0 100644 --- a/.github/skills/experimental/tts-voiceover/uv.lock +++ b/.github/skills/experimental/tts-voiceover/uv.lock @@ -235,6 +235,110 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" }, + { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" }, + { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" }, + { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" }, + { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" }, + { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" }, + { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" }, + { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" }, + { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" }, + { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + [[package]] name = "cryptography" version = "46.0.7" @@ -593,6 +697,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + [[package]] name = "python-pptx" version = "1.0.2" @@ -703,6 +821,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" }, ] +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + [[package]] name = "tts-voiceover-skill" version = "0.0.0" @@ -717,6 +889,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "pytest" }, + { name = "pytest-cov" }, { name = "ruff" }, ] fuzz = [ @@ -725,15 +898,16 @@ fuzz = [ [package.metadata] requires-dist = [ - { name = "azure-cognitiveservices-speech", specifier = ">=1.41" }, - { name = "azure-identity", specifier = ">=1.19" }, - { name = "python-pptx", specifier = ">=1.0" }, - { name = "pyyaml", specifier = ">=6.0" }, + { name = "azure-cognitiveservices-speech" }, + { name = "azure-identity" }, + { name = "python-pptx" }, + { name = "pyyaml" }, ] [package.metadata.requires-dev] dev = [ { name = "pytest", specifier = ">=9.0" }, + { name = "pytest-cov", specifier = ">=7.0" }, { name = "ruff", specifier = ">=0.15" }, ] fuzz = [{ name = "atheris", specifier = ">=3.0" }] From 711f37e8ade1d90957fd1a544c8fbbc7978895b7 Mon Sep 17 00:00:00 2001 From: Alain Uyidi <107195562+auyidi1@users.noreply.github.com> Date: Tue, 21 Apr 2026 21:00:41 +0000 Subject: [PATCH 07/68] fix(skills): add Copilot footer to tts-voiceover SKILL.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🔧 - Generated by Copilot --- .../experimental/tts-voiceover/SKILL.md | 44 ++++++++++--------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/SKILL.md b/.github/skills/experimental/tts-voiceover/SKILL.md index 6e0e0c3b6..4cacf1671 100644 --- a/.github/skills/experimental/tts-voiceover/SKILL.md +++ b/.github/skills/experimental/tts-voiceover/SKILL.md @@ -64,22 +64,22 @@ uv run scripts/embed_audio.py --input deck.pptx --audio-dir voice-over --output ### generate_voiceover.py -| Parameter | Type | Default | Description | -|:----------------|:-------|:------------------------------------|:----------------------------------------------| -| `--dry-run` | flag | `false` | Print SSML templates without generating audio | -| `--voice` | string | `en-US-Andrew:DragonHDLatestNeural` | Azure TTS voice name | -| `--rate` | string | `+10%` | Speech prosody rate | -| `--content-dir` | path | `content` | Path to slide content directory | -| `--output-dir` | path | `voice-over` | Path to WAV output directory | -| `--lexicon` | path | *(auto-detect)* | Custom acronyms.yaml path | +| Parameter | Type | Default | Description | +|:----------------|:-------|:-------------------------------------|:-----------------------------------------------| +| `--dry-run` | flag | `false` | Print SSML templates without generating audio | +| `--voice` | string | `en-US-Andrew:DragonHDLatestNeural` | Azure TTS voice name | +| `--rate` | string | `+10%` | Speech prosody rate | +| `--content-dir` | path | `content` | Path to slide content directory | +| `--output-dir` | path | `voice-over` | Path to WAV output directory | +| `--lexicon` | path | *(auto-detect)* | Custom acronyms.yaml path | ### embed_audio.py -| Parameter | Type | Default | Description | -|:--------------|:-----|:------------------|:-----------------------------| -| `--input` | path | *(required)* | Source PPTX file path | -| `--audio-dir` | path | `voice-over` | Directory with slide-NNN.wav | -| `--output` | path | `*-narrated.pptx` | Output PPTX file path | +| Parameter | Type | Default | Description | +|:---------------|:-----|:---------------------|:---------------------------------| +| `--input` | path | *(required)* | Source PPTX file path | +| `--audio-dir` | path | `voice-over` | Directory with slide-NNN.wav | +| `--output` | path | `*-narrated.pptx` | Output PPTX file path | ## Cross-Platform Wrappers @@ -181,13 +181,15 @@ Each `content.yaml` should contain a `speaker_notes:` field with the narration t ## Troubleshooting -| Issue | Solution | -|:-----------------------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------| -| `Set SPEECH_KEY ... or SPEECH_RESOURCE_ID` | Export `SPEECH_KEY` (key auth) or `SPEECH_RESOURCE_ID` (Entra ID) with `SPEECH_REGION`. | -| 401 with Entra ID auth | Verify custom domain on the Speech resource and `Cognitive Services Speech User` role. RBAC propagation takes up to 5 minutes. | -| Empty WAV files or skipped slides | Verify `speaker_notes:` is present and non-empty in `content.yaml`. | -| Mispronounced acronyms | Add entries to `acronyms.yaml` with phonetic aliases. | -| `azure-cognitiveservices-speech package is required` | Run `uv sync` in the skill directory. | -| Audio icon visible in PPTX | Reposition or resize the audio object in PowerPoint after embedding. | +| Issue | Solution | +|:------|:---------| +| `Set SPEECH_KEY ... or SPEECH_RESOURCE_ID` | Export `SPEECH_KEY` (key auth) or `SPEECH_RESOURCE_ID` (Entra ID) with `SPEECH_REGION`. | +| 401 with Entra ID auth | Verify custom domain on the Speech resource and `Cognitive Services Speech User` role. RBAC propagation takes up to 5 minutes. | +| Empty WAV files or skipped slides | Verify `speaker_notes:` is present and non-empty in `content.yaml`. | +| Mispronounced acronyms | Add entries to `acronyms.yaml` with phonetic aliases. | +| `azure-cognitiveservices-speech package is required` | Run `uv sync` in the skill directory. | +| Audio icon visible in PPTX | Reposition or resize the audio object in PowerPoint after embedding. | > Brought to you by microsoft/hve-core + +*🤖 Crafted with precision by ✨Copilot following brilliant human instruction, then carefully refined by our team of discerning human reviewers.* From 22dcc91bece6375c3fd250891b546580444b928a Mon Sep 17 00:00:00 2001 From: Alain Uyidi <107195562+auyidi1@users.noreply.github.com> Date: Tue, 21 Apr 2026 21:12:01 +0000 Subject: [PATCH 08/68] fix(skills): pin dependency versions and fix lxml CVE-2026-41066 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - restore minimum version bounds lost during rebase - add lxml>=6.1.0 to fix CVE-2026-41066 vulnerability 🔒 - Generated by Copilot --- .../experimental/tts-voiceover/pyproject.toml | 9 +- .../skills/experimental/tts-voiceover/uv.lock | 202 +++++++++--------- 2 files changed, 107 insertions(+), 104 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/pyproject.toml b/.github/skills/experimental/tts-voiceover/pyproject.toml index f275c2a59..9da248942 100644 --- a/.github/skills/experimental/tts-voiceover/pyproject.toml +++ b/.github/skills/experimental/tts-voiceover/pyproject.toml @@ -3,10 +3,11 @@ name = "tts-voiceover-skill" version = "0.0.0" requires-python = ">=3.11" dependencies = [ - "azure-cognitiveservices-speech", - "azure-identity", - "python-pptx", - "pyyaml", + "azure-cognitiveservices-speech>=1.41", + "azure-identity>=1.19", + "lxml>=6.1.0", + "python-pptx>=1.0", + "pyyaml>=6.0", ] [dependency-groups] diff --git a/.github/skills/experimental/tts-voiceover/uv.lock b/.github/skills/experimental/tts-voiceover/uv.lock index e4eb4d9d0..a896224d4 100644 --- a/.github/skills/experimental/tts-voiceover/uv.lock +++ b/.github/skills/experimental/tts-voiceover/uv.lock @@ -418,104 +418,104 @@ wheels = [ [[package]] name = "lxml" -version = "6.0.4" +version = "6.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/08/1217ca4043f55c3c92993b283a7dbfa456a2058d8b57bbb416cc96b6efff/lxml-6.0.4.tar.gz", hash = "sha256:4137516be2a90775f99d8ef80ec0283f8d78b5d8bd4630ff20163b72e7e9abf2", size = 4237780, upload-time = "2026-04-12T16:28:24.182Z" } +sdist = { url = "https://files.pythonhosted.org/packages/28/30/9abc9e34c657c33834eaf6cd02124c61bdf5944d802aa48e69be8da3585d/lxml-6.1.0.tar.gz", hash = "sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13", size = 4197006, upload-time = "2026-04-18T04:32:51.613Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/15/93/5145f2c9210bf99c01f2f54d364be805f556f2cb13af21d3c2d80e0780bb/lxml-6.0.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3602d57fdb6f744f4c5d0bd49513fe5abbced08af85bba345fc354336667cd47", size = 8525003, upload-time = "2026-04-12T16:23:34.045Z" }, - { url = "https://files.pythonhosted.org/packages/93/19/9d61560a53ac1b26aec1a83ae51fadbe0cc0b6534e2c753ad5af854f231b/lxml-6.0.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b8c7976c384dcab4bca42f371449fb711e20f1bfce99c135c9b25614aed80e55", size = 4594697, upload-time = "2026-04-12T16:23:36.403Z" }, - { url = "https://files.pythonhosted.org/packages/93/1a/0db40884f959c94ede238507ea0967dd47527ab11d130c5a571088637e78/lxml-6.0.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:579e20c120c3d231e53f0376058e4e1926b71ca4f7b77a7a75f82aea7a9b501e", size = 4922365, upload-time = "2026-04-12T16:23:38.709Z" }, - { url = "https://files.pythonhosted.org/packages/04/db/4136fab3201087bd5a4db433b9a36e50808d8af759045e7d7af757b46178/lxml-6.0.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f32a27be5fb286febd16c0d13d4a3aee474d34417bd172e64d76c6a28e2dc14", size = 5066748, upload-time = "2026-04-12T16:23:41.048Z" }, - { url = "https://files.pythonhosted.org/packages/03/d9/aad543afc57e6268200332ebe695be0320fdd2219b175d34a52027aa1bad/lxml-6.0.4-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d53b7cdaa961a4343312964f6c5a150d075a55e95e1338078d413bf38eba8c0", size = 5000464, upload-time = "2026-04-12T16:23:42.946Z" }, - { url = "https://files.pythonhosted.org/packages/ab/92/14cc575b97dedf02eb8de96af8d977f06b9f2500213805165606ff06c011/lxml-6.0.4-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d4cc697347f6c61764b58767109e270d0b4a92aba4a8053a967ed9de23a5ea9", size = 5201395, upload-time = "2026-04-12T16:23:45.227Z" }, - { url = "https://files.pythonhosted.org/packages/a7/72/0ff17f32a737a9c2840f781aee4bbd5cec947b966ff0c74c5dec56098beb/lxml-6.0.4-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:108b8d6da624133eaa1a6a5bbcb1f116b878ea9fd050a1724792d979251706fb", size = 5329108, upload-time = "2026-04-12T16:23:48.094Z" }, - { url = "https://files.pythonhosted.org/packages/f7/f7/3b1f43e0db54462b5f1ebd96ee43b240388e3b9bf372546694175bec2d41/lxml-6.0.4-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c087d643746489df06fe3ac03460d235b4b3ae705e25838257510c79f834e50f", size = 4658132, upload-time = "2026-04-12T16:23:50.279Z" }, - { url = "https://files.pythonhosted.org/packages/94/cb/90513445e4f08c500f953543aadf18501e5438b31bc816d0ce9a5e09cc5c/lxml-6.0.4-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2063c486f80c32a576112201c93269a09ebeca5b663092112c5fb39b32556340", size = 5264665, upload-time = "2026-04-12T16:23:52.397Z" }, - { url = "https://files.pythonhosted.org/packages/17/d2/c1fa939ea0fa75190dd452d9246f97c16372e2d593fe9f4684cae5c37dda/lxml-6.0.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ff016e86ec14ae96253a3834302e0e89981956b73e4e74617eeba4a6a81da08b", size = 5043801, upload-time = "2026-04-12T16:23:55.634Z" }, - { url = "https://files.pythonhosted.org/packages/22/d4/01cdd3c367045526a376cc1eadacf647f193630db3f902b8842a76b3eb2e/lxml-6.0.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:0e9ba5bcd75efb8cb4613463e6cfb55b5a76d4143e4cfa06ea027bc6cc696a3e", size = 4711416, upload-time = "2026-04-12T16:23:57.647Z" }, - { url = "https://files.pythonhosted.org/packages/8d/77/f6af805c6e23b9a12970c8c38891b087ffd884c2d4df6069e63ff1623fd6/lxml-6.0.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:9a69668bef9268f54a92f2254917df530ca4630a621027437f0e948eb1937e7b", size = 5251326, upload-time = "2026-04-12T16:23:59.901Z" }, - { url = "https://files.pythonhosted.org/packages/2b/bb/bcd429655f6d12845d91f17e3977d63de22cde5fa77f7d4eef7669a80e8c/lxml-6.0.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280f8e7398bdc48c7366ad375a5586692cd73b269d9e82e6898f9ada70dc0bcb", size = 5224752, upload-time = "2026-04-12T16:24:02.002Z" }, - { url = "https://files.pythonhosted.org/packages/69/cd/0342c5a3663115560899a0529789969a72bc5209c8f0084e5b0598cda94d/lxml-6.0.4-cp311-cp311-win32.whl", hash = "sha256:a8eddf3c705e00738db695a9a77830f8d57f7d21a54954fbef23a1b8806384ed", size = 3592977, upload-time = "2026-04-12T16:24:03.847Z" }, - { url = "https://files.pythonhosted.org/packages/92/c1/386ee2e8a8008cccc4903435f19aaffd16d9286186106752d08be2bd7ccb/lxml-6.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:b74d5b391fc49fc3cc213c930f87a7dedf2b4b0755aae4638e91e4501e278430", size = 4023718, upload-time = "2026-04-12T16:24:06.135Z" }, - { url = "https://files.pythonhosted.org/packages/a7/a0/19f5072fdc7c73d44004506172dba4b7e3d179d9b3a387efce9c30365afd/lxml-6.0.4-cp311-cp311-win_arm64.whl", hash = "sha256:2f0cf04bafc14b0eebfbc3b5b73b296dd76b5d7640d098c02e75884bb0a70f2b", size = 3666955, upload-time = "2026-04-12T16:24:08.438Z" }, - { url = "https://files.pythonhosted.org/packages/3d/18/4732abab49bbb041b1ded9dd913ca89735a0dcca038eacec64c44ba02163/lxml-6.0.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:af0b8459c4e21a8417db967b2e453d1855022dac79c79b61fb8214f3da50f17e", size = 8570033, upload-time = "2026-04-12T16:24:10.728Z" }, - { url = "https://files.pythonhosted.org/packages/72/7e/38523ec7178ca35376551911455d1b2766bc9d98bcc18f606a167fa9ecbb/lxml-6.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e0cdcea2affa53fa17dc4bf5cefc0edf72583eac987d669493a019998a623fa3", size = 4623270, upload-time = "2026-04-12T16:24:13.2Z" }, - { url = "https://files.pythonhosted.org/packages/f1/cf/f9b6c9bf9d8c63d923ef893915141767cea4cea71774f20c36d0c14e1585/lxml-6.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8da4d4840c1bc07da6fcd647784f7fbaf538eeb7a57ce6b2487acc54c5e33330", size = 4929471, upload-time = "2026-04-12T16:24:15.453Z" }, - { url = "https://files.pythonhosted.org/packages/e5/53/3117f988c9e20be4156d2b8e1bda82ae06878d11aeb820dea111a7cfa4e3/lxml-6.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fb04a997588c3980894ded9172c10c5a3e45d3f1c5410472733626d268683806", size = 5092355, upload-time = "2026-04-12T16:24:17.876Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ca/05c6ac773a2bd3edb48fa8a5c5101e927ce044c4a8aed1a85ff00fab20a5/lxml-6.0.4-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca449642a08a6ceddf6e6775b874b6aee1b6242ed80aea84124497aba28e5384", size = 5004520, upload-time = "2026-04-12T16:24:20.184Z" }, - { url = "https://files.pythonhosted.org/packages/f1/db/d8aa5aa3a51d0aa6706ef85f85027f7c972cd840fe69ba058ecaf32d093d/lxml-6.0.4-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35b3ccdd137e62033662787dd4d2b8be900c686325d6b91e3b1ff6213d05ba11", size = 5629961, upload-time = "2026-04-12T16:24:22.242Z" }, - { url = "https://files.pythonhosted.org/packages/9d/75/8fff4444e0493aeb15ab0f4a55c767b5baed9074cf67a1835dc1161f3a1f/lxml-6.0.4-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:45dc690c54b1341fec01743caed02e5f1ea49d7cfb81e3ba48903e5e844ed68a", size = 5237561, upload-time = "2026-04-12T16:24:24.572Z" }, - { url = "https://files.pythonhosted.org/packages/2a/9f/6d6cd73014f2dbf47a8aa7accd9712726f46ef4891e1c126bc285cfb94e4/lxml-6.0.4-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:15ae922e8f74b05798a0e88cee46c0244aaec6a66b5e00be7d18648fed8c432e", size = 5349197, upload-time = "2026-04-12T16:24:26.805Z" }, - { url = "https://files.pythonhosted.org/packages/2d/43/e3e9a126e166234d1659d1dd9004dc1dd50cdc3c68575b071b0a1524b4de/lxml-6.0.4-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:ebd816653707fbf10c65e3dee3bc24dac6b691654c21533b1ae49287433f4db0", size = 4693123, upload-time = "2026-04-12T16:24:28.812Z" }, - { url = "https://files.pythonhosted.org/packages/6c/98/b146dd123a4a7b69b571ff23ea8e8c68de8d8c1b03e23d01c6374d4fd835/lxml-6.0.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:21284cf36b95dd8be774eb06c304b440cf49ee811800a30080ce6d93700f0383", size = 5242967, upload-time = "2026-04-12T16:24:30.811Z" }, - { url = "https://files.pythonhosted.org/packages/7e/60/8c275584452b55a902c883e8ab63d755c5ef35d7ad1f06f9e6559095521d/lxml-6.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c08a2a9d0c4028ef5fc5a513b2e1e51af069a83c5b4206139edd08b3b8c2926", size = 5046810, upload-time = "2026-04-12T16:24:33.289Z" }, - { url = "https://files.pythonhosted.org/packages/19/aa/19ec216147e1105e5403fe73657c693a6e91bde855a13242dd6031e829e5/lxml-6.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1bc2f0f417112cf1a428599dd58125ab74d8e1c66893efd9b907cbb4a5db6e44", size = 4776383, upload-time = "2026-04-12T16:24:36.008Z" }, - { url = "https://files.pythonhosted.org/packages/41/c8/90afdb838705a736268fcffd2698c05e9a129144ce215d5e14db3bdfc295/lxml-6.0.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c0d86e328405529bc93913add9ff377e8b8ea9be878e611f19dbac7766a84483", size = 5643497, upload-time = "2026-04-12T16:24:38.276Z" }, - { url = "https://files.pythonhosted.org/packages/32/ec/1135261ec9822dafb90be0ff6fb0ec79cee0b7fe878833dfe5f2b8c393bd/lxml-6.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3cce9420fe8f91eae5d457582599d282195c958cb670aa4bea313a79103ba33f", size = 5232185, upload-time = "2026-04-12T16:24:40.516Z" }, - { url = "https://files.pythonhosted.org/packages/13/f2/7380b11cae6943720f525e5a28ad9dbead96ac710417e556b7c03f3a8af3/lxml-6.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:96214985ec194ce97b9028414e179cfb21230cba4e2413aee7e249461bb84f4d", size = 5259968, upload-time = "2026-04-12T16:24:42.917Z" }, - { url = "https://files.pythonhosted.org/packages/65/8f/141734f2c456f2253fed4237d8d4b241e3d701129cf6f0b135ccf241a75a/lxml-6.0.4-cp312-cp312-win32.whl", hash = "sha256:b2209b310e7ed1d4cd1c00d405ec9c49722fce731c7036abc1d876bf8df78139", size = 3594958, upload-time = "2026-04-12T16:24:45.039Z" }, - { url = "https://files.pythonhosted.org/packages/b7/a9/c6d3531c6d8814af0919fbdb9bda43c9e8b5deffcb70c8534017db233512/lxml-6.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:03affcacfba4671ebc305813b02bfaf34d80b6a7c5b23eafc5d6da14a1a6e623", size = 3995897, upload-time = "2026-04-12T16:24:46.98Z" }, - { url = "https://files.pythonhosted.org/packages/03/5d/1dabeddf762e5a315a31775b2bca39811d7e7a15fc3e677d044b9da973fe/lxml-6.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:af9678e3a2a047465515d95a61690109af7a4c9486f708249119adcef7861049", size = 3658607, upload-time = "2026-04-12T16:24:49.19Z" }, - { url = "https://files.pythonhosted.org/packages/78/f6/550a1ed9afde66e24bfcf9892446ea9779152df336062c6df0f7733151a2/lxml-6.0.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ecc3d55ed756ee6c3447748862a97e1f5392d2c5d7f474bace9382345e4fc274", size = 8559522, upload-time = "2026-04-12T16:24:51.563Z" }, - { url = "https://files.pythonhosted.org/packages/11/93/3f687c14d2b4d24b60fe13fd5482c8853f82a10bb87f2b577123e342ed1a/lxml-6.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7d5a627a368a0e861350ccc567a70ec675d2bc4d8b3b54f48995ae78d8d530e", size = 4617380, upload-time = "2026-04-12T16:24:54.042Z" }, - { url = "https://files.pythonhosted.org/packages/b5/ed/91e443366063d3fb7640ae2badd5d7b65be4095ac6d849788e39c043baae/lxml-6.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d385141b186cc39ebe4863c1e41936282c65df19b2d06a701dedc2a898877d6a", size = 4922791, upload-time = "2026-04-12T16:24:56.381Z" }, - { url = "https://files.pythonhosted.org/packages/30/4b/2243260b70974aca9ba0cc71bd668c0c3a79644d80ddcabbfbdb4b131848/lxml-6.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0132bb040e9bb5a199302e12bf942741defbc52922a2a06ce9ff7be0d0046483", size = 5080972, upload-time = "2026-04-12T16:24:58.823Z" }, - { url = "https://files.pythonhosted.org/packages/f8/c3/54c53c4f772341bc12331557f8b0882a426f53133926306cbe6d7f0ee7e4/lxml-6.0.4-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26aee5321e4aa1f07c9090a35f6ab8b703903fb415c6c823cfdb20ee0d779855", size = 4992236, upload-time = "2026-04-12T16:25:01.099Z" }, - { url = "https://files.pythonhosted.org/packages/be/0f/416de42e22f287585abee610eb0d1c2638c9fe24cee7e15136e0b5e138f8/lxml-6.0.4-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b5652455de198ff76e02cfa57d5efc5f834fa45521aaf3fcc13d6b5a88bde23d", size = 5612398, upload-time = "2026-04-12T16:25:03.517Z" }, - { url = "https://files.pythonhosted.org/packages/7d/63/29a3fa79b8a182f5bd5b5bdcb6f625f49f08f41d60a26ca25482820a1b99/lxml-6.0.4-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:75842801fb48aea73f4c281b923a010dfb39bad75edf8ceb2198ec30c27f01cc", size = 5227480, upload-time = "2026-04-12T16:25:06.119Z" }, - { url = "https://files.pythonhosted.org/packages/7c/4a/44d1843de599b1c6dbe578e4248c2f15e7fac90c5c86eb26775eaeac0fe0/lxml-6.0.4-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:94a1f74607a5a049ff6ff8de429fec922e643e32b5b08ec7a4fe49e8de76e17c", size = 5341001, upload-time = "2026-04-12T16:25:08.563Z" }, - { url = "https://files.pythonhosted.org/packages/0d/52/c8aebde49f169e4e3452e7756be35be1cb2903e30d961cb57aa65a27055f/lxml-6.0.4-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:173cc246d3d3b6d3b6491f0b3aaf22ebdf2eed616879482acad8bd84d73eb231", size = 4699105, upload-time = "2026-04-12T16:25:10.757Z" }, - { url = "https://files.pythonhosted.org/packages/78/60/76fc3735c31c28b70220d99452fb72052e84b618693ca2524da96f0131d8/lxml-6.0.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f0f2ee1be1b72e9890da87e4e422f2f703ff4638fd5ec5383055db431e8e30e9", size = 5231095, upload-time = "2026-04-12T16:25:13.305Z" }, - { url = "https://files.pythonhosted.org/packages/e5/60/448f01c52110102f23df5f07b3f4fde57c8e13e497e182a743d125324c0b/lxml-6.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c51a274b7e8b9ce394c3f8b471eb0b23c1914eec64fdccf674e082daf72abf11", size = 5042411, upload-time = "2026-04-12T16:25:15.541Z" }, - { url = "https://files.pythonhosted.org/packages/4a/2a/90612a001fa4fa0ff0443ebb0256a542670fe35473734c559720293e7aff/lxml-6.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:210ea934cba1a1ec42f88c4190c4d5c67b2d14321a8faed9b39e8378198ff99d", size = 4768431, upload-time = "2026-04-12T16:25:17.581Z" }, - { url = "https://files.pythonhosted.org/packages/84/d8/572845a7d741c8a8ffeaf928185263e14d97fbd355de164677340951d7a5/lxml-6.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:14fe654a59eebe16368c51778caeb0c8fda6f897adcd9afe828d87d13b5d5e51", size = 5634972, upload-time = "2026-04-12T16:25:20.111Z" }, - { url = "https://files.pythonhosted.org/packages/d7/1d/392b8c9f8cf1d502bbec50dee137c7af3dd5def5e5cd84572fbf0ba0541c/lxml-6.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ec160a2b7e2b3cb71ec35010b19a1adea05785d19ba5c9c5f986b64b78fef564", size = 5222909, upload-time = "2026-04-12T16:25:22.243Z" }, - { url = "https://files.pythonhosted.org/packages/21/ab/949fc96f825cf083612aee65d5a02eacc5eaeb2815561220e33e1e160677/lxml-6.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d305b86ef10b23cf3a6d62a2ad23fa296f76495183ee623f64d2600f65ffe09c", size = 5249096, upload-time = "2026-04-12T16:25:24.781Z" }, - { url = "https://files.pythonhosted.org/packages/56/e8/fbe44df79ede5ff760401cc3c49c4204f49f0f529cc6b27d0af7b63f5472/lxml-6.0.4-cp313-cp313-win32.whl", hash = "sha256:a2f31380aa9a9b52591e79f1c1d3ac907688fbeb9d883ba28be70f2eb5db2277", size = 3595808, upload-time = "2026-04-12T16:25:26.747Z" }, - { url = "https://files.pythonhosted.org/packages/f8/df/e873abb881092256520edf0d67d686e36f3c86b3cf289f01b6458272dede/lxml-6.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:b8efa9f681f15043e497293d58a4a63199564b253ed2291887d92bb3f74f59ab", size = 3994635, upload-time = "2026-04-12T16:25:28.828Z" }, - { url = "https://files.pythonhosted.org/packages/23/a8/9c56c8914b9b18d89face5a7472445002baf309167f7af65d988842129fd/lxml-6.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:905abe6a5888129be18f85f2aea51f0c9863fa0722fb8530dfbb687d2841d221", size = 3657374, upload-time = "2026-04-12T16:25:30.901Z" }, - { url = "https://files.pythonhosted.org/packages/10/18/36e28a809c509a67496202771f545219ac5a2f1cd61aae325991fcf5ab91/lxml-6.0.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:569d3b18340863f603582d2124e742a68e85755eff5e47c26a55e298521e3a01", size = 8575045, upload-time = "2026-04-12T16:25:33.57Z" }, - { url = "https://files.pythonhosted.org/packages/11/38/a168c820e3b08d3b4fa0f4e6b53b3930086b36cc11e428106d38c36778cd/lxml-6.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3b6245ee5241342d45e1a54a4a8bc52ef322333ada74f24aa335c4ab36f20161", size = 4622963, upload-time = "2026-04-12T16:25:36.818Z" }, - { url = "https://files.pythonhosted.org/packages/53/e0/2c9d6abdd82358cea3c0d8d6ca272a6af0f38156abce7827efb6d5b62d17/lxml-6.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79a1173ba3213a3693889a435417d4e9f3c07d96e30dc7cc3a712ed7361015fe", size = 4948832, upload-time = "2026-04-12T16:25:39.104Z" }, - { url = "https://files.pythonhosted.org/packages/96/d7/f2202852e91d7baf3a317f4523a9c14834145301e5b0f2e80c01c4bfbd49/lxml-6.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dc18bb975666b443ba23aedd2fcf57e9d0d97546b52a1de97a447c4061ba4110", size = 5085865, upload-time = "2026-04-12T16:25:41.226Z" }, - { url = "https://files.pythonhosted.org/packages/09/57/abee549324496e92708f71391c6060a164d3c95369656a1a15e9f20d8162/lxml-6.0.4-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2079f5dc83291ac190a52f8354b78648f221ecac19fb2972a2d056b555824de7", size = 5030001, upload-time = "2026-04-12T16:25:43.695Z" }, - { url = "https://files.pythonhosted.org/packages/c2/f8/432da7178c5917a16468af6c5da68fef7cf3357d4bd0e6f50272ec9a59b5/lxml-6.0.4-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3eda02da4ca16e9ca22bbe5654470c17fa1abcd967a52e4c2e50ff278221e351", size = 5646303, upload-time = "2026-04-12T16:25:46.577Z" }, - { url = "https://files.pythonhosted.org/packages/82/f9/e1c04ef667a6bf9c9dbd3bf04c50fa51d7ee25b258485bb748b27eb9a1c7/lxml-6.0.4-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3787cdc3832b70e21ac2efafea2a82a8ccb5e85bec110dc68b26023e9d3caae", size = 5237940, upload-time = "2026-04-12T16:25:49.157Z" }, - { url = "https://files.pythonhosted.org/packages/d0/f0/cdea60d92df731725fc3c4f33e387b100f210acd45c92969e42d2ba993fa/lxml-6.0.4-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:3f276d49c23103565d39440b9b3f4fc08fa22f5a96395ea4b4d4fea4458b1505", size = 5350050, upload-time = "2026-04-12T16:25:52.027Z" }, - { url = "https://files.pythonhosted.org/packages/2e/15/bf52c7a70b6081bb9e00d37cc90fcf60aa84468d9d173ad2fade38ec34c5/lxml-6.0.4-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:fdfdad73736402375b11b3a137e48cd09634177516baf5fc0bd80d1ca85f3cda", size = 4696409, upload-time = "2026-04-12T16:25:55.141Z" }, - { url = "https://files.pythonhosted.org/packages/c5/69/9bade267332cc06f9a9aa773b5a11bdfb249af485df9e142993009ea1fc4/lxml-6.0.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75912421456946931daba0ec3cedfa824c756585d05bde97813a17992bfbd013", size = 5249072, upload-time = "2026-04-12T16:25:57.362Z" }, - { url = "https://files.pythonhosted.org/packages/14/ca/043bcacb096d6ed291cbbc58724e9625a453069d6edeb840b0bf18038d05/lxml-6.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:48cd5a88da67233fd82f2920db344503c2818255217cd6ea462c9bb8254ba7cb", size = 5083779, upload-time = "2026-04-12T16:26:00.018Z" }, - { url = "https://files.pythonhosted.org/packages/04/89/f5fb18d76985969e84af13682e489acabee399bb54738a363925ea6e7390/lxml-6.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:87af86a8fa55b9ff1e6ee4233d762296f2ce641ba948af783fb995c5a8a3371b", size = 4736953, upload-time = "2026-04-12T16:26:02.289Z" }, - { url = "https://files.pythonhosted.org/packages/84/ba/d1d7284bb4ba951f188c3fc0455943c1fcbd1c33d1324d6d57b7d4a45be6/lxml-6.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a743714cd656ba7ccb29d199783906064c7b5ba3c0e2a79f0244ea0badc6a98c", size = 5669605, upload-time = "2026-04-12T16:26:04.694Z" }, - { url = "https://files.pythonhosted.org/packages/72/05/1463e55f2de27bb60feddc894dd7c0833bd501f8861392ed416291b38db5/lxml-6.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e31c76bd066fb4f81d9a32e5843bffdf939ab27afb1ffc1c924e749bfbdb00e3", size = 5236886, upload-time = "2026-04-12T16:26:07.659Z" }, - { url = "https://files.pythonhosted.org/packages/fe/fb/0b6ee9194ce3ac49db4cadaa8a9158f04779fc768b6c27c4e2945d71a99d/lxml-6.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f185fd6e7d550e9917d7103dccf51be589aba953e15994fb04646c1730019685", size = 5263382, upload-time = "2026-04-12T16:26:10.067Z" }, - { url = "https://files.pythonhosted.org/packages/9a/93/ec18a08e98dd82cac39f1d2511ee2bed5affb94d228356d8ef165a4ec3b9/lxml-6.0.4-cp314-cp314-win32.whl", hash = "sha256:774660028f8722a598400430d2746fb0075949f84a9a5cd9767d9152e3baaac5", size = 3656164, upload-time = "2026-04-12T16:26:59.568Z" }, - { url = "https://files.pythonhosted.org/packages/15/86/52507316abfc7150bf6bb191e39a12e301ee80334610a493884ae2f9d20d/lxml-6.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:fbd7d14349413f5609c0b537b1a48117d6ccef1af37986af6b03766ad05bf43e", size = 4062512, upload-time = "2026-04-12T16:27:02.212Z" }, - { url = "https://files.pythonhosted.org/packages/f1/d5/09c593a2ef2234b8cd6cf059e2dc212e0654bf05c503f0ef2daf05adb680/lxml-6.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:a61a01ec3fbfd5b73a69a7bf513271051fd6c5795d82fc5daa0255934cd8db3d", size = 3740745, upload-time = "2026-04-12T16:27:04.444Z" }, - { url = "https://files.pythonhosted.org/packages/4a/3c/42a98bf6693938bf7b285ec7f70ba2ae9d785d0e5b2cdb85d2ee29e287eb/lxml-6.0.4-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:504edb62df33cea502ea6e73847c647ba228623ca3f80a228be5723a70984dd5", size = 8826437, upload-time = "2026-04-12T16:26:12.911Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c2/ad13f39b2db8709788aa2dcb6e90b81da76db3b5b2e7d35e0946cf984960/lxml-6.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f01b7b0316d4c0926d49a7f003b2d30539f392b140a3374bb788bad180bc8478", size = 4734892, upload-time = "2026-04-12T16:26:15.871Z" }, - { url = "https://files.pythonhosted.org/packages/2c/6d/c559d7b5922c5b0380fc2cb5ac134b6a3f9d79d368347a624ee5d68b0816/lxml-6.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab999933e662501efe4b16e6cfb7c9f9deca7d072cd1788b99c8defde78c0dfb", size = 4969173, upload-time = "2026-04-12T16:26:18.335Z" }, - { url = "https://files.pythonhosted.org/packages/c7/78/ca521e36157f38e3e1a29276855cdf48d213138fc0c8365693ff5c876ca7/lxml-6.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67c3f084389fe75932c39b6869a377f6c8e21e818f31ae8a30c71dd2e59360e2", size = 5103134, upload-time = "2026-04-12T16:26:20.612Z" }, - { url = "https://files.pythonhosted.org/packages/28/a7/7d62d023bacaa0aaf60af8c0a77c6c05f84327396d755f3aa64b788678a9/lxml-6.0.4-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:377ea1d654f76ed6205c87d14920f829c9f4d31df83374d3cbcbdaae804d37b2", size = 5027205, upload-time = "2026-04-12T16:26:22.981Z" }, - { url = "https://files.pythonhosted.org/packages/34/be/51b194b81684f2e85e5d992771c45d70cb22ac6f7291ac6bc7b255830afe/lxml-6.0.4-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e60cd0bcacbfd1a96d63516b622183fb2e3f202300df9eb5533391a8a939dbfa", size = 5594461, upload-time = "2026-04-12T16:26:25.316Z" }, - { url = "https://files.pythonhosted.org/packages/39/24/8850f38fbf89dd072ff31ba22f9e40347aeada7cadf710ecb04b8d9f32d4/lxml-6.0.4-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e9e30fd63d41dd0bbdb020af5cdfffd5d9b554d907cb210f18e8fcdc8eac013", size = 5223378, upload-time = "2026-04-12T16:26:28.68Z" }, - { url = "https://files.pythonhosted.org/packages/2a/9b/595239ba8c719b0fdc7bc9ebdb7564459c9a6b24b8b363df4a02674aeece/lxml-6.0.4-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:1fb4a1606bb68c533002e7ed50d7e55e58f0ef1696330670281cb79d5ab2050d", size = 5311415, upload-time = "2026-04-12T16:26:31.513Z" }, - { url = "https://files.pythonhosted.org/packages/be/cb/aa27ac8d041acf34691577838494ad08df78e83fdfdb66948d2903e9291e/lxml-6.0.4-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:695c7708438e449d57f404db8cc1b769e77ad5b50655f32f8175686ba752f293", size = 4637953, upload-time = "2026-04-12T16:26:33.806Z" }, - { url = "https://files.pythonhosted.org/packages/f6/f2/f19114fd86825c2d1ce41cd99daad218d30cfdd2093d4de9273986fb4d68/lxml-6.0.4-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d49c35ae1e35ee9b569892cf8f8f88db9524f28d66e9daee547a5ef9f3c5f468", size = 5231532, upload-time = "2026-04-12T16:26:36.518Z" }, - { url = "https://files.pythonhosted.org/packages/9a/0e/c3fa354039ec0b6b09f40fbe1129efc572ac6239faa4906de42d5ce87c0a/lxml-6.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5801072f8967625e6249d162065d0d6011ef8ce3d0efb8754496b5246b81a74b", size = 5083767, upload-time = "2026-04-12T16:26:39.332Z" }, - { url = "https://files.pythonhosted.org/packages/b3/4b/1a0dbb6d6ffae16e54a8a3796ded0ad2f9c3bc1ff3728bde33456f4e1d63/lxml-6.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cbf768541526eba5ef1a49f991122e41b39781eafd0445a5a110fc09947a20b5", size = 4758079, upload-time = "2026-04-12T16:26:42.138Z" }, - { url = "https://files.pythonhosted.org/packages/a9/01/a246cf5f80f96766051de4b305d6552f80bdaefb37f04e019e42af0aba69/lxml-6.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eecce87cc09233786fc31c230268183bf6375126cfec1c8b3673fcdc8767b560", size = 5618686, upload-time = "2026-04-12T16:26:44.507Z" }, - { url = "https://files.pythonhosted.org/packages/eb/1f/b072a92369039ebef11b0a654be5134fcf3ed04c0f437faf9435ac9ba845/lxml-6.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:07dce892881179e11053066faca2da17b0eeb0bb7298f11bcf842a86db207dbd", size = 5227259, upload-time = "2026-04-12T16:26:47.083Z" }, - { url = "https://files.pythonhosted.org/packages/d5/a0/dc97034f9d4c0c4d30875147d81fd2c0c7f3d261b109db36ed746bf8ab1d/lxml-6.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e4f97aee337b947e6699e5574c90d087d3e2ce517016241c07e7e98a28dca885", size = 5246190, upload-time = "2026-04-12T16:26:49.468Z" }, - { url = "https://files.pythonhosted.org/packages/f2/ef/85cb69835113583c2516fee07d0ffb4d824b557424b06ba5872c20ba6078/lxml-6.0.4-cp314-cp314t-win32.whl", hash = "sha256:064477c0d4c695aa1ea4b9c1c4ee9043ab740d12135b74c458cc658350adcd86", size = 3896005, upload-time = "2026-04-12T16:26:52.163Z" }, - { url = "https://files.pythonhosted.org/packages/3d/5e/2231f34cc54b8422b793593138d86d3fa4588fb2297d4ea0472390f25627/lxml-6.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:25bad2d8438f4ef5a7ad4a8d8bcaadde20c0daced8bdb56d46236b0a7d1cbdd0", size = 4391037, upload-time = "2026-04-12T16:26:54.398Z" }, - { url = "https://files.pythonhosted.org/packages/39/53/8ba3cd5984f8363635450c93f63e541a0721b362bb32ae0d8237d9674aee/lxml-6.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:1dcd9e6cb9b7df808ea33daebd1801f37a8f50e8c075013ed2a2343246727838", size = 3816184, upload-time = "2026-04-12T16:26:57.011Z" }, - { url = "https://files.pythonhosted.org/packages/41/25/260b86340ec5aadda5e18ed39df0eea61ef8781fb0fcc16c847cdb9dfdff/lxml-6.0.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b29bcca95e82cd201d16c2101085faa2669838f4697fd914b7124a6c77032f80", size = 3929209, upload-time = "2026-04-12T16:28:07.628Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cc/b2157461584525fb0ceb7f4c3b6c1b276f6c7dd34858d78075ae8973bf3d/lxml-6.0.4-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a95e29710ecdf99b446990144598f6117271cb2ec19fd45634aa087892087077", size = 4209535, upload-time = "2026-04-12T16:28:10.071Z" }, - { url = "https://files.pythonhosted.org/packages/1d/fa/7fdcd1eb31ec0d5871a4a0b1587e78a331f59941ff3af59bed064175499e/lxml-6.0.4-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:13085e0174e9c9fa4eb5a6bdfb81646d1f7be07e5895c958e89838afb77630c6", size = 4316979, upload-time = "2026-04-12T16:28:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/53/0c/dab9f5855e7d2e51c8eb461713ada38a7d4eb3ab07fec8d13c46ed353ad6/lxml-6.0.4-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e205c4869a28ec4447375333072978356cd0eeadd0412c643543238e638b89a3", size = 4249929, upload-time = "2026-04-12T16:28:15.739Z" }, - { url = "https://files.pythonhosted.org/packages/a4/88/39e8e4ca7ee1bc9e7cd2f6b311279624afa70a375eef8727f0bb83db2936/lxml-6.0.4-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aec26080306a66ad5c62fad0053dd2170899b465137caca7eac4b72bda3588bf", size = 4399464, upload-time = "2026-04-12T16:28:18.397Z" }, - { url = "https://files.pythonhosted.org/packages/66/54/14c518cc9ce5151fcd1fa95a1c2396799a505dca2c4f0acdf85fb23fe293/lxml-6.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3912221f41d96283b10a7232344351c8511e31f18734c752ed4798c12586ea35", size = 3507404, upload-time = "2026-04-12T16:28:21.188Z" }, + { url = "https://files.pythonhosted.org/packages/5e/5d/3bccad330292946f97962df9d5f2d3ae129cce6e212732a781e856b91e07/lxml-6.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cec05be8c876f92a5aa07b01d60bbb4d11cfbdd654cad0561c0d7b5c043a61b9", size = 8526232, upload-time = "2026-04-18T04:27:40.389Z" }, + { url = "https://files.pythonhosted.org/packages/a7/51/adc8826570a112f83bb4ddb3a2ab510bbc2ccd62c1b9fe1f34fae2d90b57/lxml-6.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9c03e048b6ce8e77b09c734e931584894ecd58d08296804ca2d0b184c933ce50", size = 4595448, upload-time = "2026-04-18T04:27:44.208Z" }, + { url = "https://files.pythonhosted.org/packages/54/84/5a9ec07cbe1d2334a6465f863b949a520d2699a755738986dcd3b6b89e3f/lxml-6.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:942454ff253da14218f972b23dc72fa4edf6c943f37edd19cd697618b626fac5", size = 4923771, upload-time = "2026-04-18T04:32:17.402Z" }, + { url = "https://files.pythonhosted.org/packages/a7/23/851cfa33b6b38adb628e45ad51fb27105fa34b2b3ba9d1d4aa7a9428dfe0/lxml-6.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d036ee7b99d5148072ac7c9b847193decdfeac633db350363f7bce4fff108f0e", size = 5068101, upload-time = "2026-04-18T04:32:21.437Z" }, + { url = "https://files.pythonhosted.org/packages/b0/38/41bf99c2023c6b79916ba057d83e9db21d642f473cac210201222882d38b/lxml-6.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ae5d8d5427f3cc317e7950f2da7ad276df0cfa37b8de2f5658959e618ea8512", size = 5002573, upload-time = "2026-04-18T04:32:25.373Z" }, + { url = "https://files.pythonhosted.org/packages/c2/20/053aa10bdc39747e1e923ce2d45413075e84f70a136045bb09e5eaca41d3/lxml-6.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:363e47283bde87051b821826e71dde47f107e08614e1aa312ba0c5711e77738c", size = 5202816, upload-time = "2026-04-18T04:32:29.393Z" }, + { url = "https://files.pythonhosted.org/packages/9a/da/bc710fad8bf04b93baee752c192eaa2210cd3a84f969d0be7830fea55802/lxml-6.1.0-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:f504d861d9f2a8f94020130adac88d66de93841707a23a86244263d1e54682f5", size = 5329999, upload-time = "2026-04-18T04:32:34.019Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/bf035dedbdf7fab49411aa52e4236f3445e98d38647d85419e6c0d2806b9/lxml-6.1.0-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:23a5dc68e08ed13331d61815c08f260f46b4a60fdd1640bbeb82cf89a9d90289", size = 4659643, upload-time = "2026-04-18T04:32:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4f/22be31f33727a5e4c7b01b0a874503026e50329b259d3587e0b923cf964b/lxml-6.1.0-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f15401d8d3dbf239e23c818afc10c7207f7b95f9a307e092122b6f86dd43209a", size = 5265963, upload-time = "2026-04-18T04:32:41.881Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2b/d44d0e5c79226017f4ab8c87a802ebe4f89f97e6585a8e4166dffcdd7b6e/lxml-6.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fcf3da95e93349e0647d48d4b36a12783105bcc74cb0c416952f9988410846a3", size = 5045444, upload-time = "2026-04-18T04:32:44.512Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c3/3f034fec1594c331a6dbf9491238fdcc9d66f68cc529e109ec75b97197e1/lxml-6.1.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:0d082495c5fcf426e425a6e28daaba1fcb6d8f854a4ff01effb1f1f381203eb9", size = 4712703, upload-time = "2026-04-18T04:32:47.16Z" }, + { url = "https://files.pythonhosted.org/packages/12/16/0b83fccc158218aca75a7aa33e97441df737950734246b9fffa39301603d/lxml-6.1.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e3c4f84b24a1fcba435157d111c4b755099c6ff00a3daee1ad281817de75ed11", size = 5252745, upload-time = "2026-04-18T04:32:50.427Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ee/12e6c1b39a77666c02eaa77f94a870aaf63c4ac3a497b2d52319448b01c6/lxml-6.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:976a6b39b1b13e8c354ad8d3f261f3a4ac6609518af91bdb5094760a08f132c4", size = 5226822, upload-time = "2026-04-18T04:32:53.437Z" }, + { url = "https://files.pythonhosted.org/packages/34/20/c7852904858b4723af01d2fc14b5d38ff57cb92f01934a127ebd9a9e51aa/lxml-6.1.0-cp311-cp311-win32.whl", hash = "sha256:857efde87d365706590847b916baff69c0bc9252dc5af030e378c9800c0b10e3", size = 3594026, upload-time = "2026-04-18T04:27:31.903Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/d60c732b56da5085175c07c74b2df4e6d181b0c9a61e1691474f06ef4b39/lxml-6.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:183bfb45a493081943be7ea2b5adfc2b611e1cf377cefa8b8a8be404f45ef9a7", size = 4025114, upload-time = "2026-04-18T04:27:34.077Z" }, + { url = "https://files.pythonhosted.org/packages/c2/df/c84dcc175fd690823436d15b41cb920cd5ba5e14cd8bfb00949d5903b320/lxml-6.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:19f4164243fc206d12ed3d866e80e74f5bc3627966520da1a5f97e42c32a3f39", size = 3667742, upload-time = "2026-04-18T04:27:38.45Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d4/9326838b59dc36dfae42eec9656b97520f9997eee1de47b8316aaeed169c/lxml-6.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d2f17a16cd8751e8eb233a7e41aecdf8e511712e00088bf9be455f604cd0d28d", size = 8570663, upload-time = "2026-04-18T04:27:48.253Z" }, + { url = "https://files.pythonhosted.org/packages/d8/a4/053745ce1f8303ccbb788b86c0db3a91b973675cefc42566a188637b7c40/lxml-6.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0cea5b1d3e6e77d71bd2b9972eb2446221a69dc52bb0b9c3c6f6e5700592d93", size = 4624024, upload-time = "2026-04-18T04:27:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/90/97/a517944b20f8fd0932ad2109482bee4e29fe721416387a363306667941f6/lxml-6.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fc46da94826188ed45cb53bd8e3fc076ae22675aea2087843d4735627f867c6d", size = 4930895, upload-time = "2026-04-18T04:32:56.29Z" }, + { url = "https://files.pythonhosted.org/packages/94/7c/e08a970727d556caa040a44773c7b7e3ad0f0d73dedc863543e9a8b931f2/lxml-6.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9147d8e386ec3b82c3b15d88927f734f565b0aaadef7def562b853adca45784a", size = 5093820, upload-time = "2026-04-18T04:32:58.94Z" }, + { url = "https://files.pythonhosted.org/packages/88/ee/2a5c2aa2c32016a226ca25d3e1056a8102ea6e1fe308bf50213586635400/lxml-6.1.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5715e0e28736a070f3f34a7ccc09e2fdcba0e3060abbcf61a1a5718ff6d6b105", size = 5005790, upload-time = "2026-04-18T04:33:01.272Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/a0db9be8f38ad6043ab9429487c128dd1d30f07956ef43040402f8da49e8/lxml-6.1.0-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4937460dc5df0cdd2f06a86c285c28afda06aefa3af949f9477d3e8df430c485", size = 5630827, upload-time = "2026-04-18T04:33:04.036Z" }, + { url = "https://files.pythonhosted.org/packages/31/ba/3c13d3fc24b7cacf675f808a3a1baabf43a30d0cd24c98f94548e9aa58eb/lxml-6.1.0-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc783ee3147e60a25aa0445ea82b3e8aabb83b240f2b95d32cb75587ff781814", size = 5240445, upload-time = "2026-04-18T04:33:06.87Z" }, + { url = "https://files.pythonhosted.org/packages/55/ba/eeef4ccba09b2212fe239f46c1692a98db1878e0872ae320756488878a94/lxml-6.1.0-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:40d9189f80075f2e1f88db21ef815a2b17b28adf8e50aaf5c789bfe737027f32", size = 5350121, upload-time = "2026-04-18T04:33:09.365Z" }, + { url = "https://files.pythonhosted.org/packages/7e/01/1da87c7b587c38d0cbe77a01aae3b9c1c49ed47d76918ef3db8fc151b1ca/lxml-6.1.0-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:05b9b8787e35bec69e68daf4952b2e6dfcfb0db7ecf1a06f8cdfbbac4eb71aad", size = 4694949, upload-time = "2026-04-18T04:33:11.628Z" }, + { url = "https://files.pythonhosted.org/packages/a1/88/7db0fe66d5aaf128443ee1623dec3db1576f3e4c17751ec0ef5866468590/lxml-6.1.0-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0f08beb0182e3e9a86fae124b3c47a7b41b7b69b225e1377db983802404e54", size = 5243901, upload-time = "2026-04-18T04:33:13.95Z" }, + { url = "https://files.pythonhosted.org/packages/00/a8/1346726af7d1f6fca1f11223ba34001462b0a3660416986d37641708d57c/lxml-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73becf6d8c81d4c76b1014dbd3584cb26d904492dcf73ca85dc8bff08dcd6d2d", size = 5048054, upload-time = "2026-04-18T04:33:16.965Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b7/85057012f035d1a0c87e02f8c723ca3c3e6e0728bcf4cb62080b21b1c1e3/lxml-6.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1ae225f66e5938f4fa29d37e009a3bb3b13032ac57eb4eb42afa44f6e4054e69", size = 4777324, upload-time = "2026-04-18T04:33:19.832Z" }, + { url = "https://files.pythonhosted.org/packages/75/6c/ad2f94a91073ef570f33718040e8e160d5fb93331cf1ab3ca1323f939e2d/lxml-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:690022c7fae793b0489aa68a658822cea83e0d5933781811cabbf5ea3bcfe73d", size = 5645702, upload-time = "2026-04-18T04:33:22.436Z" }, + { url = "https://files.pythonhosted.org/packages/3b/89/0bb6c0bd549c19004c60eea9dc554dd78fd647b72314ef25d460e0d208c6/lxml-6.1.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63aeafc26aac0be8aff14af7871249e87ea1319be92090bfd632ec68e03b16a5", size = 5232901, upload-time = "2026-04-18T04:33:26.21Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d9/d609a11fb567da9399f525193e2b49847b5a409cdebe737f06a8b7126bdc/lxml-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:264c605ab9c0e4aa1a679636f4582c4d3313700009fac3ec9c3412ed0d8f3e1d", size = 5261333, upload-time = "2026-04-18T04:33:28.984Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3a/ac3f99ec8ac93089e7dd556f279e0d14c24de0a74a507e143a2e4b496e7c/lxml-6.1.0-cp312-cp312-win32.whl", hash = "sha256:56971379bc5ee8037c5a0f09fa88f66cdb7d37c3e38af3e45cf539f41131ac1f", size = 3596289, upload-time = "2026-04-18T04:27:42.819Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a7/0a915557538593cb1bbeedcd40e13c7a261822c26fecbbdb71dad0c2f540/lxml-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:bba078de0031c219e5dd06cf3e6bf8fb8e6e64a77819b358f53bb132e3e03366", size = 3997059, upload-time = "2026-04-18T04:27:46.764Z" }, + { url = "https://files.pythonhosted.org/packages/92/96/a5dc078cf0126fbfbc35611d77ecd5da80054b5893e28fb213a5613b9e1d/lxml-6.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:c3592631e652afa34999a088f98ba7dfc7d6aff0d535c410bea77a71743f3819", size = 3659552, upload-time = "2026-04-18T04:27:51.133Z" }, + { url = "https://files.pythonhosted.org/packages/08/03/69347590f1cf4a6d5a4944bb6099e6d37f334784f16062234e1f892fdb1d/lxml-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a0092f2b107b69601adf562a57c956fbb596e05e3e6651cabd3054113b007e45", size = 8559689, upload-time = "2026-04-18T04:31:57.785Z" }, + { url = "https://files.pythonhosted.org/packages/3f/58/25e00bb40b185c974cfe156c110474d9a8a8390d5f7c92a4e328189bb60e/lxml-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc7140d7a7386e6b545d41b7358f4d02b656d4053f5fa6859f92f4b9c2572c4d", size = 4617892, upload-time = "2026-04-18T04:32:01.78Z" }, + { url = "https://files.pythonhosted.org/packages/f5/54/92ad98a94ac318dc4f97aaac22ff8d1b94212b2ae8af5b6e9b354bf825f7/lxml-6.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:419c58fc92cc3a2c3fa5f78c63dbf5da70c1fa9c1b25f25727ecee89a96c7de2", size = 4923489, upload-time = "2026-04-18T04:33:31.401Z" }, + { url = "https://files.pythonhosted.org/packages/15/3b/a20aecfab42bdf4f9b390590d345857ad3ffd7c51988d1c89c53a0c73faf/lxml-6.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:37fabd1452852636cf38ecdcc9dd5ca4bba7a35d6c53fa09725deeb894a87491", size = 5082162, upload-time = "2026-04-18T04:33:34.262Z" }, + { url = "https://files.pythonhosted.org/packages/45/26/2cdb3d281ac1bd175603e290cbe4bad6eff127c0f8de90bafd6f8548f0fd/lxml-6.1.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2853c8b2170cc6cd54a6b4d50d2c1a8a7aeca201f23804b4898525c7a152cfc", size = 4993247, upload-time = "2026-04-18T04:33:36.674Z" }, + { url = "https://files.pythonhosted.org/packages/f6/05/d735aef963740022a08185c84821f689fc903acb3d50326e6b1e9886cc22/lxml-6.1.0-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e369cbd690e788c8d15e56222d91a09c6a417f49cbc543040cba0fe2e25a79e", size = 5613042, upload-time = "2026-04-18T04:33:39.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b8/ead7c10efff731738c72e59ed6eb5791854879fbed7ae98781a12006263a/lxml-6.1.0-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e69aa6805905807186eb00e66c6d97a935c928275182eb02ee40ba00da9623b2", size = 5228304, upload-time = "2026-04-18T04:33:41.647Z" }, + { url = "https://files.pythonhosted.org/packages/6b/10/e9842d2ec322ea65f0a7270aa0315a53abed06058b88ef1b027f620e7a5f/lxml-6.1.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:4bd1bdb8a9e0e2dd229de19b5f8aebac80e916921b4b2c6ef8a52bc131d0c1f9", size = 5341578, upload-time = "2026-04-18T04:33:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/89/54/40d9403d7c2775fa7301d3ddd3464689bfe9ba71acc17dfff777071b4fdc/lxml-6.1.0-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:cbd7b79cdcb4986ad78a2662625882747f09db5e4cd7b2ae178a88c9c51b3dfe", size = 4700209, upload-time = "2026-04-18T04:33:47.552Z" }, + { url = "https://files.pythonhosted.org/packages/85/b2/bbdcc2cf45dfc7dfffef4fd97e5c47b15919b6a365247d95d6f684ef5e82/lxml-6.1.0-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:43e4d297f11080ec9d64a4b1ad7ac02b4484c9f0e2179d9c4ef78e886e747b88", size = 5232365, upload-time = "2026-04-18T04:33:50.249Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/b06875665e53aaba7127611a7bed3b7b9658e20b22bc2dd217a0b7ab0091/lxml-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cc16682cc987a3da00aa56a3aa3075b08edb10d9b1e476938cfdbee8f3b67181", size = 5043654, upload-time = "2026-04-18T04:33:52.71Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9c/e71a069d09641c1a7abeb30e693f828c7c90a41cbe3d650b2d734d876f85/lxml-6.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d6d8efe71429635f0559579092bb5e60560d7b9115ee38c4adbea35632e7fa24", size = 4769326, upload-time = "2026-04-18T04:33:55.244Z" }, + { url = "https://files.pythonhosted.org/packages/cc/06/7a9cd84b3d4ed79adf35f874750abb697dec0b4a81a836037b36e47c091a/lxml-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7e39ab3a28af7784e206d8606ec0e4bcad0190f63a492bca95e94e5a4aef7f6e", size = 5635879, upload-time = "2026-04-18T04:33:58.509Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f0/9d57916befc1e54c451712c7ee48e9e74e80ae4d03bdce49914e0aee42cd/lxml-6.1.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9eb667bf50856c4a58145f8ca2d5e5be160191e79eb9e30855a476191b3c3495", size = 5224048, upload-time = "2026-04-18T04:34:00.943Z" }, + { url = "https://files.pythonhosted.org/packages/99/75/90c4eefda0c08c92221fe0753db2d6699a4c628f76ff4465ec20dea84cc1/lxml-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7f4a77d6f7edf9230cee3e1f7f6764722a41604ee5681844f18db9a81ea0ec33", size = 5250241, upload-time = "2026-04-18T04:34:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/5e/73/16596f7e4e38fa33084b9ccbccc22a15f82a290a055126f2c1541236d2ff/lxml-6.1.0-cp313-cp313-win32.whl", hash = "sha256:28902146ffbe5222df411c5d19e5352490122e14447e98cd118907ee3fd6ee62", size = 3596938, upload-time = "2026-04-18T04:31:56.206Z" }, + { url = "https://files.pythonhosted.org/packages/8e/63/981401c5680c1eb30893f00a19641ac80db5d1e7086c62cb4b13ed813038/lxml-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:4a1503c56e4e2b38dc76f2f2da7bae69670c0f1933e27cfa34b2fa5876410b16", size = 3995728, upload-time = "2026-04-18T04:31:58.763Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e8/c358a38ac3e541d16a1b527e4e9cb78c0419b0506a070ace11777e5e8404/lxml-6.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:e0af85773850417d994d019741239b901b22c6680206f46a34766926e466141d", size = 3658372, upload-time = "2026-04-18T04:32:03.629Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/cee4cf203ef0bab5c52afc118da61d6b460c928f2893d40023cfa27e0b80/lxml-6.1.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ab863fd37458fed6456525f297d21239d987800c46e67da5ef04fc6b3dd93ac8", size = 8576713, upload-time = "2026-04-18T04:32:06.831Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a7/eda05babeb7e046839204eaf254cd4d7c9130ce2bbf0d9e90ea41af5654d/lxml-6.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6fd8b1df8254ff4fd93fd31da1fc15770bde23ac045be9bb1f87425702f61cc9", size = 4623874, upload-time = "2026-04-18T04:32:10.755Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e9/db5846de9b436b91890a62f29d80cd849ea17948a49bf532d5278ee69a9e/lxml-6.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:47024feaae386a92a146af0d2aeed65229bf6fff738e6a11dda6b0015fb8fd03", size = 4949535, upload-time = "2026-04-18T04:34:06.657Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ba/0d3593373dcae1d68f40dc3c41a5a92f2544e68115eb2f62319a4c2a6500/lxml-6.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3f00972f84450204cd5d93a5395965e348956aaceaadec693a22ec743f8ae3eb", size = 5086881, upload-time = "2026-04-18T04:34:09.556Z" }, + { url = "https://files.pythonhosted.org/packages/43/76/759a7484539ad1af0d125a9afe9c3fb5f82a8779fd1f5f56319d9e4ea2fd/lxml-6.1.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97faa0860e13b05b15a51fb4986421ef7a30f0b3334061c416e0981e9450ca4c", size = 5031305, upload-time = "2026-04-18T04:34:12.336Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b9/c1f0daf981a11e47636126901fd4ab82429e18c57aeb0fc3ad2940b42d8b/lxml-6.1.0-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:972a6451204798675407beaad97b868d0c733d9a74dafefc63120b81b8c2de28", size = 5647522, upload-time = "2026-04-18T04:34:14.89Z" }, + { url = "https://files.pythonhosted.org/packages/31/e6/1f533dcd205275363d9ba3511bcec52fa2df86abf8abe6a5f2c599f0dc31/lxml-6.1.0-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fe022f20bc4569ec66b63b3fb275a3d628d9d32da6326b2982584104db6d3086", size = 5239310, upload-time = "2026-04-18T04:34:17.652Z" }, + { url = "https://files.pythonhosted.org/packages/c3/8c/4175fb709c78a6e315ed814ed33be3defd8b8721067e70419a6cf6f971da/lxml-6.1.0-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:75c4c7c619a744f972f4451bf5adf6d0fb00992a1ffc9fd78e13b0bc817cc99f", size = 5350799, upload-time = "2026-04-18T04:34:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/6ffdebc5994975f0dde4acb59761902bd9d9bb84422b9a0bd239a7da9ca8/lxml-6.1.0-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3648f20d25102a22b6061c688beb3a805099ea4beb0a01ce62975d926944d292", size = 4697693, upload-time = "2026-04-18T04:34:23.541Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/565f36bd5c73294602d48e04d23f81ff4c8736be6ba5e1d1ec670ac9be80/lxml-6.1.0-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77b9f99b17cbf14026d1e618035077060fc7195dd940d025149f3e2e830fbfcb", size = 5250708, upload-time = "2026-04-18T04:34:26.001Z" }, + { url = "https://files.pythonhosted.org/packages/5a/11/a68ab9dd18c5c499404deb4005f4bc4e0e88e5b72cd755ad96efec81d18d/lxml-6.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32662519149fd7a9db354175aa5e417d83485a8039b8aaa62f873ceee7ea4cad", size = 5084737, upload-time = "2026-04-18T04:34:28.32Z" }, + { url = "https://files.pythonhosted.org/packages/ab/78/e8f41e2c74f4af564e6a0348aea69fb6daaefa64bc071ef469823d22cc18/lxml-6.1.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:73d658216fc173cf2c939e90e07b941c5e12736b0bf6a99e7af95459cfe8eabb", size = 4737817, upload-time = "2026-04-18T04:34:30.784Z" }, + { url = "https://files.pythonhosted.org/packages/06/2d/aa4e117aa2ce2f3b35d9ff246be74a2f8e853baba5d2a92c64744474603a/lxml-6.1.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ac4db068889f8772a4a698c5980ec302771bb545e10c4b095d4c8be26749616f", size = 5670753, upload-time = "2026-04-18T04:34:33.675Z" }, + { url = "https://files.pythonhosted.org/packages/08/f5/dd745d50c0409031dbfcc4881740542a01e54d6f0110bd420fa7782110b8/lxml-6.1.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:45e9dfbd1b661eb64ba0d4dbe762bd210c42d86dd1e5bd2bdf89d634231beb43", size = 5238071, upload-time = "2026-04-18T04:34:36.12Z" }, + { url = "https://files.pythonhosted.org/packages/3e/74/ad424f36d0340a904665867dab310a3f1f4c96ff4039698de83b77f44c1f/lxml-6.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:89e8d73d09ac696a5ba42ec69787913d53284f12092f651506779314f10ba585", size = 5264319, upload-time = "2026-04-18T04:34:39.035Z" }, + { url = "https://files.pythonhosted.org/packages/53/36/a15d8b3514ec889bfd6aa3609107fcb6c9189f8dc347f1c0b81eded8d87c/lxml-6.1.0-cp314-cp314-win32.whl", hash = "sha256:ebe33f4ec1b2de38ceb225a1749a2965855bffeef435ba93cd2d5d540783bf2f", size = 3657139, upload-time = "2026-04-18T04:32:20.006Z" }, + { url = "https://files.pythonhosted.org/packages/1a/a4/263ebb0710851a3c6c937180a9a86df1206fdfe53cc43005aa2237fd7736/lxml-6.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:398443df51c538bd578529aa7e5f7afc6c292644174b47961f3bf87fe5741120", size = 4064195, upload-time = "2026-04-18T04:32:23.876Z" }, + { url = "https://files.pythonhosted.org/packages/80/68/2000f29d323b6c286de077ad20b429fc52272e44eae6d295467043e56012/lxml-6.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:8c8984e1d8c4b3949e419158fda14d921ff703a9ed8a47236c6eb7a2b6cb4946", size = 3741870, upload-time = "2026-04-18T04:32:27.922Z" }, + { url = "https://files.pythonhosted.org/packages/30/e9/21383c7c8d43799f0da90224c0d7c921870d476ec9b3e01e1b2c0b8237c5/lxml-6.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1081dd10bc6fa437db2500e13993abf7cc30716d0a2f40e65abb935f02ec559c", size = 8827548, upload-time = "2026-04-18T04:32:15.094Z" }, + { url = "https://files.pythonhosted.org/packages/a5/01/c6bc11cd587030dd4f719f65c5657960649fe3e19196c844c75bf32cd0d6/lxml-6.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:dabecc48db5f42ba348d1f5d5afdc54c6c4cc758e676926c7cd327045749517d", size = 4735866, upload-time = "2026-04-18T04:32:18.924Z" }, + { url = "https://files.pythonhosted.org/packages/f3/01/757132fff5f4acf25463b5298f1a46099f3a94480b806547b29ce5e385de/lxml-6.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e3dd5fe19c9e0ac818a9c7f132a5e43c1339ec1cbbfecb1a938bd3a47875b7c9", size = 4969476, upload-time = "2026-04-18T04:34:41.889Z" }, + { url = "https://files.pythonhosted.org/packages/fd/fb/1bc8b9d27ed64be7c8903db6c89e74dc8c2cd9ec630a7462e4654316dc5b/lxml-6.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9e7b0a4ca6dcc007a4cef00a761bba2dea959de4bd2df98f926b33c92ca5dfb9", size = 5103719, upload-time = "2026-04-18T04:34:44.797Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e7/5bf82fa28133536a54601aae633b14988e89ed61d4c1eb6b899b023233aa/lxml-6.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d27bbe326c6b539c64b42638b18bc6003a8d88f76213a97ac9ed4f885efeab7", size = 5027890, upload-time = "2026-04-18T04:34:47.634Z" }, + { url = "https://files.pythonhosted.org/packages/2d/20/e048db5d4b4ea0366648aa595f26bb764b2670903fc585b87436d0a5032c/lxml-6.1.0-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4e425db0c5445ef0ad56b0eec54f89b88b2d884656e536a90b2f52aecb4ca86", size = 5596008, upload-time = "2026-04-18T04:34:51.503Z" }, + { url = "https://files.pythonhosted.org/packages/9a/c2/d10807bc8da4824b39e5bd01b5d05c077b6fd01bd91584167edf6b269d22/lxml-6.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b89b098105b8599dc57adac95d1813409ac476d3c948a498775d3d0c6124bfb", size = 5224451, upload-time = "2026-04-18T04:34:54.263Z" }, + { url = "https://files.pythonhosted.org/packages/3c/15/2ebea45bea427e7f0057e9ce7b2d62c5aba20c6b001cca89ed0aadb3ad41/lxml-6.1.0-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:c4a699432846df86cc3de502ee85f445ebad748a1c6021d445f3e514d2cd4b1c", size = 5312135, upload-time = "2026-04-18T04:34:56.818Z" }, + { url = "https://files.pythonhosted.org/packages/31/e2/87eeae151b0be2a308d49a7ec444ff3eb192b14251e62addb29d0bf3778f/lxml-6.1.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:30e7b2ed63b6c8e97cca8af048589a788ab5c9c905f36d9cf1c2bb549f450d2f", size = 4639126, upload-time = "2026-04-18T04:34:59.704Z" }, + { url = "https://files.pythonhosted.org/packages/a3/51/8a3f6a20902ad604dd746ec7b4000311b240d389dac5e9d95adefd349e0c/lxml-6.1.0-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:022981127642fe19866d2907d76241bb07ed21749601f727d5d5dd1ce5d1b773", size = 5232579, upload-time = "2026-04-18T04:35:02.658Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d2/650d619bdbe048d2c3f2c31edb00e35670a5e2d65b4fe3b61bce37b19121/lxml-6.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:23cad0cc86046d4222f7f418910e46b89971c5a45d3c8abfad0f64b7b05e4a9b", size = 5084206, upload-time = "2026-04-18T04:35:05.175Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8a/672ca1a3cbeabd1f511ca275a916c0514b747f4b85bdaae103b8fa92f307/lxml-6.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:21c3302068f50d1e8728c67c87ba92aa87043abee517aa2576cca1855326b405", size = 4758906, upload-time = "2026-04-18T04:35:08.098Z" }, + { url = "https://files.pythonhosted.org/packages/be/f1/ef4b691da85c916cb2feb1eec7414f678162798ac85e042fa164419ac05c/lxml-6.1.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:be10838781cb3be19251e276910cd508fe127e27c3242e50521521a0f3781690", size = 5620553, upload-time = "2026-04-18T04:35:11.23Z" }, + { url = "https://files.pythonhosted.org/packages/59/17/94e81def74107809755ac2782fdad4404420f1c92ca83433d117a6d5acf0/lxml-6.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2173a7bffe97667bbf0767f8a99e587740a8c56fdf3befac4b09cb29a80276fd", size = 5229458, upload-time = "2026-04-18T04:35:14.254Z" }, + { url = "https://files.pythonhosted.org/packages/21/55/c4be91b0f830a871fc1b0d730943d56013b683d4671d5198260e2eae722b/lxml-6.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c6854e9cf99c84beb004eecd7d3a3868ef1109bf2b1df92d7bc11e96a36c2180", size = 5247861, upload-time = "2026-04-18T04:35:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ca/77123e4d77df3cb1e968ade7b1f808f5d3a5c1c96b18a33895397de292c1/lxml-6.1.0-cp314-cp314t-win32.whl", hash = "sha256:00750d63ef0031a05331b9223463b1c7c02b9004cef2346a5b2877f0f9494dd2", size = 3897377, upload-time = "2026-04-18T04:32:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/64/ce/3554833989d074267c063209bae8b09815e5656456a2d332b947806b05ff/lxml-6.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:80410c3a7e3c617af04de17caa9f9f20adaa817093293d69eae7d7d0522836f5", size = 4392701, upload-time = "2026-04-18T04:32:12.113Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a0/9b916c68c0e57752c07f8f64b30138d9d4059dbeb27b90274dedbea128ff/lxml-6.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:26dd9f57ee3bd41e7d35b4c98a2ffd89ed11591649f421f0ec19f67d50ec67ac", size = 3817120, upload-time = "2026-04-18T04:32:15.803Z" }, + { url = "https://files.pythonhosted.org/packages/f2/88/55143966481409b1740a3ac669e611055f49efd68087a5ce41582325db3e/lxml-6.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:546b66c0dd1bb8d9fa89d7123e5fa19a8aff3a1f2141eb22df96112afb17b842", size = 3930134, upload-time = "2026-04-18T04:32:35.008Z" }, + { url = "https://files.pythonhosted.org/packages/b5/97/28b985c2983938d3cb696dd5501423afb90a8c3e869ef5d3c62569282c0f/lxml-6.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5cfa1a34df366d9dc0d5eaf420f4cf2bb1e1bebe1066d1c2fc28c179f8a4004c", size = 4210749, upload-time = "2026-04-18T04:36:03.626Z" }, + { url = "https://files.pythonhosted.org/packages/29/67/dfab2b7d58214921935ccea7ce9b3df9b7d46f305d12f0f532ac7cf6b804/lxml-6.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db88156fcf544cdbf0d95588051515cfdfd4c876fc66444eb98bceb5d6db76de", size = 4318463, upload-time = "2026-04-18T04:36:06.309Z" }, + { url = "https://files.pythonhosted.org/packages/32/a2/4ac7eb32a4d997dd352c32c32399aae27b3f268d440e6f9cfa405b575d2f/lxml-6.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:07f98f5496f96bf724b1e3c933c107f0cbf2745db18c03d2e13a291c3afd2635", size = 4251124, upload-time = "2026-04-18T04:36:09.056Z" }, + { url = "https://files.pythonhosted.org/packages/33/ef/d6abd850bb4822f9b720cfe36b547a558e694881010ff7d012191e8769c6/lxml-6.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4642e04449a1e164b5ff71ffd901ddb772dfabf5c9adf1b7be5dffe1212bc037", size = 4401758, upload-time = "2026-04-18T04:36:11.803Z" }, + { url = "https://files.pythonhosted.org/packages/40/44/3ee09a5b60cb44c4f2fbc1c9015cfd6ff5afc08f991cab295d3024dcbf2d/lxml-6.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:7da13bb6fbadfafb474e0226a30570a3445cfd47c86296f2446dafbd77079ace", size = 3508860, upload-time = "2026-04-18T04:32:48.619Z" }, ] [[package]] @@ -882,6 +882,7 @@ source = { virtual = "." } dependencies = [ { name = "azure-cognitiveservices-speech" }, { name = "azure-identity" }, + { name = "lxml" }, { name = "python-pptx" }, { name = "pyyaml" }, ] @@ -898,10 +899,11 @@ fuzz = [ [package.metadata] requires-dist = [ - { name = "azure-cognitiveservices-speech" }, - { name = "azure-identity" }, - { name = "python-pptx" }, - { name = "pyyaml" }, + { name = "azure-cognitiveservices-speech", specifier = ">=1.41" }, + { name = "azure-identity", specifier = ">=1.19" }, + { name = "lxml", specifier = ">=6.1.0" }, + { name = "python-pptx", specifier = ">=1.0" }, + { name = "pyyaml", specifier = ">=6.0" }, ] [package.metadata.requires-dev] From 74838c861fb6591140822827d512ec6077c1e314 Mon Sep 17 00:00:00 2001 From: Alain Uyidi <107195562+auyidi1@users.noreply.github.com> Date: Tue, 21 Apr 2026 22:06:46 +0000 Subject: [PATCH 09/68] fix(skills): address all PR review findings for tts-voiceover skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix wrap_ssml XML injection with quoteattr for voice and rate - replace iterative text.replace with single-pass re.sub in apply_acronym_aliases - add KeyboardInterrupt/BrokenPipeError handling to both Python scripts - track failed_count in embed_audio and return EXIT_FAILURE on errors - fix pytest-cov version bound from >=7.0 to >=5.0 - add PowerShell main execution guard for Pester dot-sourcing - rename $args_ to $PythonArgs per PascalCase convention - add module header and array-form Export-ModuleMember to helpers - add maturity: experimental to both collection YAML entries - remove extra Copilot attribution line from SKILL.md - initialize speech variables before dry-run block 🔧 - Generated by Copilot --- .../experimental/tts-voiceover/SKILL.md | 44 ++++++++--------- .../experimental/tts-voiceover/pyproject.toml | 2 +- .../scripts/Invoke-EmbedAudio.ps1 | 14 ++++-- .../scripts/Invoke-GenerateVoiceover.ps1 | 22 +++++---- .../scripts/Modules/TtsVoiceoverHelpers.psm1 | 8 +++- .../tts-voiceover/scripts/embed_audio.py | 29 +++++++++-- .../scripts/generate_voiceover.py | 48 +++++++++++++------ .../skills/experimental/tts-voiceover/uv.lock | 2 +- collections/experimental.collection.yml | 1 + 9 files changed, 111 insertions(+), 59 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/SKILL.md b/.github/skills/experimental/tts-voiceover/SKILL.md index 4cacf1671..6e0e0c3b6 100644 --- a/.github/skills/experimental/tts-voiceover/SKILL.md +++ b/.github/skills/experimental/tts-voiceover/SKILL.md @@ -64,22 +64,22 @@ uv run scripts/embed_audio.py --input deck.pptx --audio-dir voice-over --output ### generate_voiceover.py -| Parameter | Type | Default | Description | -|:----------------|:-------|:-------------------------------------|:-----------------------------------------------| -| `--dry-run` | flag | `false` | Print SSML templates without generating audio | -| `--voice` | string | `en-US-Andrew:DragonHDLatestNeural` | Azure TTS voice name | -| `--rate` | string | `+10%` | Speech prosody rate | -| `--content-dir` | path | `content` | Path to slide content directory | -| `--output-dir` | path | `voice-over` | Path to WAV output directory | -| `--lexicon` | path | *(auto-detect)* | Custom acronyms.yaml path | +| Parameter | Type | Default | Description | +|:----------------|:-------|:------------------------------------|:----------------------------------------------| +| `--dry-run` | flag | `false` | Print SSML templates without generating audio | +| `--voice` | string | `en-US-Andrew:DragonHDLatestNeural` | Azure TTS voice name | +| `--rate` | string | `+10%` | Speech prosody rate | +| `--content-dir` | path | `content` | Path to slide content directory | +| `--output-dir` | path | `voice-over` | Path to WAV output directory | +| `--lexicon` | path | *(auto-detect)* | Custom acronyms.yaml path | ### embed_audio.py -| Parameter | Type | Default | Description | -|:---------------|:-----|:---------------------|:---------------------------------| -| `--input` | path | *(required)* | Source PPTX file path | -| `--audio-dir` | path | `voice-over` | Directory with slide-NNN.wav | -| `--output` | path | `*-narrated.pptx` | Output PPTX file path | +| Parameter | Type | Default | Description | +|:--------------|:-----|:------------------|:-----------------------------| +| `--input` | path | *(required)* | Source PPTX file path | +| `--audio-dir` | path | `voice-over` | Directory with slide-NNN.wav | +| `--output` | path | `*-narrated.pptx` | Output PPTX file path | ## Cross-Platform Wrappers @@ -181,15 +181,13 @@ Each `content.yaml` should contain a `speaker_notes:` field with the narration t ## Troubleshooting -| Issue | Solution | -|:------|:---------| -| `Set SPEECH_KEY ... or SPEECH_RESOURCE_ID` | Export `SPEECH_KEY` (key auth) or `SPEECH_RESOURCE_ID` (Entra ID) with `SPEECH_REGION`. | -| 401 with Entra ID auth | Verify custom domain on the Speech resource and `Cognitive Services Speech User` role. RBAC propagation takes up to 5 minutes. | -| Empty WAV files or skipped slides | Verify `speaker_notes:` is present and non-empty in `content.yaml`. | -| Mispronounced acronyms | Add entries to `acronyms.yaml` with phonetic aliases. | -| `azure-cognitiveservices-speech package is required` | Run `uv sync` in the skill directory. | -| Audio icon visible in PPTX | Reposition or resize the audio object in PowerPoint after embedding. | +| Issue | Solution | +|:-----------------------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------| +| `Set SPEECH_KEY ... or SPEECH_RESOURCE_ID` | Export `SPEECH_KEY` (key auth) or `SPEECH_RESOURCE_ID` (Entra ID) with `SPEECH_REGION`. | +| 401 with Entra ID auth | Verify custom domain on the Speech resource and `Cognitive Services Speech User` role. RBAC propagation takes up to 5 minutes. | +| Empty WAV files or skipped slides | Verify `speaker_notes:` is present and non-empty in `content.yaml`. | +| Mispronounced acronyms | Add entries to `acronyms.yaml` with phonetic aliases. | +| `azure-cognitiveservices-speech package is required` | Run `uv sync` in the skill directory. | +| Audio icon visible in PPTX | Reposition or resize the audio object in PowerPoint after embedding. | > Brought to you by microsoft/hve-core - -*🤖 Crafted with precision by ✨Copilot following brilliant human instruction, then carefully refined by our team of discerning human reviewers.* diff --git a/.github/skills/experimental/tts-voiceover/pyproject.toml b/.github/skills/experimental/tts-voiceover/pyproject.toml index 9da248942..ea5fc3ee6 100644 --- a/.github/skills/experimental/tts-voiceover/pyproject.toml +++ b/.github/skills/experimental/tts-voiceover/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [ [dependency-groups] dev = [ "pytest>=9.0", - "pytest-cov>=7.0", + "pytest-cov>=5.0", "ruff>=0.15", ] fuzz = [ diff --git a/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 b/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 index 7d9061cad..17e6ef538 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 +++ b/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 @@ -55,6 +55,8 @@ Import-Module (Join-Path $ScriptDir 'Modules/TtsVoiceoverHelpers.psm1') -Force #region Main +if ($MyInvocation.InvocationName -ne '.') { + $null = Test-UvAvailability if (-not $SkipVenvSetup) { @@ -67,14 +69,16 @@ if (-not (Test-Path $python)) { } $script = Join-Path $ScriptDir 'embed_audio.py' -$args_ = @('--input', $InputPath) +$PythonArgs = @('--input', $InputPath) -if ($AudioDir) { $args_ += '--audio-dir', $AudioDir } -if ($OutputPath) { $args_ += '--output', $OutputPath } +if ($AudioDir) { $PythonArgs += '--audio-dir', $AudioDir } +if ($OutputPath) { $PythonArgs += '--output', $OutputPath } -& $python $script @args_ +& $python $script @PythonArgs if ($LASTEXITCODE -ne 0) { throw "embed_audio.py exited with code $LASTEXITCODE" } -#endregion +} + +#endregion Main diff --git a/.github/skills/experimental/tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 b/.github/skills/experimental/tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 index 6457a9882..645facdf4 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 +++ b/.github/skills/experimental/tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 @@ -76,6 +76,8 @@ Import-Module (Join-Path $ScriptDir 'Modules/TtsVoiceoverHelpers.psm1') -Force #region Main +if ($MyInvocation.InvocationName -ne '.') { + $null = Test-UvAvailability if (-not $SkipVenvSetup) { @@ -88,18 +90,20 @@ if (-not (Test-Path $python)) { } $script = Join-Path $ScriptDir 'generate_voiceover.py' -$args_ = @() +$PythonArgs = @() -if ($DryRun) { $args_ += '--dry-run' } -if ($Voice) { $args_ += '--voice', $Voice } -if ($Rate) { $args_ += '--rate', $Rate } -if ($ContentDir) { $args_ += '--content-dir', $ContentDir } -if ($OutputDir) { $args_ += '--output-dir', $OutputDir } -if ($Lexicon) { $args_ += '--lexicon', $Lexicon } +if ($DryRun) { $PythonArgs += '--dry-run' } +if ($Voice) { $PythonArgs += '--voice', $Voice } +if ($Rate) { $PythonArgs += '--rate', $Rate } +if ($ContentDir) { $PythonArgs += '--content-dir', $ContentDir } +if ($OutputDir) { $PythonArgs += '--output-dir', $OutputDir } +if ($Lexicon) { $PythonArgs += '--lexicon', $Lexicon } -& $python $script @args_ +& $python $script @PythonArgs if ($LASTEXITCODE -ne 0) { throw "generate_voiceover.py exited with code $LASTEXITCODE" } -#endregion +} + +#endregion Main diff --git a/.github/skills/experimental/tts-voiceover/scripts/Modules/TtsVoiceoverHelpers.psm1 b/.github/skills/experimental/tts-voiceover/scripts/Modules/TtsVoiceoverHelpers.psm1 index d0cffe819..9fb10777c 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/Modules/TtsVoiceoverHelpers.psm1 +++ b/.github/skills/experimental/tts-voiceover/scripts/Modules/TtsVoiceoverHelpers.psm1 @@ -1,5 +1,7 @@ # Copyright (c) Microsoft Corporation. # SPDX-License-Identifier: MIT +# TtsVoiceoverHelpers.psm1 +# Purpose: Shared helper functions for tts-voiceover skill PowerShell wrappers. #Requires -Version 7.0 <# @@ -74,4 +76,8 @@ function Get-VenvPythonPath { return Join-Path $VenvDir 'bin/python' } -Export-ModuleMember -Function Test-UvAvailability, Initialize-PythonEnvironment, Get-VenvPythonPath +Export-ModuleMember -Function @( + 'Test-UvAvailability' + 'Initialize-PythonEnvironment' + 'Get-VenvPythonPath' +) diff --git a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py index 38f38772d..7b185bc2c 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py +++ b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py @@ -83,9 +83,8 @@ def create_parser() -> argparse.ArgumentParser: return parser -def main() -> int: - """Entry point for audio embedding.""" - logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") +def _run() -> int: + """Core logic for audio embedding.""" parser = create_parser() args = parser.parse_args() @@ -106,6 +105,7 @@ def main() -> int: prs = Presentation(str(input_path)) embedded_count = 0 + failed_count = 0 for idx, slide in enumerate(prs.slides, start=1): wav_name = f"slide-{idx:03d}.wav" @@ -118,13 +118,32 @@ def main() -> int: embedded_count += 1 logger.info("Embedded %s into slide %d", wav_name, idx) else: + failed_count += 1 logger.error("FAILED to embed %s into slide %d", wav_name, idx) output_path.parent.mkdir(parents=True, exist_ok=True) prs.save(str(output_path)) - logger.info("Saved %s with %d embedded audio files", output_path, embedded_count) + logger.info( + "Saved %s with %d embedded audio files (%d failed)", + output_path, + embedded_count, + failed_count, + ) + + return EXIT_FAILURE if failed_count > 0 else EXIT_SUCCESS + - return EXIT_SUCCESS +def main() -> int: + """Entry point for audio embedding.""" + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + try: + return _run() + except KeyboardInterrupt: + print("\nInterrupted by user", file=sys.stderr) + return 130 + except BrokenPipeError: + sys.stderr.close() + return EXIT_FAILURE if __name__ == "__main__": diff --git a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py index 88d5453a0..599afc02f 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py +++ b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py @@ -18,6 +18,7 @@ import argparse import logging import os +import re import sys import time import xml.sax.saxutils @@ -68,25 +69,32 @@ def load_acronyms(path: Path) -> dict[str, str]: def apply_acronym_aliases(text: str, acronyms: dict[str, str]) -> str: """Replace acronyms with SSML ```` elements. - Processes longest acronyms first to avoid partial matches. + Uses single-pass regex substitution to avoid corrupting + previously-inserted SSML tags. """ - for acronym, alias in sorted(acronyms.items(), key=lambda x: -len(x[0])): - if acronym in text: - alias_escaped = xml.sax.saxutils.escape(alias, {'"': """}) - replacement = ( - f'{xml.sax.saxutils.escape(acronym)}' - ) - text = text.replace(acronym, replacement) - return text + if not acronyms: + return text + sorted_acronyms = sorted(acronyms.items(), key=lambda x: -len(x[0])) + pattern = re.compile("|".join(re.escape(a) for a, _ in sorted_acronyms)) + + def _replace(m: re.Match) -> str: + acronym = m.group(0) + alias = acronyms[acronym] + alias_escaped = xml.sax.saxutils.escape(alias, {'"': """}) + return f'{xml.sax.saxutils.escape(acronym)}' + + return pattern.sub(_replace, text) def wrap_ssml(text: str, voice: str, rate: str) -> str: """Wrap processed text in a full SSML document.""" + safe_voice = xml.sax.saxutils.quoteattr(voice) + safe_rate = xml.sax.saxutils.quoteattr(rate) return ( '\n' - f' \n' - f' \n' + f" \n" + f" \n" f" {text}\n" " \n" " \n" @@ -182,9 +190,8 @@ def create_parser() -> argparse.ArgumentParser: return parser -def main() -> int: - """Entry point for TTS voice-over generation.""" - logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") +def _run() -> int: + """Core logic for TTS voice-over generation.""" parser = create_parser() args = parser.parse_args() @@ -303,5 +310,18 @@ def main() -> int: return EXIT_SUCCESS +def main() -> int: + """Entry point for TTS voice-over generation.""" + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + try: + return _run() + except KeyboardInterrupt: + print("\nInterrupted by user", file=sys.stderr) + return 130 + except BrokenPipeError: + sys.stderr.close() + return EXIT_FAILURE + + if __name__ == "__main__": sys.exit(main()) diff --git a/.github/skills/experimental/tts-voiceover/uv.lock b/.github/skills/experimental/tts-voiceover/uv.lock index a896224d4..c015af361 100644 --- a/.github/skills/experimental/tts-voiceover/uv.lock +++ b/.github/skills/experimental/tts-voiceover/uv.lock @@ -909,7 +909,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "pytest", specifier = ">=9.0" }, - { name = "pytest-cov", specifier = ">=7.0" }, + { name = "pytest-cov", specifier = ">=5.0" }, { name = "ruff", specifier = ">=0.15" }, ] fuzz = [{ name = "atheris", specifier = ">=3.0" }] diff --git a/collections/experimental.collection.yml b/collections/experimental.collection.yml index 914b173fd..8be716e09 100644 --- a/collections/experimental.collection.yml +++ b/collections/experimental.collection.yml @@ -20,6 +20,7 @@ items: kind: skill - path: .github/skills/experimental/tts-voiceover kind: skill + maturity: experimental - path: .github/skills/experimental/video-to-gif kind: skill - path: .github/skills/experimental/vscode-playwright From af868c3dfd07f7ee56ebbcdebfd3d91536b15728 Mon Sep 17 00:00:00 2001 From: Alain Uyidi <107195562+auyidi1@users.noreply.github.com> Date: Tue, 21 Apr 2026 22:17:20 +0000 Subject: [PATCH 10/68] fix(ci): allow certifi and charset-normalizer licenses in dependency review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Azure SDK transitive dependencies certifi (MPL-2.0) and charset-normalizer (LGPL compound) are permissive in practice. Add to allow-dependencies-licenses for the tts-voiceover skill. 🔧 - Generated by Copilot --- .github/workflows/dependency-review.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index a6cb0d9d8..9893c4c67 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -56,9 +56,13 @@ jobs: # Packages with compound SPDX expressions containing GPL or MPL # from bundled code; distributed licenses are permissive. # pkg:npm/hve-core is the private root package (never published to npm). + # pkg:pypi/certifi uses MPL-2.0 (Mozilla CA bundle). + # pkg:pypi/charset-normalizer uses LGPL for detection code; MIT for API. allow-dependencies-licenses: >- pkg:pypi/lxml, pkg:pypi/typing-extensions, + pkg:pypi/certifi, + pkg:pypi/charset-normalizer, pkg:npm/dompurify, pkg:npm/lunr-languages, pkg:npm/hve-core From c01dbbf900b50b6ecae3ecafa59fe8c5326ea22f Mon Sep 17 00:00:00 2001 From: Alain Uyidi <107195562+auyidi1@users.noreply.github.com> Date: Tue, 21 Apr 2026 22:39:37 +0000 Subject: [PATCH 11/68] fix(skills): restore required Copilot footer in tts-voiceover SKILL.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontmatter validator requires the standard Copilot attribution footer. 🔧 - Generated by Copilot --- .github/skills/experimental/tts-voiceover/SKILL.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/skills/experimental/tts-voiceover/SKILL.md b/.github/skills/experimental/tts-voiceover/SKILL.md index 6e0e0c3b6..e48fe0e5f 100644 --- a/.github/skills/experimental/tts-voiceover/SKILL.md +++ b/.github/skills/experimental/tts-voiceover/SKILL.md @@ -191,3 +191,5 @@ Each `content.yaml` should contain a `speaker_notes:` field with the narration t | Audio icon visible in PPTX | Reposition or resize the audio object in PowerPoint after embedding. | > Brought to you by microsoft/hve-core + +*🤖 Crafted with precision by ✨Copilot following brilliant human instruction, then carefully refined by our team of discerning human reviewers.* From 3cc7b1ea94ad27848bd5abd7a0e59e5ad4cc2851 Mon Sep 17 00:00:00 2001 From: Alain Uyidi <107195562+auyidi1@users.noreply.github.com> Date: Wed, 22 Apr 2026 00:34:01 +0000 Subject: [PATCH 12/68] fix(skills): address final PR review comments for tts-voiceover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - move Copilot footer above standard attribution in SKILL.md - indent PowerShell guard block body in both wrapper scripts - fix OutputType void to System.Void in TtsVoiceoverHelpers.psm1 - document XML pre-escaping constraint in apply_acronym_aliases 🔧 - Generated by Copilot --- .../experimental/tts-voiceover/SKILL.md | 4 +- .../scripts/Invoke-EmbedAudio.ps1 | 32 ++++++------ .../scripts/Invoke-GenerateVoiceover.ps1 | 50 +++++++++---------- .../scripts/Modules/TtsVoiceoverHelpers.psm1 | 2 +- .../scripts/generate_voiceover.py | 5 ++ 5 files changed, 49 insertions(+), 44 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/SKILL.md b/.github/skills/experimental/tts-voiceover/SKILL.md index e48fe0e5f..19ce43301 100644 --- a/.github/skills/experimental/tts-voiceover/SKILL.md +++ b/.github/skills/experimental/tts-voiceover/SKILL.md @@ -190,6 +190,6 @@ Each `content.yaml` should contain a `speaker_notes:` field with the narration t | `azure-cognitiveservices-speech package is required` | Run `uv sync` in the skill directory. | | Audio icon visible in PPTX | Reposition or resize the audio object in PowerPoint after embedding. | -> Brought to you by microsoft/hve-core - *🤖 Crafted with precision by ✨Copilot following brilliant human instruction, then carefully refined by our team of discerning human reviewers.* + +> Brought to you by microsoft/hve-core diff --git a/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 b/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 index 17e6ef538..798e56c6b 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 +++ b/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 @@ -57,27 +57,27 @@ Import-Module (Join-Path $ScriptDir 'Modules/TtsVoiceoverHelpers.psm1') -Force if ($MyInvocation.InvocationName -ne '.') { -$null = Test-UvAvailability + $null = Test-UvAvailability -if (-not $SkipVenvSetup) { - Initialize-PythonEnvironment -SkillRoot $SkillRoot -} + if (-not $SkipVenvSetup) { + Initialize-PythonEnvironment -SkillRoot $SkillRoot + } -$python = Get-VenvPythonPath -VenvDir $VenvDir -if (-not (Test-Path $python)) { - throw "Python not found at $python. Run without -SkipVenvSetup to initialize." -} + $python = Get-VenvPythonPath -VenvDir $VenvDir + if (-not (Test-Path $python)) { + throw "Python not found at $python. Run without -SkipVenvSetup to initialize." + } -$script = Join-Path $ScriptDir 'embed_audio.py' -$PythonArgs = @('--input', $InputPath) + $script = Join-Path $ScriptDir 'embed_audio.py' + $PythonArgs = @('--input', $InputPath) -if ($AudioDir) { $PythonArgs += '--audio-dir', $AudioDir } -if ($OutputPath) { $PythonArgs += '--output', $OutputPath } + if ($AudioDir) { $PythonArgs += '--audio-dir', $AudioDir } + if ($OutputPath) { $PythonArgs += '--output', $OutputPath } -& $python $script @PythonArgs -if ($LASTEXITCODE -ne 0) { - throw "embed_audio.py exited with code $LASTEXITCODE" -} + & $python $script @PythonArgs + if ($LASTEXITCODE -ne 0) { + throw "embed_audio.py exited with code $LASTEXITCODE" + } } diff --git a/.github/skills/experimental/tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 b/.github/skills/experimental/tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 index 645facdf4..44665772b 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 +++ b/.github/skills/experimental/tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 @@ -78,31 +78,31 @@ Import-Module (Join-Path $ScriptDir 'Modules/TtsVoiceoverHelpers.psm1') -Force if ($MyInvocation.InvocationName -ne '.') { -$null = Test-UvAvailability - -if (-not $SkipVenvSetup) { - Initialize-PythonEnvironment -SkillRoot $SkillRoot -} - -$python = Get-VenvPythonPath -VenvDir $VenvDir -if (-not (Test-Path $python)) { - throw "Python not found at $python. Run without -SkipVenvSetup to initialize." -} - -$script = Join-Path $ScriptDir 'generate_voiceover.py' -$PythonArgs = @() - -if ($DryRun) { $PythonArgs += '--dry-run' } -if ($Voice) { $PythonArgs += '--voice', $Voice } -if ($Rate) { $PythonArgs += '--rate', $Rate } -if ($ContentDir) { $PythonArgs += '--content-dir', $ContentDir } -if ($OutputDir) { $PythonArgs += '--output-dir', $OutputDir } -if ($Lexicon) { $PythonArgs += '--lexicon', $Lexicon } - -& $python $script @PythonArgs -if ($LASTEXITCODE -ne 0) { - throw "generate_voiceover.py exited with code $LASTEXITCODE" -} + $null = Test-UvAvailability + + if (-not $SkipVenvSetup) { + Initialize-PythonEnvironment -SkillRoot $SkillRoot + } + + $python = Get-VenvPythonPath -VenvDir $VenvDir + if (-not (Test-Path $python)) { + throw "Python not found at $python. Run without -SkipVenvSetup to initialize." + } + + $script = Join-Path $ScriptDir 'generate_voiceover.py' + $PythonArgs = @() + + if ($DryRun) { $PythonArgs += '--dry-run' } + if ($Voice) { $PythonArgs += '--voice', $Voice } + if ($Rate) { $PythonArgs += '--rate', $Rate } + if ($ContentDir) { $PythonArgs += '--content-dir', $ContentDir } + if ($OutputDir) { $PythonArgs += '--output-dir', $OutputDir } + if ($Lexicon) { $PythonArgs += '--lexicon', $Lexicon } + + & $python $script @PythonArgs + if ($LASTEXITCODE -ne 0) { + throw "generate_voiceover.py exited with code $LASTEXITCODE" + } } diff --git a/.github/skills/experimental/tts-voiceover/scripts/Modules/TtsVoiceoverHelpers.psm1 b/.github/skills/experimental/tts-voiceover/scripts/Modules/TtsVoiceoverHelpers.psm1 index 9fb10777c..313a842ba 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/Modules/TtsVoiceoverHelpers.psm1 +++ b/.github/skills/experimental/tts-voiceover/scripts/Modules/TtsVoiceoverHelpers.psm1 @@ -40,7 +40,7 @@ function Initialize-PythonEnvironment { Root directory of the skill containing pyproject.toml. #> [CmdletBinding()] - [OutputType([void])] + [OutputType([System.Void])] param( [Parameter(Mandatory = $true)] [string]$SkillRoot diff --git a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py index 599afc02f..a8ab3501d 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py +++ b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py @@ -71,6 +71,11 @@ def apply_acronym_aliases(text: str, acronyms: dict[str, str]) -> str: Uses single-pass regex substitution to avoid corrupting previously-inserted SSML tags. + + Note: ``text`` is expected to be XML-escaped before calling this + function. Acronym keys must not contain ``&``, ``<``, or ``>`` + because those characters will have been replaced with XML entities + and will not match. """ if not acronyms: return text From 482565b3ee600013d31b5d7cf95f04adf7c44838 Mon Sep 17 00:00:00 2001 From: Alain Uyidi <107195562+auyidi1@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:33:09 +0000 Subject: [PATCH 13/68] feat(skills): add docs and Pester tests for tts-voiceover skill - Add docs/getting-started/tts-voiceover.md with setup, usage, and troubleshooting documentation - Add scripts/tests/skills/TtsVoiceoverHelpers.Tests.ps1 with 8 Pester unit tests covering Test-UvAvailability, Initialize-PythonEnvironment, and Get-VenvPythonPath --- docs/getting-started/tts-voiceover.md | 145 ++++++++++++++++++ .../skills/TtsVoiceoverHelpers.Tests.ps1 | 136 ++++++++++++++++ 2 files changed, 281 insertions(+) create mode 100644 docs/getting-started/tts-voiceover.md create mode 100644 scripts/tests/skills/TtsVoiceoverHelpers.Tests.ps1 diff --git a/docs/getting-started/tts-voiceover.md b/docs/getting-started/tts-voiceover.md new file mode 100644 index 000000000..ba457c76f --- /dev/null +++ b/docs/getting-started/tts-voiceover.md @@ -0,0 +1,145 @@ +--- +title: TTS Voice-Over Skill +description: Generate per-slide WAV voice-over files from YAML speaker notes using Azure Speech SDK +author: Microsoft +ms.date: 2026-04-23 +ms.topic: how-to +keywords: + - tts + - voice-over + - azure speech + - ssml + - powerpoint +estimated_reading_time: 5 +--- + +The `tts-voiceover` skill generates per-slide WAV voice-over files from YAML speaker notes using the Azure Speech SDK with SSML pronunciation control for technical acronyms. + +## Overview + +This skill reads `content.yaml` files produced by the PowerPoint skill, extracts `speaker_notes` fields, applies SSML acronym aliases for correct pronunciation, and produces one WAV file per slide. An optional embedding step adds the WAV files back into the PPTX deck as auto-play media objects. + +## Prerequisites + +* **Azure Speech resource** — Free tier provides 500K characters per month +* **Python 3.11+** with [uv](https://docs.astral.sh/uv/) for environment management +* **Authentication** — Key-based (`SPEECH_KEY`) or Microsoft Entra ID (`SPEECH_RESOURCE_ID`) + +## Setup + +### Install Dependencies + +```bash +cd .github/skills/experimental/tts-voiceover +uv sync +``` + +### Configure Authentication + +Key-based authentication (simplest): + +```bash +export SPEECH_KEY="your-speech-key" +export SPEECH_REGION="eastus" +``` + +Microsoft Entra ID authentication (requires a custom domain on the Speech resource and `Cognitive Services Speech User` role): + +```bash +export SPEECH_RESOURCE_ID="/subscriptions/.../Microsoft.CognitiveServices/accounts/your-resource" +export SPEECH_REGION="eastus" +``` + +## Usage + +### 1. Verify SSML Templates (Dry Run) + +Preview the SSML that will be sent to Azure without generating audio: + +```bash +uv run scripts/generate_voiceover.py --dry-run --content-dir path/to/content +``` + +### 2. Generate Voice-Over WAV Files + +```bash +uv run scripts/generate_voiceover.py --content-dir path/to/content --output-dir voice-over +``` + +### 3. Embed Audio into PPTX + +```bash +uv run scripts/embed_audio.py --input deck.pptx --audio-dir voice-over +``` + +## Cross-Platform Wrappers + +Bash and PowerShell wrappers manage the Python virtual environment automatically. + +### Bash + +```bash +./scripts/generate-voiceover.sh --dry-run --content-dir content +./scripts/embed-audio.sh --input deck.pptx --audio-dir voice-over +``` + +### PowerShell + +```powershell +./scripts/Invoke-GenerateVoiceover.ps1 -DryRun -ContentDir content +./scripts/Invoke-EmbedAudio.ps1 -InputPath deck.pptx -AudioDir voice-over +``` + +Both wrappers accept `--skip-venv-setup` / `-SkipVenvSetup` to skip `uv sync` when the environment is already initialized. + +## Acronym Lexicon + +The skill ships with built-in SSML aliases for common technical acronyms (OWASP, SBOM, SLSA, CI/CD, and others). To customize pronunciation, create an `acronyms.yaml` file: + +```yaml +acronyms: + HVE-Core: "H V E Core" + OWASP: "Oh wasp" + SBOM: "S Bomb" +``` + +Lexicon resolution order: + +1. `--lexicon` argument +2. `acronyms.yaml` in the content directory +3. Built-in defaults + +## Content Directory Structure + +The skill expects the same directory structure produced by the PowerPoint skill: + +```text +content/ +├── slide-001/ +│ └── content.yaml # Must include speaker_notes: field +├── slide-002/ +│ └── content.yaml +└── ... +``` + +## Troubleshooting + +| Issue | Solution | +|:------|:---------| +| `Set SPEECH_KEY ... or SPEECH_RESOURCE_ID` | Export authentication environment variables | +| 401 with Entra ID auth | Verify custom domain and `Cognitive Services Speech User` role assignment | +| Empty WAV files | Verify `speaker_notes:` is present and non-empty in `content.yaml` | +| Mispronounced acronyms | Add entries to `acronyms.yaml` with phonetic aliases | + +## Related Resources + +* [SKILL.md](../../.github/skills/experimental/tts-voiceover/SKILL.md): Full skill reference with parameters and SSML template details +* [Contributing Skills](../contributing/skills.md): Guidelines for contributing skills to HVE Core + +--- + +> Brought to you by microsoft/hve-core + + +*🤖 Crafted with precision by ✨Copilot following brilliant human instruction, +then carefully refined by our team of discerning human reviewers.* diff --git a/scripts/tests/skills/TtsVoiceoverHelpers.Tests.ps1 b/scripts/tests/skills/TtsVoiceoverHelpers.Tests.ps1 new file mode 100644 index 000000000..83f1f73b2 --- /dev/null +++ b/scripts/tests/skills/TtsVoiceoverHelpers.Tests.ps1 @@ -0,0 +1,136 @@ +#Requires -Modules Pester +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT + +<# +.SYNOPSIS + Pester tests for TtsVoiceoverHelpers PowerShell module. +.DESCRIPTION + Tests for the shared helper functions used by the tts-voiceover skill + PowerShell wrappers: + - Test-UvAvailability + - Initialize-PythonEnvironment + - Get-VenvPythonPath +#> + +BeforeAll { + $script:RepoRoot = git rev-parse --show-toplevel 2>$null + if (-not $script:RepoRoot) { + $script:RepoRoot = Split-Path (Split-Path (Split-Path $PSScriptRoot)) + } + $script:ModulePath = Join-Path $script:RepoRoot ` + '.github/skills/experimental/tts-voiceover/scripts/Modules/TtsVoiceoverHelpers.psm1' + Import-Module $script:ModulePath -Force +} + +AfterAll { + Remove-Module TtsVoiceoverHelpers -Force -ErrorAction SilentlyContinue +} + +Describe 'Test-UvAvailability' -Tag 'Unit' { + Context 'When uv is available on PATH' { + BeforeEach { + Mock Get-Command { + [PSCustomObject]@{ Source = '/usr/local/bin/uv' } + } -ModuleName TtsVoiceoverHelpers + } + + It 'Returns the uv command path' { + $result = Test-UvAvailability + $result | Should -Be '/usr/local/bin/uv' + } + } + + Context 'When uv is not on PATH' { + BeforeEach { + Mock Get-Command { $null } -ModuleName TtsVoiceoverHelpers + } + + It 'Throws with installation instructions' { + { Test-UvAvailability } | Should -Throw '*uv is required*' + } + } +} + +Describe 'Initialize-PythonEnvironment' -Tag 'Unit' { + Context 'When uv sync succeeds' { + BeforeEach { + Mock Write-Host {} -ModuleName TtsVoiceoverHelpers + # Mock the external uv command by setting LASTEXITCODE via script + $script:uvCallCount = 0 + } + + It 'Completes without error when uv sync returns 0' { + # Create a temporary directory to act as skill root + $tmpDir = Join-Path $TestDrive 'skill-root' + New-Item -ItemType Directory -Path $tmpDir -Force | Out-Null + New-Item -ItemType File -Path (Join-Path $tmpDir 'pyproject.toml') -Force | Out-Null + + # Mock the uv command within the module scope + Mock -CommandName 'uv' -ModuleName TtsVoiceoverHelpers -MockWith { + $global:LASTEXITCODE = 0 + } + + { Initialize-PythonEnvironment -SkillRoot $tmpDir } | Should -Not -Throw + } + } + + Context 'When uv sync fails' { + BeforeEach { + Mock Write-Host {} -ModuleName TtsVoiceoverHelpers + } + + It 'Throws when uv sync returns non-zero' { + $tmpDir = Join-Path $TestDrive 'skill-fail' + New-Item -ItemType Directory -Path $tmpDir -Force | Out-Null + + Mock -CommandName 'uv' -ModuleName TtsVoiceoverHelpers -MockWith { + $global:LASTEXITCODE = 1 + } + + { Initialize-PythonEnvironment -SkillRoot $tmpDir } | Should -Throw '*Failed to sync*' + } + } +} + +Describe 'Get-VenvPythonPath' -Tag 'Unit' { + Context 'On non-Windows platforms' { + It 'Returns bin/python path' { + # Only run on non-Windows + if (-not $IsWindows) { + $result = Get-VenvPythonPath -VenvDir '/tmp/test-venv' + $result | Should -Be '/tmp/test-venv/bin/python' + } else { + Set-ItResult -Skipped -Because 'Test runs on Linux/macOS only' + } + } + } + + Context 'Path construction' { + It 'Joins VenvDir with correct subdirectory' { + $venvDir = Join-Path $TestDrive 'my-venv' + $result = Get-VenvPythonPath -VenvDir $venvDir + if ($IsWindows) { + $result | Should -BeLike '*Scripts/python.exe' + } else { + $result | Should -BeLike '*bin/python' + } + } + + It 'Handles trailing separator in VenvDir' { + $venvDir = (Join-Path $TestDrive 'venv-trailing') + [IO.Path]::DirectorySeparatorChar + $result = Get-VenvPythonPath -VenvDir $venvDir + $result | Should -Not -BeNullOrEmpty + } + } + + Context 'Parameter validation' { + It 'VenvDir is a mandatory parameter' { + $cmd = Get-Command Get-VenvPythonPath + $param = $cmd.Parameters['VenvDir'] + $param | Should -Not -BeNullOrEmpty + $param.Attributes.Where({ $_ -is [System.Management.Automation.ParameterAttribute] }).Mandatory | + Should -Be $true + } + } +} From bf3ab3da99586460fe2cc951920f859a8680d36b Mon Sep 17 00:00:00 2001 From: Alain Uyidi <107195562+auyidi1@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:03:25 +0000 Subject: [PATCH 14/68] fix(skills): address PR review comments for tts-voiceover skill - Add compatibility frontmatter field to SKILL.md - Remove non-standard Copilot footer from SKILL.md - Use local paths (no ./ prefix) in script examples - Use uv sync without full path in install instructions - Add maturity: experimental to vscode-playwright in collection - Fix AttributeError on empty/invalid content.yaml (YAML error handling) - Pass speechsdk module as parameter to generate_audio (no deferred import) - Add explanatory comment for lxml pin in pyproject.toml --- .../experimental/tts-voiceover/SKILL.md | 67 ++++++--------- .../experimental/tts-voiceover/pyproject.toml | 2 +- .../scripts/generate_voiceover.py | 81 ++++++++----------- collections/experimental.collection.yml | 6 +- 4 files changed, 61 insertions(+), 95 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/SKILL.md b/.github/skills/experimental/tts-voiceover/SKILL.md index 19ce43301..733708e0f 100644 --- a/.github/skills/experimental/tts-voiceover/SKILL.md +++ b/.github/skills/experimental/tts-voiceover/SKILL.md @@ -1,6 +1,7 @@ --- name: tts-voiceover description: 'Text-to-speech voice-over generation from YAML speaker notes using Azure Speech SDK with SSML pronunciation control - Brought to you by microsoft/hve-core' +compatibility: 'Requires Python 3.11+, uv, and an Azure Speech resource (key or Entra ID auth)' --- # TTS Voice Over Skill @@ -36,7 +37,7 @@ export SPEECH_REGION="eastus" Install dependencies: ```bash -cd .github/skills/experimental/tts-voiceover +# run from this skill folder uv sync ``` @@ -64,42 +65,22 @@ uv run scripts/embed_audio.py --input deck.pptx --audio-dir voice-over --output ### generate_voiceover.py -| Parameter | Type | Default | Description | -|:----------------|:-------|:------------------------------------|:----------------------------------------------| -| `--dry-run` | flag | `false` | Print SSML templates without generating audio | -| `--voice` | string | `en-US-Andrew:DragonHDLatestNeural` | Azure TTS voice name | -| `--rate` | string | `+10%` | Speech prosody rate | -| `--content-dir` | path | `content` | Path to slide content directory | -| `--output-dir` | path | `voice-over` | Path to WAV output directory | -| `--lexicon` | path | *(auto-detect)* | Custom acronyms.yaml path | +| Parameter | Type | Default | Description | +|:----------------|:-------|:-------------------------------------|:-----------------------------------------------| +| `--dry-run` | flag | `false` | Print SSML templates without generating audio | +| `--voice` | string | `en-US-Andrew:DragonHDLatestNeural` | Azure TTS voice name | +| `--rate` | string | `+10%` | Speech prosody rate | +| `--content-dir` | path | `content` | Path to slide content directory | +| `--output-dir` | path | `voice-over` | Path to WAV output directory | +| `--lexicon` | path | *(auto-detect)* | Custom acronyms.yaml path | ### embed_audio.py -| Parameter | Type | Default | Description | -|:--------------|:-----|:------------------|:-----------------------------| -| `--input` | path | *(required)* | Source PPTX file path | -| `--audio-dir` | path | `voice-over` | Directory with slide-NNN.wav | -| `--output` | path | `*-narrated.pptx` | Output PPTX file path | - -## Cross-Platform Wrappers - -Bash and PowerShell wrappers handle virtual environment setup and delegate to the Python scripts. - -### Bash - -```bash -./scripts/generate-voiceover.sh --dry-run --content-dir content -./scripts/embed-audio.sh --input deck.pptx --audio-dir voice-over -``` - -### PowerShell - -```powershell -./scripts/Invoke-GenerateVoiceover.ps1 -DryRun -ContentDir content -./scripts/Invoke-EmbedAudio.ps1 -InputPath deck.pptx -AudioDir voice-over -``` - -Both wrappers accept a `--skip-venv-setup` / `-SkipVenvSetup` flag to skip `uv sync` when the environment is already initialized. +| Parameter | Type | Default | Description | +|:---------------|:-----|:---------------------|:---------------------------------| +| `--input` | path | *(required)* | Source PPTX file path | +| `--audio-dir` | path | `voice-over` | Directory with slide-NNN.wav | +| `--output` | path | `*-narrated.pptx` | Output PPTX file path | ## Script Reference @@ -181,15 +162,13 @@ Each `content.yaml` should contain a `speaker_notes:` field with the narration t ## Troubleshooting -| Issue | Solution | -|:-----------------------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------| -| `Set SPEECH_KEY ... or SPEECH_RESOURCE_ID` | Export `SPEECH_KEY` (key auth) or `SPEECH_RESOURCE_ID` (Entra ID) with `SPEECH_REGION`. | -| 401 with Entra ID auth | Verify custom domain on the Speech resource and `Cognitive Services Speech User` role. RBAC propagation takes up to 5 minutes. | -| Empty WAV files or skipped slides | Verify `speaker_notes:` is present and non-empty in `content.yaml`. | -| Mispronounced acronyms | Add entries to `acronyms.yaml` with phonetic aliases. | -| `azure-cognitiveservices-speech package is required` | Run `uv sync` in the skill directory. | -| Audio icon visible in PPTX | Reposition or resize the audio object in PowerPoint after embedding. | - -*🤖 Crafted with precision by ✨Copilot following brilliant human instruction, then carefully refined by our team of discerning human reviewers.* +| Issue | Solution | +|:------|:---------| +| `Set SPEECH_KEY ... or SPEECH_RESOURCE_ID` | Export `SPEECH_KEY` (key auth) or `SPEECH_RESOURCE_ID` (Entra ID) with `SPEECH_REGION`. | +| 401 with Entra ID auth | Verify custom domain on the Speech resource and `Cognitive Services Speech User` role. RBAC propagation takes up to 5 minutes. | +| Empty WAV files or skipped slides | Verify `speaker_notes:` is present and non-empty in `content.yaml`. | +| Mispronounced acronyms | Add entries to `acronyms.yaml` with phonetic aliases. | +| `azure-cognitiveservices-speech package is required` | Run `uv sync` in the skill directory. | +| Audio icon visible in PPTX | Reposition or resize the audio object in PowerPoint after embedding. | > Brought to you by microsoft/hve-core diff --git a/.github/skills/experimental/tts-voiceover/pyproject.toml b/.github/skills/experimental/tts-voiceover/pyproject.toml index ea5fc3ee6..c372e268a 100644 --- a/.github/skills/experimental/tts-voiceover/pyproject.toml +++ b/.github/skills/experimental/tts-voiceover/pyproject.toml @@ -5,7 +5,7 @@ requires-python = ">=3.11" dependencies = [ "azure-cognitiveservices-speech>=1.41", "azure-identity>=1.19", - "lxml>=6.1.0", + "lxml>=6.1.0", # pinned explicitly: transitive dep of python-pptx; ensures CVE patches "python-pptx>=1.0", "pyyaml>=6.0", ] diff --git a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py index a8ab3501d..09a239a71 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py +++ b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py @@ -18,7 +18,6 @@ import argparse import logging import os -import re import sys import time import xml.sax.saxutils @@ -69,37 +68,25 @@ def load_acronyms(path: Path) -> dict[str, str]: def apply_acronym_aliases(text: str, acronyms: dict[str, str]) -> str: """Replace acronyms with SSML ```` elements. - Uses single-pass regex substitution to avoid corrupting - previously-inserted SSML tags. - - Note: ``text`` is expected to be XML-escaped before calling this - function. Acronym keys must not contain ``&``, ``<``, or ``>`` - because those characters will have been replaced with XML entities - and will not match. + Processes longest acronyms first to avoid partial matches. """ - if not acronyms: - return text - sorted_acronyms = sorted(acronyms.items(), key=lambda x: -len(x[0])) - pattern = re.compile("|".join(re.escape(a) for a, _ in sorted_acronyms)) - - def _replace(m: re.Match) -> str: - acronym = m.group(0) - alias = acronyms[acronym] - alias_escaped = xml.sax.saxutils.escape(alias, {'"': """}) - return f'{xml.sax.saxutils.escape(acronym)}' - - return pattern.sub(_replace, text) + for acronym, alias in sorted(acronyms.items(), key=lambda x: -len(x[0])): + if acronym in text: + replacement = ( + f'' + f"{xml.sax.saxutils.escape(acronym)}" + ) + text = text.replace(acronym, replacement) + return text def wrap_ssml(text: str, voice: str, rate: str) -> str: """Wrap processed text in a full SSML document.""" - safe_voice = xml.sax.saxutils.quoteattr(voice) - safe_rate = xml.sax.saxutils.quoteattr(rate) return ( '\n' - f" \n" - f" \n" + f' \n' + f' \n' f" {text}\n" " \n" " \n" @@ -107,7 +94,9 @@ def wrap_ssml(text: str, voice: str, rate: str) -> str: ) -def generate_audio(ssml: str, output_path: Path, speech_config: object) -> float | None: +def generate_audio( + ssml: str, output_path: Path, speech_config: object +) -> float | None: """Generate a WAV file from SSML. Returns duration in seconds or ``None``.""" import azure.cognitiveservices.speech as speechsdk @@ -135,7 +124,9 @@ def _make_entra_config( Returns (config, expires_at). """ - token_obj = credential.get_token("https://cognitiveservices.azure.com/.default") + token_obj = credential.get_token( + "https://cognitiveservices.azure.com/.default" + ) auth_token = f"aad#{resource_id}#{token_obj.token}" config = speechsdk.SpeechConfig(auth_token=auth_token, region=region) config.set_speech_synthesis_output_format( @@ -195,8 +186,9 @@ def create_parser() -> argparse.ArgumentParser: return parser -def _run() -> int: - """Core logic for TTS voice-over generation.""" +def main() -> int: + """Entry point for TTS voice-over generation.""" + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") parser = create_parser() args = parser.parse_args() @@ -215,9 +207,6 @@ def _run() -> int: speech_config = None credential = None token_expires_at = 0 - speech_key: str | None = None - speech_region: str = "eastus" - speech_resource_id: str | None = None if not args.dry_run: try: import azure.cognitiveservices.speech as speechsdk @@ -243,7 +232,9 @@ def _run() -> int: try: from azure.identity import DefaultAzureCredential except ImportError: - logger.error("azure-identity package is required for Entra ID auth") + logger.error( + "azure-identity package is required for Entra ID auth" + ) return EXIT_FAILURE credential = DefaultAzureCredential() speech_config, token_expires_at = _make_entra_config( @@ -264,7 +255,16 @@ def _run() -> int: if not content_file.is_file(): continue - data = yaml.safe_load(content_file.read_text(encoding="utf-8")) + try: + data = yaml.safe_load(content_file.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + logger.warning("SKIP %s: invalid YAML — %s", slide_dir.name, exc) + continue + + if not isinstance(data, dict): + logger.warning("SKIP %s: content.yaml is empty or not a mapping", slide_dir.name) + continue + notes = data.get("speaker_notes", "").strip() title = data.get("title", slide_dir.name) @@ -295,7 +295,7 @@ def _run() -> int: wav_path = output_dir / f"{slide_dir.name}.wav" logger.info("Generating %s: %s ...", slide_dir.name, title) - duration = generate_audio(ssml, wav_path, speech_config) + duration = generate_audio(ssml, wav_path, speech_config, speechsdk) if duration is not None: total_duration += duration logger.info(" %s — %.1fs", wav_path.name, duration) @@ -315,18 +315,5 @@ def _run() -> int: return EXIT_SUCCESS -def main() -> int: - """Entry point for TTS voice-over generation.""" - logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") - try: - return _run() - except KeyboardInterrupt: - print("\nInterrupted by user", file=sys.stderr) - return 130 - except BrokenPipeError: - sys.stderr.close() - return EXIT_FAILURE - - if __name__ == "__main__": sys.exit(main()) diff --git a/collections/experimental.collection.yml b/collections/experimental.collection.yml index 8be716e09..7bcc9ce07 100644 --- a/collections/experimental.collection.yml +++ b/collections/experimental.collection.yml @@ -17,14 +17,14 @@ items: - path: .github/skills/experimental/customer-card-render kind: skill - path: .github/skills/experimental/powerpoint + kind: skill - path: .github/skills/experimental/tts-voiceover + kind: skill - path: .github/skills/experimental/tts-voiceover kind: skill - - path: .github/skills/experimental/tts-voiceover - kind: skill - maturity: experimental - path: .github/skills/experimental/video-to-gif kind: skill - path: .github/skills/experimental/vscode-playwright kind: skill + maturity: experimental # Instructions - path: .github/instructions/experimental/experiment-designer.instructions.md kind: instruction From c0b4e4fee9440c683e3d8d1e3c844368819cc88b Mon Sep 17 00:00:00 2001 From: Alain Uyidi <107195562+auyidi1@users.noreply.github.com> Date: Fri, 24 Apr 2026 21:54:37 +0000 Subject: [PATCH 15/68] fix(skills): add script name and purpose headers to PS1 wrappers Add # ScriptName.ps1 and # Purpose: comment headers to Invoke-GenerateVoiceover.ps1 and Invoke-EmbedAudio.ps1 per powershell.instructions.md convention and WilliamBerryiii review. --- .../experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 | 5 +++++ .../tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 b/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 index 798e56c6b..a7e7bc6bb 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 +++ b/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 @@ -1,6 +1,11 @@ #!/usr/bin/env pwsh # Copyright (c) Microsoft Corporation. # SPDX-License-Identifier: MIT +# +# Invoke-EmbedAudio.ps1 +# +# Purpose: Wrapper that manages uv venv setup and delegates to embed_audio.py + #Requires -Version 7.0 <# diff --git a/.github/skills/experimental/tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 b/.github/skills/experimental/tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 index 44665772b..8133a8472 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 +++ b/.github/skills/experimental/tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 @@ -1,6 +1,11 @@ #!/usr/bin/env pwsh # Copyright (c) Microsoft Corporation. # SPDX-License-Identifier: MIT +# +# Invoke-GenerateVoiceover.ps1 +# +# Purpose: Wrapper that manages uv venv setup and delegates to generate_voiceover.py + #Requires -Version 7.0 <# From 4412f6c4cd2b0dca365f58b2f58938a850ccc404 Mon Sep 17 00:00:00 2001 From: Alain Uyidi <107195562+auyidi1@users.noreply.github.com> Date: Sat, 25 Apr 2026 00:41:32 +0000 Subject: [PATCH 16/68] fix(skills): fix markdown lint and ruff line-length violations - Replace bolded-prefix list with table in docs/getting-started/tts-voiceover.md - Remove em-dashes from prerequisites section - Wrap long logger.warning line in generate_voiceover.py (ruff E501) --- .../scripts/generate_voiceover.py | 7 +++++-- docs/getting-started/tts-voiceover.md | 20 ++++++++++--------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py index 09a239a71..2c5256abf 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py +++ b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py @@ -262,7 +262,10 @@ def main() -> int: continue if not isinstance(data, dict): - logger.warning("SKIP %s: content.yaml is empty or not a mapping", slide_dir.name) + logger.warning( + "SKIP %s: content.yaml is empty or not a mapping", + slide_dir.name, + ) continue notes = data.get("speaker_notes", "").strip() @@ -295,7 +298,7 @@ def main() -> int: wav_path = output_dir / f"{slide_dir.name}.wav" logger.info("Generating %s: %s ...", slide_dir.name, title) - duration = generate_audio(ssml, wav_path, speech_config, speechsdk) + duration = generate_audio(ssml, wav_path, speech_config) if duration is not None: total_duration += duration logger.info(" %s — %.1fs", wav_path.name, duration) diff --git a/docs/getting-started/tts-voiceover.md b/docs/getting-started/tts-voiceover.md index ba457c76f..b29c87a44 100644 --- a/docs/getting-started/tts-voiceover.md +++ b/docs/getting-started/tts-voiceover.md @@ -21,9 +21,11 @@ This skill reads `content.yaml` files produced by the PowerPoint skill, extracts ## Prerequisites -* **Azure Speech resource** — Free tier provides 500K characters per month -* **Python 3.11+** with [uv](https://docs.astral.sh/uv/) for environment management -* **Authentication** — Key-based (`SPEECH_KEY`) or Microsoft Entra ID (`SPEECH_RESOURCE_ID`) +| Requirement | Details | +|:------------|:--------| +| Azure Speech resource | Free tier provides 500K characters per month | +| Python 3.11+ | With [uv](https://docs.astral.sh/uv/) for environment management | +| Authentication | Key-based (`SPEECH_KEY`) or Microsoft Entra ID (`SPEECH_RESOURCE_ID`) | ## Setup @@ -124,12 +126,12 @@ content/ ## Troubleshooting -| Issue | Solution | -|:------|:---------| -| `Set SPEECH_KEY ... or SPEECH_RESOURCE_ID` | Export authentication environment variables | -| 401 with Entra ID auth | Verify custom domain and `Cognitive Services Speech User` role assignment | -| Empty WAV files | Verify `speaker_notes:` is present and non-empty in `content.yaml` | -| Mispronounced acronyms | Add entries to `acronyms.yaml` with phonetic aliases | +| Issue | Solution | +|:-------------------------------------------|:--------------------------------------------------------------------------| +| `Set SPEECH_KEY ... or SPEECH_RESOURCE_ID` | Export authentication environment variables | +| 401 with Entra ID auth | Verify custom domain and `Cognitive Services Speech User` role assignment | +| Empty WAV files | Verify `speaker_notes:` is present and non-empty in `content.yaml` | +| Mispronounced acronyms | Add entries to `acronyms.yaml` with phonetic aliases | ## Related Resources From d432388ebce347aa0e418c05212c9704aa2c211b Mon Sep 17 00:00:00 2001 From: Alain Uyidi <107195562+auyidi1@users.noreply.github.com> Date: Sat, 25 Apr 2026 01:07:14 +0000 Subject: [PATCH 17/68] fix(skills): apply ruff format and table formatting for CI - Apply ruff format to generate_voiceover.py - Run format:tables on collection and plugin markdown files - Fix prerequisites section in docs/getting-started/tts-voiceover.md --- .../tts-voiceover/scripts/generate_voiceover.py | 12 +++--------- docs/getting-started/tts-voiceover.md | 10 +++++----- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py index 2c5256abf..d32169b86 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py +++ b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py @@ -94,9 +94,7 @@ def wrap_ssml(text: str, voice: str, rate: str) -> str: ) -def generate_audio( - ssml: str, output_path: Path, speech_config: object -) -> float | None: +def generate_audio(ssml: str, output_path: Path, speech_config: object) -> float | None: """Generate a WAV file from SSML. Returns duration in seconds or ``None``.""" import azure.cognitiveservices.speech as speechsdk @@ -124,9 +122,7 @@ def _make_entra_config( Returns (config, expires_at). """ - token_obj = credential.get_token( - "https://cognitiveservices.azure.com/.default" - ) + token_obj = credential.get_token("https://cognitiveservices.azure.com/.default") auth_token = f"aad#{resource_id}#{token_obj.token}" config = speechsdk.SpeechConfig(auth_token=auth_token, region=region) config.set_speech_synthesis_output_format( @@ -232,9 +228,7 @@ def main() -> int: try: from azure.identity import DefaultAzureCredential except ImportError: - logger.error( - "azure-identity package is required for Entra ID auth" - ) + logger.error("azure-identity package is required for Entra ID auth") return EXIT_FAILURE credential = DefaultAzureCredential() speech_config, token_expires_at = _make_entra_config( diff --git a/docs/getting-started/tts-voiceover.md b/docs/getting-started/tts-voiceover.md index b29c87a44..1a107c6c5 100644 --- a/docs/getting-started/tts-voiceover.md +++ b/docs/getting-started/tts-voiceover.md @@ -21,11 +21,11 @@ This skill reads `content.yaml` files produced by the PowerPoint skill, extracts ## Prerequisites -| Requirement | Details | -|:------------|:--------| -| Azure Speech resource | Free tier provides 500K characters per month | -| Python 3.11+ | With [uv](https://docs.astral.sh/uv/) for environment management | -| Authentication | Key-based (`SPEECH_KEY`) or Microsoft Entra ID (`SPEECH_RESOURCE_ID`) | +| Requirement | Details | +|:----------------------|:----------------------------------------------------------------------| +| Azure Speech resource | Free tier provides 500K characters per month | +| Python 3.11+ | With [uv](https://docs.astral.sh/uv/) for environment management | +| Authentication | Key-based (`SPEECH_KEY`) or Microsoft Entra ID (`SPEECH_RESOURCE_ID`) | ## Setup From 2e0455b5ad25d560484a3e11be8174aed27134aa Mon Sep 17 00:00:00 2001 From: Alain Uyidi <107195562+auyidi1@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:04:41 +0000 Subject: [PATCH 18/68] fix(skills): add narration timing to embed_audio for PowerPoint video export embed_audio.py now injects p:timing animation XML and slide transition auto-advance after embedding WAV files. PowerPoint recognizes the audio as recorded narrations, enabling File > Export > Create a Video > Use Recorded Timings and Narrations. - Add get_wav_duration_ms() to read WAV duration for slide timing - Add _add_narration_timing() with playFrom(0) media commands - Add _set_slide_transition() with advTm matching audio duration - Add _find_audio_shape_id() to locate embedded audio shapes - Update SKILL.md and getting-started docs with narration behavior --- .../experimental/tts-voiceover/SKILL.md | 5 + .../tts-voiceover/scripts/embed_audio.py | 139 ++++++++++++++---- docs/getting-started/tts-voiceover.md | 6 + 3 files changed, 120 insertions(+), 30 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/SKILL.md b/.github/skills/experimental/tts-voiceover/SKILL.md index 733708e0f..a3d0e864b 100644 --- a/.github/skills/experimental/tts-voiceover/SKILL.md +++ b/.github/skills/experimental/tts-voiceover/SKILL.md @@ -76,6 +76,10 @@ uv run scripts/embed_audio.py --input deck.pptx --audio-dir voice-over --output ### embed_audio.py +Embeds WAV files into corresponding PPTX slides and adds narration timing +XML so PowerPoint recognizes the audio for video export via +**File > Export > Create a Video > Use Recorded Timings and Narrations**. + | Parameter | Type | Default | Description | |:---------------|:-----|:---------------------|:---------------------------------| | `--input` | path | *(required)* | Source PPTX file path | @@ -170,5 +174,6 @@ Each `content.yaml` should contain a `speaker_notes:` field with the narration t | Mispronounced acronyms | Add entries to `acronyms.yaml` with phonetic aliases. | | `azure-cognitiveservices-speech package is required` | Run `uv sync` in the skill directory. | | Audio icon visible in PPTX | Reposition or resize the audio object in PowerPoint after embedding. | +| Video export shows "No timings recorded" | Re-embed audio with the updated `embed_audio.py` which adds narration timing XML automatically. | > Brought to you by microsoft/hve-core diff --git a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py index 7b185bc2c..73402b6fa 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py +++ b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py @@ -4,16 +4,14 @@ """Embed per-slide WAV voice-over files into a PowerPoint deck. Reads slide-NNN.wav files from an audio directory and adds them as embedded -media objects in the corresponding slides of a PPTX file. +media objects in the corresponding slides of a PPTX file. Adds animation +timing XML so PowerPoint recognizes the audio as narrations, enabling +'Use Recorded Timings and Narrations' in File > Export > Create a Video. Usage: python embed_audio.py --input deck.pptx --audio-dir voice-over python embed_audio.py --input deck.pptx --audio-dir voice-over \ --output deck-narrated.pptx - -Note: python-pptx has limited audio embedding support. The audio is added via -``add_movie()`` with a small off-screen icon. Manual PowerPoint audio -configuration may produce better auto-play results. """ from __future__ import annotations @@ -21,10 +19,12 @@ import argparse import logging import sys +import wave from pathlib import Path +from lxml import etree from pptx import Presentation -from pptx.slide import Slide +from pptx.oxml.ns import qn from pptx.util import Inches logger = logging.getLogger(__name__) @@ -35,10 +35,101 @@ AUDIO_MIME_TYPE = "audio/wav" ICON_SIZE = Inches(0.1) +TIMING_BUFFER_MS = 1500 + + +def get_wav_duration_ms(wav_path: Path) -> int: + """Return WAV file duration in milliseconds with buffer.""" + with wave.open(str(wav_path), "rb") as wf: + frames = wf.getnframes() + rate = wf.getframerate() + return int((frames / float(rate)) * 1000) + TIMING_BUFFER_MS + + +def _add_narration_timing(slide: object, shape_id: int, duration_ms: int) -> None: + """Add auto-play narration timing XML to a slide. + + Creates the p:timing element structure that PowerPoint generates + when using Record Slide Show, enabling 'Use Recorded Timings and + Narrations' in video export. + """ + existing = slide._element.find(qn("p:timing")) + if existing is not None: + slide._element.remove(existing) + + timing_xml = ( + '' + "" + '' + "" + '' + f'' + "" + '' + '' + "" + '' + '' + "" + '' + f'' + f'' + "" + "" + "" + "" + '' + "" + "" + '' + "" + "" + "" + ) + slide._element.append(etree.fromstring(timing_xml)) -def embed_slide_audio(slide: Slide, wav_path: Path) -> bool: - """Embed a WAV file into a PowerPoint slide. +def _set_slide_transition(slide: object, duration_ms: int) -> None: + """Set slide auto-advance timing after audio duration.""" + existing = slide._element.find(qn("p:transition")) + if existing is not None: + slide._element.remove(existing) + + transition = slide._element.makeelement( + qn("p:transition"), + {"advClick": "1", "advTm": str(duration_ms)}, + ) + timing = slide._element.find(qn("p:timing")) + if timing is not None: + timing.addprevious(transition) + else: + slide._element.append(transition) + + +def _find_audio_shape_id(slide: object) -> int | None: + """Find the shape ID of the audio/movie shape on a slide.""" + for shape in slide.shapes: + sp = shape._element + for tag_suffix in ("nvPicPr", "nvSpPr"): + nv = sp.find(qn(f"p:{tag_suffix}")) + if nv is None: + continue + nvPr = nv.find(qn("p:nvPr")) + if nvPr is None: + continue + if nvPr.find(qn("a:audioFile")) is not None: + return shape.shape_id + if nvPr.find(qn("a:videoFile")) is not None: + return shape.shape_id + return None + + +def embed_slide_audio(slide: object, wav_path: Path) -> bool: + """Embed a WAV file into a slide as a media object. + + Adds narration timing XML and slide auto-advance so PowerPoint + recognizes the audio for video export. Returns True on success, False on failure. """ @@ -51,6 +142,11 @@ def embed_slide_audio(slide: Slide, wav_path: Path) -> bool: height=ICON_SIZE, mime_type=AUDIO_MIME_TYPE, ) + shape_id = _find_audio_shape_id(slide) + if shape_id is not None: + duration_ms = get_wav_duration_ms(wav_path) + _add_narration_timing(slide, shape_id, duration_ms) + _set_slide_transition(slide, duration_ms) return True except Exception: logger.exception("Failed to embed audio %s", wav_path.name) @@ -83,8 +179,9 @@ def create_parser() -> argparse.ArgumentParser: return parser -def _run() -> int: - """Core logic for audio embedding.""" +def main() -> int: + """Entry point for audio embedding.""" + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") parser = create_parser() args = parser.parse_args() @@ -105,7 +202,6 @@ def _run() -> int: prs = Presentation(str(input_path)) embedded_count = 0 - failed_count = 0 for idx, slide in enumerate(prs.slides, start=1): wav_name = f"slide-{idx:03d}.wav" @@ -118,32 +214,15 @@ def _run() -> int: embedded_count += 1 logger.info("Embedded %s into slide %d", wav_name, idx) else: - failed_count += 1 logger.error("FAILED to embed %s into slide %d", wav_name, idx) output_path.parent.mkdir(parents=True, exist_ok=True) prs.save(str(output_path)) logger.info( - "Saved %s with %d embedded audio files (%d failed)", - output_path, - embedded_count, - failed_count, + "Saved %s with %d embedded audio files", output_path, embedded_count ) - return EXIT_FAILURE if failed_count > 0 else EXIT_SUCCESS - - -def main() -> int: - """Entry point for audio embedding.""" - logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") - try: - return _run() - except KeyboardInterrupt: - print("\nInterrupted by user", file=sys.stderr) - return 130 - except BrokenPipeError: - sys.stderr.close() - return EXIT_FAILURE + return EXIT_SUCCESS if __name__ == "__main__": diff --git a/docs/getting-started/tts-voiceover.md b/docs/getting-started/tts-voiceover.md index 1a107c6c5..60c206d05 100644 --- a/docs/getting-started/tts-voiceover.md +++ b/docs/getting-started/tts-voiceover.md @@ -70,10 +70,15 @@ uv run scripts/generate_voiceover.py --content-dir path/to/content --output-dir ### 3. Embed Audio into PPTX +Embedding adds WAV files as media objects and injects narration timing XML so +PowerPoint recognizes the audio for video export. + ```bash uv run scripts/embed_audio.py --input deck.pptx --audio-dir voice-over ``` +After embedding, use **File > Export > Create a Video > Use Recorded Timings and Narrations** in PowerPoint to produce an MP4 with synchronized audio. + ## Cross-Platform Wrappers Bash and PowerShell wrappers manage the Python virtual environment automatically. @@ -132,6 +137,7 @@ content/ | 401 with Entra ID auth | Verify custom domain and `Cognitive Services Speech User` role assignment | | Empty WAV files | Verify `speaker_notes:` is present and non-empty in `content.yaml` | | Mispronounced acronyms | Add entries to `acronyms.yaml` with phonetic aliases | +| Video export shows "No timings recorded" | Re-embed audio with the latest `embed_audio.py` | ## Related Resources From 1eecef2e929385d09fb8512dee6eb96107450e29 Mon Sep 17 00:00:00 2001 From: auyidi Date: Wed, 29 Apr 2026 23:00:58 +0000 Subject: [PATCH 19/68] fix(skills): address PR review findings for tts-voiceover - generate_voiceover.py: XML-escape voice/rate via quoteattr in wrap_ssml, single-pass regex in apply_acronym_aliases to prevent SSML tag corruption, add KeyboardInterrupt/BrokenPipeError handling in main() - embed_audio.py: add KeyboardInterrupt/BrokenPipeError handling in main(), return EXIT_FAILURE when zero audio files embedded - experimental.collection.yml: fix malformed YAML (merged lines, duplicate entry), add maturity: experimental to tts-voiceover entry - hve-core-all.collection.yml: add maturity: experimental to tts-voiceover --- .../tts-voiceover/scripts/embed_audio.py | 17 ++++++-- .../scripts/generate_voiceover.py | 43 ++++++++++++++----- collections/experimental.collection.yml | 5 ++- collections/hve-core-all.collection.yml | 1 + 4 files changed, 50 insertions(+), 16 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py index 73402b6fa..a3af1dd8d 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py +++ b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py @@ -184,6 +184,17 @@ def main() -> int: logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") parser = create_parser() args = parser.parse_args() + try: + return _run(args) + except KeyboardInterrupt: + return 130 + except BrokenPipeError: + sys.stderr.close() + return 1 + + +def _run(args) -> int: + """Execute audio embedding logic.""" input_path: Path = args.input audio_dir: Path = args.audio_dir @@ -218,10 +229,10 @@ def main() -> int: output_path.parent.mkdir(parents=True, exist_ok=True) prs.save(str(output_path)) - logger.info( - "Saved %s with %d embedded audio files", output_path, embedded_count - ) + logger.info("Saved %s with %d embedded audio files", output_path, embedded_count) + if embedded_count == 0: + return EXIT_FAILURE return EXIT_SUCCESS diff --git a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py index d32169b86..b4569768d 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py +++ b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py @@ -18,6 +18,7 @@ import argparse import logging import os +import re import sys import time import xml.sax.saxutils @@ -68,25 +69,34 @@ def load_acronyms(path: Path) -> dict[str, str]: def apply_acronym_aliases(text: str, acronyms: dict[str, str]) -> str: """Replace acronyms with SSML ```` elements. - Processes longest acronyms first to avoid partial matches. + Uses a single-pass regex to avoid corrupting previously-inserted SSML + tags when an acronym appears inside an alias value or tag content. """ - for acronym, alias in sorted(acronyms.items(), key=lambda x: -len(x[0])): - if acronym in text: - replacement = ( - f'' - f"{xml.sax.saxutils.escape(acronym)}" - ) - text = text.replace(acronym, replacement) - return text + if not acronyms: + return text + # Build pattern matching all acronyms, longest first + sorted_keys = sorted(acronyms.keys(), key=len, reverse=True) + pattern = re.compile("|".join(re.escape(k) for k in sorted_keys)) + + def _replace(m: re.Match) -> str: + acronym = m.group(0) + alias = acronyms[acronym] + safe_alias = xml.sax.saxutils.quoteattr(alias) + safe_acronym = xml.sax.saxutils.escape(acronym) + return f"{safe_acronym}" + + return pattern.sub(_replace, text) def wrap_ssml(text: str, voice: str, rate: str) -> str: """Wrap processed text in a full SSML document.""" + safe_voice = xml.sax.saxutils.quoteattr(voice) + safe_rate = xml.sax.saxutils.quoteattr(rate) return ( '\n' - f' \n' - f' \n' + f" \n" + f" \n" f" {text}\n" " \n" " \n" @@ -187,6 +197,17 @@ def main() -> int: logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") parser = create_parser() args = parser.parse_args() + try: + return _run(args) + except KeyboardInterrupt: + return 130 + except BrokenPipeError: + sys.stderr.close() + return 1 + + +def _run(args) -> int: + """Execute TTS generation logic.""" content_dir: Path = args.content_dir output_dir: Path = args.output_dir diff --git a/collections/experimental.collection.yml b/collections/experimental.collection.yml index 7bcc9ce07..b115ce82e 100644 --- a/collections/experimental.collection.yml +++ b/collections/experimental.collection.yml @@ -17,9 +17,10 @@ items: - path: .github/skills/experimental/customer-card-render kind: skill - path: .github/skills/experimental/powerpoint - kind: skill - path: .github/skills/experimental/tts-voiceover - kind: skill - path: .github/skills/experimental/tts-voiceover kind: skill + - path: .github/skills/experimental/tts-voiceover + kind: skill + maturity: experimental - path: .github/skills/experimental/video-to-gif kind: skill - path: .github/skills/experimental/vscode-playwright diff --git a/collections/hve-core-all.collection.yml b/collections/hve-core-all.collection.yml index c0af2f99d..20e5ed406 100644 --- a/collections/hve-core-all.collection.yml +++ b/collections/hve-core-all.collection.yml @@ -577,6 +577,7 @@ items: kind: skill - path: .github/skills/experimental/tts-voiceover kind: skill + maturity: experimental - path: .github/skills/experimental/video-to-gif kind: skill - path: .github/skills/experimental/vscode-playwright From 4e7ed665e25a5a4d329724e3fe80791a9e3ef83f Mon Sep 17 00:00:00 2001 From: auyidi Date: Wed, 29 Apr 2026 23:07:09 +0000 Subject: [PATCH 20/68] fix(collections): add missing maturity: experimental for vscode-playwright Resolves maturity conflict between experimental.collection.yml (experimental) and hve-core-all.collection.yml (defaulting to stable). --- collections/hve-core-all.collection.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/collections/hve-core-all.collection.yml b/collections/hve-core-all.collection.yml index 20e5ed406..2ace72e0a 100644 --- a/collections/hve-core-all.collection.yml +++ b/collections/hve-core-all.collection.yml @@ -582,6 +582,7 @@ items: kind: skill - path: .github/skills/experimental/vscode-playwright kind: skill + maturity: experimental - path: .github/skills/gitlab/gitlab kind: skill - path: .github/skills/installer/hve-core-installer From 2973bcdb2b4131994ef7b166d19bd290c389f285 Mon Sep 17 00:00:00 2001 From: auyidi Date: Wed, 29 Apr 2026 23:10:53 +0000 Subject: [PATCH 21/68] fix(docs): use pathname:// protocol for out-of-scope SKILL.md link Docusaurus cannot resolve relative links that point outside the docs plugin directory. Apply the pathname:// protocol as recommended by the Docusaurus build error message. --- docs/getting-started/tts-voiceover.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/getting-started/tts-voiceover.md b/docs/getting-started/tts-voiceover.md index 60c206d05..46324983b 100644 --- a/docs/getting-started/tts-voiceover.md +++ b/docs/getting-started/tts-voiceover.md @@ -141,7 +141,7 @@ content/ ## Related Resources -* [SKILL.md](../../.github/skills/experimental/tts-voiceover/SKILL.md): Full skill reference with parameters and SSML template details +* [SKILL.md](pathname://../../.github/skills/experimental/tts-voiceover/SKILL.md): Full skill reference with parameters and SSML template details * [Contributing Skills](../contributing/skills.md): Guidelines for contributing skills to HVE Core --- From d4c2bc667e2ed43c914bcb61b44eb9f69ae90a48 Mon Sep 17 00:00:00 2001 From: auyidi Date: Wed, 29 Apr 2026 23:12:55 +0000 Subject: [PATCH 22/68] chore(plugins): regenerate plugins after collection maturity updates --- collections/experimental.collection.md | 1 + collections/hve-core-all.collection.md | 1 + plugins/experimental/README.md | 1 + plugins/hve-core-all/README.md | 1 + 4 files changed, 4 insertions(+) diff --git a/collections/experimental.collection.md b/collections/experimental.collection.md index 48f4d2feb..a20dd268f 100644 --- a/collections/experimental.collection.md +++ b/collections/experimental.collection.md @@ -24,6 +24,7 @@ Experimental and preview artifacts not yet promoted to stable collections. Items |--------------------------|----------------------------------------------------------------------------------------------------------------------------------------------| | **customer-card-render** | Generate customer-card PowerPoint content YAML from Design Thinking canonical artifacts and build using the shared PowerPoint skill pipeline | | **powerpoint** | PowerPoint slide deck generation and management using python-pptx with YAML-driven content and styling | +| **tts-voiceover** | Text-to-speech voice-over generation from YAML speaker notes using Azure Speech SDK with SSML pronunciation control | | **video-to-gif** | Video-to-GIF conversion skill with FFmpeg two-pass optimization | | **vscode-playwright** | VS Code screenshot capture using Playwright MCP with serve-web for slide decks and documentation | diff --git a/collections/hve-core-all.collection.md b/collections/hve-core-all.collection.md index 0403a774a..1f8f4ba25 100644 --- a/collections/hve-core-all.collection.md +++ b/collections/hve-core-all.collection.md @@ -267,6 +267,7 @@ Use this edition when you want access to everything without choosing a focused c | **python-foundational** | Foundational Python best practices, idioms, and code quality fundamentals | | **secure-by-design** | Secure by Design principles knowledge base for assessing adherence to security-first design, development, and deployment practices across the software lifecycle - Brought to you by microsoft/hve-core. | | **security-reviewer-formats** | Format specifications and data contracts for the security reviewer orchestrator and its subagents - Brought to you by microsoft/hve-core. | +| **tts-voiceover** | Text-to-speech voice-over generation from YAML speaker notes using Azure Speech SDK with SSML pronunciation control | | **video-to-gif** | Video-to-GIF conversion skill with FFmpeg two-pass optimization | | **vscode-playwright** | VS Code screenshot capture using Playwright MCP with serve-web for slide decks and documentation | diff --git a/plugins/experimental/README.md b/plugins/experimental/README.md index 42c50be35..832771745 100644 --- a/plugins/experimental/README.md +++ b/plugins/experimental/README.md @@ -66,6 +66,7 @@ copilot plugin install experimental@hve-core |----------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | customer-card-render | Generate customer-card PowerPoint content YAML from Design Thinking canonical artifacts and build using the shared PowerPoint skill pipeline - Brought to you by microsoft/hve-core | | powerpoint | PowerPoint slide deck generation and management using python-pptx with YAML-driven content and styling - Brought to you by microsoft/hve-core | +| tts-voiceover | Text-to-speech voice-over generation from YAML speaker notes using Azure Speech SDK with SSML pronunciation control - Brought to you by microsoft/hve-core | | video-to-gif | Video-to-GIF conversion skill with FFmpeg two-pass optimization - Brought to you by microsoft/hve-core | | vscode-playwright | VS Code screenshot capture using Playwright MCP with serve-web for slide decks and documentation - Brought to you by microsoft/hve-core | diff --git a/plugins/hve-core-all/README.md b/plugins/hve-core-all/README.md index edab24775..81097e46c 100644 --- a/plugins/hve-core-all/README.md +++ b/plugins/hve-core-all/README.md @@ -533,6 +533,7 @@ copilot plugin install hve-core-all@hve-core | python-foundational | Foundational Python best practices, idioms, and code quality fundamentals - Brought to you by microsoft/hve-core | | customer-card-render | Generate customer-card PowerPoint content YAML from Design Thinking canonical artifacts and build using the shared PowerPoint skill pipeline - Brought to you by microsoft/hve-core | | powerpoint | PowerPoint slide deck generation and management using python-pptx with YAML-driven content and styling - Brought to you by microsoft/hve-core | +| tts-voiceover | Text-to-speech voice-over generation from YAML speaker notes using Azure Speech SDK with SSML pronunciation control - Brought to you by microsoft/hve-core | | video-to-gif | Video-to-GIF conversion skill with FFmpeg two-pass optimization - Brought to you by microsoft/hve-core | | vscode-playwright | VS Code screenshot capture using Playwright MCP with serve-web for slide decks and documentation - Brought to you by microsoft/hve-core | | gitlab | Manage GitLab merge requests and pipelines with a Python CLI - Brought to you by microsoft/hve-core | From 4479aff723bb3d5f26584f94ee3ec5f0d81a4cc3 Mon Sep 17 00:00:00 2001 From: auyidi Date: Wed, 29 Apr 2026 23:16:02 +0000 Subject: [PATCH 23/68] fix(skills): add missing Copilot footer to tts-voiceover SKILL.md --- .../experimental/tts-voiceover/SKILL.md | 46 ++++++++++--------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/SKILL.md b/.github/skills/experimental/tts-voiceover/SKILL.md index a3d0e864b..965e229fb 100644 --- a/.github/skills/experimental/tts-voiceover/SKILL.md +++ b/.github/skills/experimental/tts-voiceover/SKILL.md @@ -65,14 +65,14 @@ uv run scripts/embed_audio.py --input deck.pptx --audio-dir voice-over --output ### generate_voiceover.py -| Parameter | Type | Default | Description | -|:----------------|:-------|:-------------------------------------|:-----------------------------------------------| -| `--dry-run` | flag | `false` | Print SSML templates without generating audio | -| `--voice` | string | `en-US-Andrew:DragonHDLatestNeural` | Azure TTS voice name | -| `--rate` | string | `+10%` | Speech prosody rate | -| `--content-dir` | path | `content` | Path to slide content directory | -| `--output-dir` | path | `voice-over` | Path to WAV output directory | -| `--lexicon` | path | *(auto-detect)* | Custom acronyms.yaml path | +| Parameter | Type | Default | Description | +|:----------------|:-------|:------------------------------------|:----------------------------------------------| +| `--dry-run` | flag | `false` | Print SSML templates without generating audio | +| `--voice` | string | `en-US-Andrew:DragonHDLatestNeural` | Azure TTS voice name | +| `--rate` | string | `+10%` | Speech prosody rate | +| `--content-dir` | path | `content` | Path to slide content directory | +| `--output-dir` | path | `voice-over` | Path to WAV output directory | +| `--lexicon` | path | *(auto-detect)* | Custom acronyms.yaml path | ### embed_audio.py @@ -80,11 +80,11 @@ Embeds WAV files into corresponding PPTX slides and adds narration timing XML so PowerPoint recognizes the audio for video export via **File > Export > Create a Video > Use Recorded Timings and Narrations**. -| Parameter | Type | Default | Description | -|:---------------|:-----|:---------------------|:---------------------------------| -| `--input` | path | *(required)* | Source PPTX file path | -| `--audio-dir` | path | `voice-over` | Directory with slide-NNN.wav | -| `--output` | path | `*-narrated.pptx` | Output PPTX file path | +| Parameter | Type | Default | Description | +|:--------------|:-----|:------------------|:-----------------------------| +| `--input` | path | *(required)* | Source PPTX file path | +| `--audio-dir` | path | `voice-over` | Directory with slide-NNN.wav | +| `--output` | path | `*-narrated.pptx` | Output PPTX file path | ## Script Reference @@ -166,14 +166,16 @@ Each `content.yaml` should contain a `speaker_notes:` field with the narration t ## Troubleshooting -| Issue | Solution | -|:------|:---------| -| `Set SPEECH_KEY ... or SPEECH_RESOURCE_ID` | Export `SPEECH_KEY` (key auth) or `SPEECH_RESOURCE_ID` (Entra ID) with `SPEECH_REGION`. | -| 401 with Entra ID auth | Verify custom domain on the Speech resource and `Cognitive Services Speech User` role. RBAC propagation takes up to 5 minutes. | -| Empty WAV files or skipped slides | Verify `speaker_notes:` is present and non-empty in `content.yaml`. | -| Mispronounced acronyms | Add entries to `acronyms.yaml` with phonetic aliases. | -| `azure-cognitiveservices-speech package is required` | Run `uv sync` in the skill directory. | -| Audio icon visible in PPTX | Reposition or resize the audio object in PowerPoint after embedding. | -| Video export shows "No timings recorded" | Re-embed audio with the updated `embed_audio.py` which adds narration timing XML automatically. | +| Issue | Solution | +|:-----------------------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------| +| `Set SPEECH_KEY ... or SPEECH_RESOURCE_ID` | Export `SPEECH_KEY` (key auth) or `SPEECH_RESOURCE_ID` (Entra ID) with `SPEECH_REGION`. | +| 401 with Entra ID auth | Verify custom domain on the Speech resource and `Cognitive Services Speech User` role. RBAC propagation takes up to 5 minutes. | +| Empty WAV files or skipped slides | Verify `speaker_notes:` is present and non-empty in `content.yaml`. | +| Mispronounced acronyms | Add entries to `acronyms.yaml` with phonetic aliases. | +| `azure-cognitiveservices-speech package is required` | Run `uv sync` in the skill directory. | +| Audio icon visible in PPTX | Reposition or resize the audio object in PowerPoint after embedding. | +| Video export shows "No timings recorded" | Re-embed audio with the updated `embed_audio.py` which adds narration timing XML automatically. | > Brought to you by microsoft/hve-core + +*🤖 Crafted with precision by ✨Copilot following brilliant human instruction, then carefully refined by our team of discerning human reviewers.* From b0b136e664d736c993eea68cc4f01267f3c93d0c Mon Sep 17 00:00:00 2001 From: auyidi Date: Wed, 29 Apr 2026 23:18:58 +0000 Subject: [PATCH 24/68] style(docs): fix markdown table formatting in tts-voiceover guide --- docs/getting-started/tts-voiceover.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/getting-started/tts-voiceover.md b/docs/getting-started/tts-voiceover.md index 46324983b..1e2f4f613 100644 --- a/docs/getting-started/tts-voiceover.md +++ b/docs/getting-started/tts-voiceover.md @@ -137,7 +137,7 @@ content/ | 401 with Entra ID auth | Verify custom domain and `Cognitive Services Speech User` role assignment | | Empty WAV files | Verify `speaker_notes:` is present and non-empty in `content.yaml` | | Mispronounced acronyms | Add entries to `acronyms.yaml` with phonetic aliases | -| Video export shows "No timings recorded" | Re-embed audio with the latest `embed_audio.py` | +| Video export shows "No timings recorded" | Re-embed audio with the latest `embed_audio.py` | ## Related Resources From 7bd1052e39e41e41fb6813a9e06cd459e959ec8e Mon Sep 17 00:00:00 2001 From: auyidi Date: Wed, 29 Apr 2026 23:24:15 +0000 Subject: [PATCH 25/68] chore(plugins): regenerate plugin READMEs after SKILL.md footer update --- plugins/experimental/README.md | 1 + plugins/hve-core-all/README.md | 1 + 2 files changed, 2 insertions(+) diff --git a/plugins/experimental/README.md b/plugins/experimental/README.md index 832771745..0f8587b9a 100644 --- a/plugins/experimental/README.md +++ b/plugins/experimental/README.md @@ -33,6 +33,7 @@ Experimental and preview artifacts not yet promoted to stable collections. Items |--------------------------|----------------------------------------------------------------------------------------------------------------------------------------------| | **customer-card-render** | Generate customer-card PowerPoint content YAML from Design Thinking canonical artifacts and build using the shared PowerPoint skill pipeline | | **powerpoint** | PowerPoint slide deck generation and management using python-pptx with YAML-driven content and styling | +| **tts-voiceover** | Text-to-speech voice-over generation from YAML speaker notes using Azure Speech SDK with SSML pronunciation control | | **video-to-gif** | Video-to-GIF conversion skill with FFmpeg two-pass optimization | | **vscode-playwright** | VS Code screenshot capture using Playwright MCP with serve-web for slide decks and documentation | diff --git a/plugins/hve-core-all/README.md b/plugins/hve-core-all/README.md index 81097e46c..9c2abe836 100644 --- a/plugins/hve-core-all/README.md +++ b/plugins/hve-core-all/README.md @@ -274,6 +274,7 @@ Use this edition when you want access to everything without choosing a focused c | **python-foundational** | Foundational Python best practices, idioms, and code quality fundamentals | | **secure-by-design** | Secure by Design principles knowledge base for assessing adherence to security-first design, development, and deployment practices across the software lifecycle - Brought to you by microsoft/hve-core. | | **security-reviewer-formats** | Format specifications and data contracts for the security reviewer orchestrator and its subagents - Brought to you by microsoft/hve-core. | +| **tts-voiceover** | Text-to-speech voice-over generation from YAML speaker notes using Azure Speech SDK with SSML pronunciation control | | **video-to-gif** | Video-to-GIF conversion skill with FFmpeg two-pass optimization | | **vscode-playwright** | VS Code screenshot capture using Playwright MCP with serve-web for slide decks and documentation | From 022c725f118777371efb8dfe39698ecd4e2f4b15 Mon Sep 17 00:00:00 2001 From: auyidi Date: Thu, 30 Apr 2026 00:04:54 +0000 Subject: [PATCH 26/68] fix(skills): address latest PR review for tts-voiceover - generate_voiceover.py: replace object type hints with Any for dynamically-imported SDK types, add str() coercion for speaker_notes to handle non-string YAML values, add noqa: PLC0415 for inline imports - docs/getting-started/tts-voiceover.md: add sidebar_position frontmatter, replace pathname:// link with GitHub URL for SKILL.md --- .../tts-voiceover/scripts/generate_voiceover.py | 14 ++++++++------ docs/getting-started/tts-voiceover.md | 3 ++- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py index b4569768d..face18877 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py +++ b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py @@ -23,6 +23,7 @@ import time import xml.sax.saxutils from pathlib import Path +from typing import Any import yaml @@ -104,9 +105,9 @@ def wrap_ssml(text: str, voice: str, rate: str) -> str: ) -def generate_audio(ssml: str, output_path: Path, speech_config: object) -> float | None: +def generate_audio(ssml: str, output_path: Path, speech_config: Any) -> float | None: """Generate a WAV file from SSML. Returns duration in seconds or ``None``.""" - import azure.cognitiveservices.speech as speechsdk + import azure.cognitiveservices.speech as speechsdk # noqa: PLC0415 audio_config = speechsdk.audio.AudioOutputConfig(filename=str(output_path)) synthesizer = speechsdk.SpeechSynthesizer( @@ -123,8 +124,8 @@ def generate_audio(ssml: str, output_path: Path, speech_config: object) -> float def _make_entra_config( - speechsdk: object, - credential: object, + speechsdk: Any, + credential: Any, resource_id: str, region: str, ) -> tuple: @@ -226,7 +227,7 @@ def _run(args) -> int: token_expires_at = 0 if not args.dry_run: try: - import azure.cognitiveservices.speech as speechsdk + import azure.cognitiveservices.speech as speechsdk # noqa: PLC0415 except ImportError: logger.error( "azure-cognitiveservices-speech package is required" @@ -283,7 +284,8 @@ def _run(args) -> int: ) continue - notes = data.get("speaker_notes", "").strip() + raw_notes = data.get("speaker_notes") or "" + notes = str(raw_notes).strip() title = data.get("title", slide_dir.name) if not notes: diff --git a/docs/getting-started/tts-voiceover.md b/docs/getting-started/tts-voiceover.md index 1e2f4f613..8403f522e 100644 --- a/docs/getting-started/tts-voiceover.md +++ b/docs/getting-started/tts-voiceover.md @@ -1,6 +1,7 @@ --- title: TTS Voice-Over Skill description: Generate per-slide WAV voice-over files from YAML speaker notes using Azure Speech SDK +sidebar_position: 5 author: Microsoft ms.date: 2026-04-23 ms.topic: how-to @@ -141,7 +142,7 @@ content/ ## Related Resources -* [SKILL.md](pathname://../../.github/skills/experimental/tts-voiceover/SKILL.md): Full skill reference with parameters and SSML template details +* [SKILL.md](https://github.com/microsoft/hve-core/blob/main/.github/skills/experimental/tts-voiceover/SKILL.md): Full skill reference with parameters and SSML template details * [Contributing Skills](../contributing/skills.md): Guidelines for contributing skills to HVE Core --- From 171cb0f2eecf9b5cf304087c132e13aa9ada6756 Mon Sep 17 00:00:00 2001 From: auyidi Date: Thu, 30 Apr 2026 01:15:49 +0000 Subject: [PATCH 27/68] test(skills): add test_embed_audio.py for tts-voiceover skill - TestGetWavDurationMs: verify duration calculation with 1500ms buffer - TestAddNarrationTiming: verify p:timing XML appended with correct spid and duration, replacing existing timing elements - TestEmbedSlideAudio: verify success/failure return values including exception path coverage --- .../tts-voiceover/tests/test_embed_audio.py | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 .github/skills/experimental/tts-voiceover/tests/test_embed_audio.py diff --git a/.github/skills/experimental/tts-voiceover/tests/test_embed_audio.py b/.github/skills/experimental/tts-voiceover/tests/test_embed_audio.py new file mode 100644 index 000000000..045a5fcc5 --- /dev/null +++ b/.github/skills/experimental/tts-voiceover/tests/test_embed_audio.py @@ -0,0 +1,111 @@ +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT +"""Tests for embed_audio module.""" + +import wave +from pathlib import Path +from unittest.mock import MagicMock, patch + +from embed_audio import ( + _add_narration_timing, + embed_slide_audio, + get_wav_duration_ms, +) + + +def _make_wav(tmp_path: Path, name: str = "test.wav", duration_ms: int = 100) -> Path: + """Create a minimal valid WAV file.""" + sample_rate = 16000 + num_samples = int(sample_rate * duration_ms / 1000) + path = tmp_path / name + with wave.open(str(path), "wb") as wf: + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(sample_rate) + wf.writeframes(b"\x00\x00" * num_samples) + return path + + +class TestGetWavDurationMs: + """Tests for get_wav_duration_ms.""" + + def test_returns_duration_with_buffer(self, tmp_path): + wav = _make_wav(tmp_path, duration_ms=1000) + result = get_wav_duration_ms(wav) + # 1000ms audio + 1500ms buffer = ~2500ms + assert 2400 <= result <= 2600 + + def test_short_file(self, tmp_path): + wav = _make_wav(tmp_path, duration_ms=50) + result = get_wav_duration_ms(wav) + # 50ms audio + 500ms buffer + assert result >= 500 + + +class TestAddNarrationTiming: + """Tests for _add_narration_timing.""" + + def test_appends_timing_element(self): + """Verify p:timing is added with the correct spid attribute.""" + from lxml import etree + + nsmap = {"p": "http://schemas.openxmlformats.org/presentationml/2006/main"} + slide_xml = etree.Element(f"{{{nsmap['p']}}}sld", nsmap=nsmap) + mock_slide = MagicMock() + mock_slide._element = slide_xml + + _add_narration_timing(mock_slide, shape_id=42, duration_ms=5000) + + timing = slide_xml.find( + "{http://schemas.openxmlformats.org/presentationml/2006/main}timing" + ) + assert timing is not None + + # Verify spid references the correct shape + xml_str = etree.tostring(timing, encoding="unicode") + assert 'spid="42"' in xml_str + assert 'dur="5000"' in xml_str + + def test_replaces_existing_timing(self): + """Verify existing p:timing is removed before adding new one.""" + from lxml import etree + + ns = "http://schemas.openxmlformats.org/presentationml/2006/main" + slide_xml = etree.Element(f"{{{ns}}}sld") + old_timing = etree.SubElement(slide_xml, f"{{{ns}}}timing") + etree.SubElement(old_timing, "old-content") + + mock_slide = MagicMock() + mock_slide._element = slide_xml + + _add_narration_timing(mock_slide, shape_id=10, duration_ms=3000) + + timings = slide_xml.findall(f"{{{ns}}}timing") + assert len(timings) == 1 + xml_str = etree.tostring(timings[0], encoding="unicode") + assert "old-content" not in xml_str + assert 'spid="10"' in xml_str + + +class TestEmbedSlideAudio: + """Tests for embed_slide_audio.""" + + def test_returns_true_on_success(self, tmp_path): + wav = _make_wav(tmp_path) + mock_slide = MagicMock() + mock_shape = MagicMock() + mock_shape.shape_id = 99 + mock_slide.shapes.add_movie.return_value = mock_shape + mock_slide.shapes.__iter__ = MagicMock(return_value=iter([])) + + with patch("embed_audio._find_audio_shape_id", return_value=None): + result = embed_slide_audio(mock_slide, wav) + assert result is True + + def test_returns_false_on_exception(self, tmp_path): + wav = _make_wav(tmp_path) + mock_slide = MagicMock() + mock_slide.shapes.add_movie.side_effect = RuntimeError("test error") + + result = embed_slide_audio(mock_slide, wav) + assert result is False From 7c28ef546ea50459983846c4821fcfc9e3fb1822 Mon Sep 17 00:00:00 2001 From: auyidi Date: Thu, 30 Apr 2026 01:52:27 +0000 Subject: [PATCH 28/68] fix(skills): address final review items for tts-voiceover - generate_voiceover.py: add argparse.Namespace type annotation to _run, initialize speech_key/speech_region/speech_resource_id/speechsdk before conditional block, add speechsdk is not None guard on token-refresh - embed_audio.py: add argparse.Namespace type annotation to _run - SKILL.md: move compatibility to metadata section with structured fields, keep Copilot footer (required by frontmatter validation) --- .github/skills/experimental/tts-voiceover/SKILL.md | 10 ++++++---- .../experimental/tts-voiceover/scripts/embed_audio.py | 2 +- .../tts-voiceover/scripts/generate_voiceover.py | 9 +++++++-- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/SKILL.md b/.github/skills/experimental/tts-voiceover/SKILL.md index 965e229fb..53f53c9d5 100644 --- a/.github/skills/experimental/tts-voiceover/SKILL.md +++ b/.github/skills/experimental/tts-voiceover/SKILL.md @@ -1,7 +1,11 @@ --- name: tts-voiceover description: 'Text-to-speech voice-over generation from YAML speaker notes using Azure Speech SDK with SSML pronunciation control - Brought to you by microsoft/hve-core' -compatibility: 'Requires Python 3.11+, uv, and an Azure Speech resource (key or Entra ID auth)' +metadata: + authors: "microsoft/hve-core" + spec_version: "1.0" + requires_python: ">=3.11" + platform: "Cross-platform (Windows, macOS, Linux)" --- # TTS Voice Over Skill @@ -176,6 +180,4 @@ Each `content.yaml` should contain a `speaker_notes:` field with the narration t | Audio icon visible in PPTX | Reposition or resize the audio object in PowerPoint after embedding. | | Video export shows "No timings recorded" | Re-embed audio with the updated `embed_audio.py` which adds narration timing XML automatically. | -> Brought to you by microsoft/hve-core - -*🤖 Crafted with precision by ✨Copilot following brilliant human instruction, then carefully refined by our team of discerning human reviewers.* +> Brought to you by microsoft/hve-core\n\n*🤖 Crafted with precision by ✨Copilot following brilliant human instruction, then carefully refined by our team of discerning human reviewers.* diff --git a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py index a3af1dd8d..0630cef71 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py +++ b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py @@ -193,7 +193,7 @@ def main() -> int: return 1 -def _run(args) -> int: +def _run(args: argparse.Namespace) -> int: """Execute audio embedding logic.""" input_path: Path = args.input diff --git a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py index face18877..66486f061 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py +++ b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py @@ -207,7 +207,7 @@ def main() -> int: return 1 -def _run(args) -> int: +def _run(args: argparse.Namespace) -> int: """Execute TTS generation logic.""" content_dir: Path = args.content_dir @@ -225,6 +225,10 @@ def _run(args) -> int: speech_config = None credential = None token_expires_at = 0 + speechsdk: Any = None + speech_key: str | None = None + speech_region: str = "eastus" + speech_resource_id: str | None = None if not args.dry_run: try: import azure.cognitiveservices.speech as speechsdk # noqa: PLC0415 @@ -304,7 +308,8 @@ def _run(args) -> int: # Refresh Entra ID token before expiry. if ( - speech_resource_id + speechsdk is not None + and speech_resource_id and not speech_key and time.time() > token_expires_at - 300 ): From d7c1b830e05dae5d87a1c4f298bca2aa6cfccb8f Mon Sep 17 00:00:00 2001 From: auyidi Date: Thu, 30 Apr 2026 03:13:15 +0000 Subject: [PATCH 29/68] style(skills): rename test methods to BDD format per python-test conventions Rename all test methods in fuzz_harness.py to follow the test_given_context_when_action_then_expected naming convention. Also update wrap_ssml assertions to match quoteattr output format. --- .../tts-voiceover/tests/fuzz_harness.py | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/tests/fuzz_harness.py b/.github/skills/experimental/tts-voiceover/tests/fuzz_harness.py index 72814ee6c..2953747e7 100644 --- a/.github/skills/experimental/tts-voiceover/tests/fuzz_harness.py +++ b/.github/skills/experimental/tts-voiceover/tests/fuzz_harness.py @@ -89,24 +89,24 @@ def fuzz_dispatch(data): class TestFuzzApplyAcronymAliases: """Property tests for apply_acronym_aliases.""" - def test_known_acronym_replaced(self): + def test_given_known_acronym_when_applied_then_sub_element_inserted(self): result = apply_acronym_aliases("Check OWASP guidelines", _DEFAULT_ACRONYMS) assert "Oh wasp" in result assert "") @@ -136,23 +138,23 @@ def test_speak_root_element(self): class TestFuzzLoadAcronyms: """Property tests for load_acronyms.""" - def test_nonexistent_file_returns_defaults(self): + def test_given_nonexistent_file_when_loaded_then_returns_defaults(self): result = load_acronyms(Path("/nonexistent/acronyms.yaml")) assert result == _DEFAULT_ACRONYMS - def test_valid_yaml_file(self, tmp_path): + def test_given_valid_yaml_when_loaded_then_returns_custom_map(self, tmp_path): lexicon = tmp_path / "acronyms.yaml" lexicon.write_text('acronyms:\n FOO: "bar"\n', encoding="utf-8") result = load_acronyms(lexicon) assert result == {"FOO": "bar"} - def test_invalid_format_returns_defaults(self, tmp_path): + def test_given_invalid_format_when_loaded_then_returns_defaults(self, tmp_path): lexicon = tmp_path / "acronyms.yaml" lexicon.write_text("acronyms: not-a-dict\n", encoding="utf-8") result = load_acronyms(lexicon) assert result == _DEFAULT_ACRONYMS - def test_empty_file_returns_defaults(self, tmp_path): + def test_given_empty_file_when_loaded_then_returns_defaults(self, tmp_path): lexicon = tmp_path / "acronyms.yaml" lexicon.write_text("", encoding="utf-8") result = load_acronyms(lexicon) From e987592f86992e6f7eeaa2139f559563cc8f30f0 Mon Sep 17 00:00:00 2001 From: auyidi Date: Thu, 30 Apr 2026 03:20:25 +0000 Subject: [PATCH 30/68] fix(skills): correct buffer comment and tighten assertion in test_short_file --- .../experimental/tts-voiceover/tests/test_embed_audio.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/tests/test_embed_audio.py b/.github/skills/experimental/tts-voiceover/tests/test_embed_audio.py index 045a5fcc5..3e16f1861 100644 --- a/.github/skills/experimental/tts-voiceover/tests/test_embed_audio.py +++ b/.github/skills/experimental/tts-voiceover/tests/test_embed_audio.py @@ -38,8 +38,8 @@ def test_returns_duration_with_buffer(self, tmp_path): def test_short_file(self, tmp_path): wav = _make_wav(tmp_path, duration_ms=50) result = get_wav_duration_ms(wav) - # 50ms audio + 500ms buffer - assert result >= 500 + # 50ms audio + 1500ms buffer = ~1550ms + assert result >= 1500 class TestAddNarrationTiming: From e56b4a25eb021ecd61f07920192732fbd43aa317 Mon Sep 17 00:00:00 2001 From: auyidi Date: Thu, 30 Apr 2026 03:25:23 +0000 Subject: [PATCH 31/68] fix(skills): add pytest-mock and migrate to mocker fixture - Add pytest-mock>=3.14 to dev dependencies - Replace unittest.mock.patch with mocker.patch in test_embed_audio.py - Retain MagicMock for pure test data stubs (permitted per conventions) --- .../experimental/tts-voiceover/pyproject.toml | 1 + .../tts-voiceover/tests/test_embed_audio.py | 8 ++++---- .github/skills/experimental/tts-voiceover/uv.lock | 14 ++++++++++++++ 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/pyproject.toml b/.github/skills/experimental/tts-voiceover/pyproject.toml index c372e268a..18323d906 100644 --- a/.github/skills/experimental/tts-voiceover/pyproject.toml +++ b/.github/skills/experimental/tts-voiceover/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ dev = [ "pytest>=9.0", "pytest-cov>=5.0", + "pytest-mock>=3.14", "ruff>=0.15", ] fuzz = [ diff --git a/.github/skills/experimental/tts-voiceover/tests/test_embed_audio.py b/.github/skills/experimental/tts-voiceover/tests/test_embed_audio.py index 3e16f1861..8be7ed511 100644 --- a/.github/skills/experimental/tts-voiceover/tests/test_embed_audio.py +++ b/.github/skills/experimental/tts-voiceover/tests/test_embed_audio.py @@ -4,7 +4,7 @@ import wave from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from embed_audio import ( _add_narration_timing, @@ -90,7 +90,7 @@ def test_replaces_existing_timing(self): class TestEmbedSlideAudio: """Tests for embed_slide_audio.""" - def test_returns_true_on_success(self, tmp_path): + def test_returns_true_on_success(self, tmp_path, mocker): wav = _make_wav(tmp_path) mock_slide = MagicMock() mock_shape = MagicMock() @@ -98,8 +98,8 @@ def test_returns_true_on_success(self, tmp_path): mock_slide.shapes.add_movie.return_value = mock_shape mock_slide.shapes.__iter__ = MagicMock(return_value=iter([])) - with patch("embed_audio._find_audio_shape_id", return_value=None): - result = embed_slide_audio(mock_slide, wav) + mocker.patch("embed_audio._find_audio_shape_id", return_value=None) + result = embed_slide_audio(mock_slide, wav) assert result is True def test_returns_false_on_exception(self, tmp_path): diff --git a/.github/skills/experimental/tts-voiceover/uv.lock b/.github/skills/experimental/tts-voiceover/uv.lock index c015af361..809fac788 100644 --- a/.github/skills/experimental/tts-voiceover/uv.lock +++ b/.github/skills/experimental/tts-voiceover/uv.lock @@ -711,6 +711,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + [[package]] name = "python-pptx" version = "1.0.2" @@ -891,6 +903,7 @@ dependencies = [ dev = [ { name = "pytest" }, { name = "pytest-cov" }, + { name = "pytest-mock" }, { name = "ruff" }, ] fuzz = [ @@ -910,6 +923,7 @@ requires-dist = [ dev = [ { name = "pytest", specifier = ">=9.0" }, { name = "pytest-cov", specifier = ">=5.0" }, + { name = "pytest-mock", specifier = ">=3.14" }, { name = "ruff", specifier = ">=0.15" }, ] fuzz = [{ name = "atheris", specifier = ">=3.0" }] From 259a19f8b63d2f6feffbfaea5d7c89f98f3a0df0 Mon Sep 17 00:00:00 2001 From: auyidi Date: Thu, 30 Apr 2026 03:26:36 +0000 Subject: [PATCH 32/68] style(skills): move #Requires after copyright headers per PS conventions --- .../experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 | 3 +-- .../tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 b/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 index a7e7bc6bb..6eb666fa9 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 +++ b/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 @@ -1,13 +1,12 @@ #!/usr/bin/env pwsh # Copyright (c) Microsoft Corporation. # SPDX-License-Identifier: MIT +#Requires -Version 7.0 # # Invoke-EmbedAudio.ps1 # # Purpose: Wrapper that manages uv venv setup and delegates to embed_audio.py -#Requires -Version 7.0 - <# .SYNOPSIS Embeds per-slide WAV voice-over files into a PowerPoint deck. diff --git a/.github/skills/experimental/tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 b/.github/skills/experimental/tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 index 8133a8472..5e4c10d28 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 +++ b/.github/skills/experimental/tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 @@ -1,13 +1,12 @@ #!/usr/bin/env pwsh # Copyright (c) Microsoft Corporation. # SPDX-License-Identifier: MIT +#Requires -Version 7.0 # # Invoke-GenerateVoiceover.ps1 # # Purpose: Wrapper that manages uv venv setup and delegates to generate_voiceover.py -#Requires -Version 7.0 - <# .SYNOPSIS Generates per-slide TTS voice-over from YAML speaker notes via Azure Speech SDK. From d167b6a87d405181070f4faf344f0d5d8a6bbf2a Mon Sep 17 00:00:00 2001 From: auyidi Date: Thu, 30 Apr 2026 03:28:58 +0000 Subject: [PATCH 33/68] test(skills): add test_generate_voiceover.py for tts-voiceover skill - TestResolveLexicon: explicit arg, content dir fallback, default path - TestCreateParser: defaults, dry-run flag, custom voice - TestRunDryRun: valid content, missing dir, empty notes skipping --- .../tests/test_generate_voiceover.py | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 .github/skills/experimental/tts-voiceover/tests/test_generate_voiceover.py diff --git a/.github/skills/experimental/tts-voiceover/tests/test_generate_voiceover.py b/.github/skills/experimental/tts-voiceover/tests/test_generate_voiceover.py new file mode 100644 index 000000000..082d28bfa --- /dev/null +++ b/.github/skills/experimental/tts-voiceover/tests/test_generate_voiceover.py @@ -0,0 +1,142 @@ +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT +"""Tests for generate_voiceover module.""" + +from pathlib import Path + +import yaml +from generate_voiceover import ( + _resolve_lexicon, + create_parser, +) + + +class TestResolveLexicon: + """Tests for _resolve_lexicon.""" + + def test_given_explicit_arg_when_resolved_then_returns_arg(self, tmp_path): + explicit = tmp_path / "custom.yaml" + result = _resolve_lexicon(explicit, tmp_path) + assert result == explicit + + def test_given_content_dir_lexicon_when_resolved_then_returns_it(self, tmp_path): + lexicon = tmp_path / "acronyms.yaml" + lexicon.write_text("acronyms:\n FOO: bar\n", encoding="utf-8") + result = _resolve_lexicon(None, tmp_path) + assert result == lexicon + + def test_given_no_lexicon_and_no_content_file_when_resolved_then_returns_default( + self, + ): + result = _resolve_lexicon(None, Path("/nonexistent")) + assert result == Path("acronyms.yaml") + + +class TestCreateParser: + """Tests for create_parser.""" + + def test_given_defaults_when_parsed_then_has_expected_values(self): + parser = create_parser() + args = parser.parse_args(["--content-dir", "c", "--output-dir", "o"]) + assert str(args.content_dir) == "c" + assert str(args.output_dir) == "o" + assert args.dry_run is False + assert args.voice is not None + assert args.rate is not None + + def test_given_dry_run_flag_when_parsed_then_dry_run_true(self): + parser = create_parser() + args = parser.parse_args( + ["--content-dir", "c", "--output-dir", "o", "--dry-run"] + ) + assert args.dry_run is True + + def test_given_custom_voice_when_parsed_then_voice_set(self): + parser = create_parser() + args = parser.parse_args( + [ + "--content-dir", + "c", + "--output-dir", + "o", + "--voice", + "en-US-Jenny", + ] + ) + assert args.voice == "en-US-Jenny" + + +class TestRunDryRun: + """Tests for _run in dry-run mode.""" + + def test_given_valid_content_when_dry_run_then_returns_success(self, tmp_path): + from generate_voiceover import _run + + content = tmp_path / "content" + slide = content / "slide-001" + slide.mkdir(parents=True) + (slide / "content.yaml").write_text( + yaml.dump( + { + "slide": 1, + "title": "Test", + "speaker_notes": "Hello world", + } + ), + encoding="utf-8", + ) + output = tmp_path / "output" + + parser = create_parser() + args = parser.parse_args( + [ + "--content-dir", + str(content), + "--output-dir", + str(output), + "--dry-run", + ] + ) + rc = _run(args) + assert rc == 0 + + def test_given_missing_content_dir_when_run_then_returns_failure(self, tmp_path): + from generate_voiceover import _run + + parser = create_parser() + args = parser.parse_args( + [ + "--content-dir", + str(tmp_path / "missing"), + "--output-dir", + str(tmp_path / "out"), + "--dry-run", + ] + ) + rc = _run(args) + assert rc == 1 + + def test_given_empty_notes_when_dry_run_then_slide_skipped(self, tmp_path, capsys): + from generate_voiceover import _run + + content = tmp_path / "content" + slide = content / "slide-001" + slide.mkdir(parents=True) + (slide / "content.yaml").write_text( + yaml.dump({"slide": 1, "title": "Empty", "speaker_notes": ""}), + encoding="utf-8", + ) + output = tmp_path / "output" + + parser = create_parser() + args = parser.parse_args( + [ + "--content-dir", + str(content), + "--output-dir", + str(output), + "--dry-run", + ] + ) + rc = _run(args) + assert rc == 0 From 1bc89da19286bfd124320de325376eb5cfd1a6fc Mon Sep 17 00:00:00 2001 From: auyidi Date: Thu, 30 Apr 2026 03:41:20 +0000 Subject: [PATCH 34/68] fix(skills): log exception type in embed_slide_audio catch block --- .../experimental/tts-voiceover/scripts/embed_audio.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py index 0630cef71..af036715f 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py +++ b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py @@ -148,8 +148,10 @@ def embed_slide_audio(slide: object, wav_path: Path) -> bool: _add_narration_timing(slide, shape_id, duration_ms) _set_slide_transition(slide, duration_ms) return True - except Exception: - logger.exception("Failed to embed audio %s", wav_path.name) + except Exception as exc: # noqa: BLE001 + logger.exception( + "Failed to embed audio %s (%s)", wav_path.name, type(exc).__name__ + ) return False From c89fb96de5b82b12abeed1a3d8553aaf9fc8b0a9 Mon Sep 17 00:00:00 2001 From: auyidi Date: Thu, 30 Apr 2026 03:42:33 +0000 Subject: [PATCH 35/68] fix(skills): remove non-standard metadata fields from SKILL.md frontmatter --- .github/skills/experimental/tts-voiceover/SKILL.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/SKILL.md b/.github/skills/experimental/tts-voiceover/SKILL.md index 53f53c9d5..6ad584069 100644 --- a/.github/skills/experimental/tts-voiceover/SKILL.md +++ b/.github/skills/experimental/tts-voiceover/SKILL.md @@ -4,8 +4,6 @@ description: 'Text-to-speech voice-over generation from YAML speaker notes using metadata: authors: "microsoft/hve-core" spec_version: "1.0" - requires_python: ">=3.11" - platform: "Cross-platform (Windows, macOS, Linux)" --- # TTS Voice Over Skill From 05502c5a0635e658d5e566c0ddf93d05045e0645 Mon Sep 17 00:00:00 2001 From: auyidi Date: Thu, 30 Apr 2026 03:44:37 +0000 Subject: [PATCH 36/68] fix(skills): return EXIT_FAILURE when audio synthesis fails for any slide --- .../tts-voiceover/scripts/generate_voiceover.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py index 66486f061..0c6f56a4c 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py +++ b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py @@ -269,6 +269,7 @@ def _run(args: argparse.Namespace) -> int: total_duration = 0.0 slide_count = 0 + failed_count = 0 for slide_dir in sorted(content_dir.glob("slide-*")): content_file = slide_dir / "content.yaml" @@ -326,6 +327,7 @@ def _run(args: argparse.Namespace) -> int: logger.info(" %s — %.1fs", wav_path.name, duration) else: logger.error(" FAILED: %s", wav_path.name) + failed_count += 1 if args.dry_run: print(f"\n--- Dry run complete: {slide_count} slides processed ---") @@ -336,8 +338,10 @@ def _run(args: argparse.Namespace) -> int: total_duration / 60, slide_count, ) + if failed_count: + logger.error("%d slide(s) failed synthesis", failed_count) - return EXIT_SUCCESS + return EXIT_FAILURE if failed_count > 0 else EXIT_SUCCESS if __name__ == "__main__": From 3761c6709fabcedd69adef0c4a562c42cdf3670a Mon Sep 17 00:00:00 2001 From: auyidi Date: Thu, 30 Apr 2026 04:02:02 +0000 Subject: [PATCH 37/68] fix(skills): address final review items for tts-voiceover - SKILL.md: fix literal backslash-n in footer, restore proper newlines - Invoke-EmbedAudio.ps1: add .NOTES section to comment-based help - Invoke-GenerateVoiceover.ps1: add .NOTES section to comment-based help - test_embed_audio.py: rename methods to BDD test_given_when_then format --- .github/skills/experimental/tts-voiceover/SKILL.md | 4 +++- .../tts-voiceover/scripts/Invoke-EmbedAudio.ps1 | 4 ++++ .../scripts/Invoke-GenerateVoiceover.ps1 | 4 ++++ .../tts-voiceover/tests/test_embed_audio.py | 12 ++++++------ 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/SKILL.md b/.github/skills/experimental/tts-voiceover/SKILL.md index 6ad584069..8f20741a3 100644 --- a/.github/skills/experimental/tts-voiceover/SKILL.md +++ b/.github/skills/experimental/tts-voiceover/SKILL.md @@ -178,4 +178,6 @@ Each `content.yaml` should contain a `speaker_notes:` field with the narration t | Audio icon visible in PPTX | Reposition or resize the audio object in PowerPoint after embedding. | | Video export shows "No timings recorded" | Re-embed audio with the updated `embed_audio.py` which adds narration timing XML automatically. | -> Brought to you by microsoft/hve-core\n\n*🤖 Crafted with precision by ✨Copilot following brilliant human instruction, then carefully refined by our team of discerning human reviewers.* +> Brought to you by microsoft/hve-core + +*🤖 Crafted with precision by ✨Copilot following brilliant human instruction, then carefully refined by our team of discerning human reviewers.* diff --git a/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 b/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 index 6eb666fa9..490dfe1d8 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 +++ b/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 @@ -32,6 +32,10 @@ .EXAMPLE ./Invoke-EmbedAudio.ps1 -InputPath deck.pptx -AudioDir voice-over -OutputPath deck-narrated.pptx + +.NOTES + Part of the tts-voiceover skill. Manages uv virtual environment setup + and delegates to embed_audio.py for WAV embedding into PPTX slides. #> [CmdletBinding()] diff --git a/.github/skills/experimental/tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 b/.github/skills/experimental/tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 index 5e4c10d28..b09898526 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 +++ b/.github/skills/experimental/tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 @@ -44,6 +44,10 @@ .EXAMPLE ./Invoke-GenerateVoiceover.ps1 -ContentDir content -Voice "en-US-Jenny:DragonHDLatestNeural" -Rate "+5%" + +.NOTES + Part of the tts-voiceover skill. Manages uv virtual environment setup + and delegates to generate_voiceover.py for TTS audio generation. #> [CmdletBinding()] diff --git a/.github/skills/experimental/tts-voiceover/tests/test_embed_audio.py b/.github/skills/experimental/tts-voiceover/tests/test_embed_audio.py index 8be7ed511..5464d5e25 100644 --- a/.github/skills/experimental/tts-voiceover/tests/test_embed_audio.py +++ b/.github/skills/experimental/tts-voiceover/tests/test_embed_audio.py @@ -29,13 +29,13 @@ def _make_wav(tmp_path: Path, name: str = "test.wav", duration_ms: int = 100) -> class TestGetWavDurationMs: """Tests for get_wav_duration_ms.""" - def test_returns_duration_with_buffer(self, tmp_path): + def test_given_1s_wav_when_get_duration_then_includes_buffer(self, tmp_path): wav = _make_wav(tmp_path, duration_ms=1000) result = get_wav_duration_ms(wav) # 1000ms audio + 1500ms buffer = ~2500ms assert 2400 <= result <= 2600 - def test_short_file(self, tmp_path): + def test_given_short_wav_when_get_duration_then_includes_buffer(self, tmp_path): wav = _make_wav(tmp_path, duration_ms=50) result = get_wav_duration_ms(wav) # 50ms audio + 1500ms buffer = ~1550ms @@ -45,7 +45,7 @@ def test_short_file(self, tmp_path): class TestAddNarrationTiming: """Tests for _add_narration_timing.""" - def test_appends_timing_element(self): + def test_given_slide_xml_when_add_timing_then_timing_element_appended(self): """Verify p:timing is added with the correct spid attribute.""" from lxml import etree @@ -66,7 +66,7 @@ def test_appends_timing_element(self): assert 'spid="42"' in xml_str assert 'dur="5000"' in xml_str - def test_replaces_existing_timing(self): + def test_given_existing_timing_when_add_timing_then_old_replaced(self): """Verify existing p:timing is removed before adding new one.""" from lxml import etree @@ -90,7 +90,7 @@ def test_replaces_existing_timing(self): class TestEmbedSlideAudio: """Tests for embed_slide_audio.""" - def test_returns_true_on_success(self, tmp_path, mocker): + def test_given_valid_slide_when_embed_then_returns_true(self, tmp_path, mocker): wav = _make_wav(tmp_path) mock_slide = MagicMock() mock_shape = MagicMock() @@ -102,7 +102,7 @@ def test_returns_true_on_success(self, tmp_path, mocker): result = embed_slide_audio(mock_slide, wav) assert result is True - def test_returns_false_on_exception(self, tmp_path): + def test_given_exception_when_embed_audio_then_returns_false(self, tmp_path): wav = _make_wav(tmp_path) mock_slide = MagicMock() mock_slide.shapes.add_movie.side_effect = RuntimeError("test error") From b934e49e6b9638a12c3b87668b17a285e55fcc2d Mon Sep 17 00:00:00 2001 From: auyidi Date: Thu, 30 Apr 2026 04:19:54 +0000 Subject: [PATCH 38/68] docs(skills): add input contract and lexicon constraint to apply_acronym_aliases docstring --- .../tts-voiceover/scripts/generate_voiceover.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py index 0c6f56a4c..dc3412018 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py +++ b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py @@ -72,6 +72,15 @@ def apply_acronym_aliases(text: str, acronyms: dict[str, str]) -> str: Uses a single-pass regex to avoid corrupting previously-inserted SSML tags when an acronym appears inside an alias value or tag content. + + **Input contract**: ``text`` must already be XML-escaped + (e.g. via ``xml.sax.saxutils.escape()``). The returned string is a + mix of XML-escaped character data and SSML ```` markup fragments + intended for embedding directly inside an SSML ```` element. + + **Lexicon constraint**: acronym keys containing XML-special characters + (``&``, ``<``, ``>``) will never match because the input text is + pre-escaped. Use only ASCII-safe acronym keys. """ if not acronyms: return text From ec423ead1daf710db79a929812d9f053af43cb49 Mon Sep 17 00:00:00 2001 From: auyidi Date: Thu, 30 Apr 2026 05:12:38 +0000 Subject: [PATCH 39/68] fix(skills): return False when audio shape not found, add type-safe lexicon filtering - embed_audio.py: embed_slide_audio returns False when _find_audio_shape_id returns None, preventing silent success without narration timing - generate_voiceover.py: load_acronyms coerces keys/values to str and filters None entries to prevent TypeError in apply_acronym_aliases - test_embed_audio.py: update test for new False return, add test for missing shape_id path --- .../tts-voiceover/scripts/embed_audio.py | 6 ++++++ .../scripts/generate_voiceover.py | 10 ++++++++-- .../tts-voiceover/tests/test_embed_audio.py | 18 +++++++++++++----- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py index af036715f..e8cf92846 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py +++ b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py @@ -147,6 +147,12 @@ def embed_slide_audio(slide: object, wav_path: Path) -> bool: duration_ms = get_wav_duration_ms(wav_path) _add_narration_timing(slide, shape_id, duration_ms) _set_slide_transition(slide, duration_ms) + else: + logger.warning( + "Could not find audio shape for %s; narration timing not set", + wav_path.name, + ) + return False return True except Exception as exc: # noqa: BLE001 logger.exception( diff --git a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py index dc3412018..cf28e5b0f 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py +++ b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py @@ -61,8 +61,14 @@ def load_acronyms(path: Path) -> dict[str, str]: data = yaml.safe_load(path.read_text(encoding="utf-8")) acronyms = data.get("acronyms") if isinstance(data, dict) else None if isinstance(acronyms, dict): - logger.info("Loaded %d acronyms from %s", len(acronyms), path) - return acronyms + clean = { + str(k): str(v) + for k, v in acronyms.items() + if k is not None and v is not None + } + if clean: + logger.info("Loaded %d acronyms from %s", len(clean), path) + return clean logger.warning("Invalid acronyms format in %s; using defaults", path) return dict(_DEFAULT_ACRONYMS) diff --git a/.github/skills/experimental/tts-voiceover/tests/test_embed_audio.py b/.github/skills/experimental/tts-voiceover/tests/test_embed_audio.py index 5464d5e25..25c83790f 100644 --- a/.github/skills/experimental/tts-voiceover/tests/test_embed_audio.py +++ b/.github/skills/experimental/tts-voiceover/tests/test_embed_audio.py @@ -93,15 +93,23 @@ class TestEmbedSlideAudio: def test_given_valid_slide_when_embed_then_returns_true(self, tmp_path, mocker): wav = _make_wav(tmp_path) mock_slide = MagicMock() - mock_shape = MagicMock() - mock_shape.shape_id = 99 - mock_slide.shapes.add_movie.return_value = mock_shape - mock_slide.shapes.__iter__ = MagicMock(return_value=iter([])) + mock_slide.shapes.add_movie.return_value = MagicMock() - mocker.patch("embed_audio._find_audio_shape_id", return_value=None) + mocker.patch("embed_audio._find_audio_shape_id", return_value=42) + mocker.patch("embed_audio._add_narration_timing") + mocker.patch("embed_audio._set_slide_transition") result = embed_slide_audio(mock_slide, wav) assert result is True + def test_given_no_shape_id_when_embed_then_returns_false(self, tmp_path, mocker): + wav = _make_wav(tmp_path) + mock_slide = MagicMock() + mock_slide.shapes.add_movie.return_value = MagicMock() + + mocker.patch("embed_audio._find_audio_shape_id", return_value=None) + result = embed_slide_audio(mock_slide, wav) + assert result is False + def test_given_exception_when_embed_audio_then_returns_false(self, tmp_path): wav = _make_wav(tmp_path) mock_slide = MagicMock() From ebdb8dd59f4b27fe3e4866d302a33900e02f9020 Mon Sep 17 00:00:00 2001 From: auyidi Date: Thu, 30 Apr 2026 05:15:51 +0000 Subject: [PATCH 40/68] fix(skills): move Copilot footer above attribution so attribution is last line --- .github/skills/experimental/tts-voiceover/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/SKILL.md b/.github/skills/experimental/tts-voiceover/SKILL.md index 8f20741a3..e2955a0f5 100644 --- a/.github/skills/experimental/tts-voiceover/SKILL.md +++ b/.github/skills/experimental/tts-voiceover/SKILL.md @@ -178,6 +178,6 @@ Each `content.yaml` should contain a `speaker_notes:` field with the narration t | Audio icon visible in PPTX | Reposition or resize the audio object in PowerPoint after embedding. | | Video export shows "No timings recorded" | Re-embed audio with the updated `embed_audio.py` which adds narration timing XML automatically. | -> Brought to you by microsoft/hve-core - *🤖 Crafted with precision by ✨Copilot following brilliant human instruction, then carefully refined by our team of discerning human reviewers.* + +> Brought to you by microsoft/hve-core From db59e6dd1eedb6b6ceea43685345e29f5ff4464b Mon Sep 17 00:00:00 2001 From: auyidi Date: Thu, 30 Apr 2026 05:31:49 +0000 Subject: [PATCH 41/68] fix(skills): XML-escape fuzz inputs, use Slide type hint, remove unused noqa - fuzz_harness.py: apply xml.sax.saxutils.escape() to text before calling apply_acronym_aliases and wrap_ssml (honours input contract) - embed_audio.py: replace object type hints with pptx.slide.Slide, replace noqa: BLE001 with descriptive comment (BLE not in ruff select) --- .../experimental/tts-voiceover/scripts/embed_audio.py | 11 ++++++----- .../experimental/tts-voiceover/tests/fuzz_harness.py | 7 +++++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py index e8cf92846..d376a9c8a 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py +++ b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py @@ -25,6 +25,7 @@ from lxml import etree from pptx import Presentation from pptx.oxml.ns import qn +from pptx.slide import Slide from pptx.util import Inches logger = logging.getLogger(__name__) @@ -46,7 +47,7 @@ def get_wav_duration_ms(wav_path: Path) -> int: return int((frames / float(rate)) * 1000) + TIMING_BUFFER_MS -def _add_narration_timing(slide: object, shape_id: int, duration_ms: int) -> None: +def _add_narration_timing(slide: Slide, shape_id: int, duration_ms: int) -> None: """Add auto-play narration timing XML to a slide. Creates the p:timing element structure that PowerPoint generates @@ -90,7 +91,7 @@ def _add_narration_timing(slide: object, shape_id: int, duration_ms: int) -> Non slide._element.append(etree.fromstring(timing_xml)) -def _set_slide_transition(slide: object, duration_ms: int) -> None: +def _set_slide_transition(slide: Slide, duration_ms: int) -> None: """Set slide auto-advance timing after audio duration.""" existing = slide._element.find(qn("p:transition")) if existing is not None: @@ -107,7 +108,7 @@ def _set_slide_transition(slide: object, duration_ms: int) -> None: slide._element.append(transition) -def _find_audio_shape_id(slide: object) -> int | None: +def _find_audio_shape_id(slide: Slide) -> int | None: """Find the shape ID of the audio/movie shape on a slide.""" for shape in slide.shapes: sp = shape._element @@ -125,7 +126,7 @@ def _find_audio_shape_id(slide: object) -> int | None: return None -def embed_slide_audio(slide: object, wav_path: Path) -> bool: +def embed_slide_audio(slide: Slide, wav_path: Path) -> bool: """Embed a WAV file into a slide as a media object. Adds narration timing XML and slide auto-advance so PowerPoint @@ -154,7 +155,7 @@ def embed_slide_audio(slide: object, wav_path: Path) -> bool: ) return False return True - except Exception as exc: # noqa: BLE001 + except Exception as exc: # python-pptx raises varied internal exceptions logger.exception( "Failed to embed audio %s (%s)", wav_path.name, type(exc).__name__ ) diff --git a/.github/skills/experimental/tts-voiceover/tests/fuzz_harness.py b/.github/skills/experimental/tts-voiceover/tests/fuzz_harness.py index 2953747e7..c2feb1e33 100644 --- a/.github/skills/experimental/tts-voiceover/tests/fuzz_harness.py +++ b/.github/skills/experimental/tts-voiceover/tests/fuzz_harness.py @@ -10,6 +10,7 @@ import sys import tempfile +import xml.sax.saxutils from contextlib import suppress from pathlib import Path @@ -35,7 +36,8 @@ def fuzz_apply_acronym_aliases(data): """Fuzz apply_acronym_aliases with random text and the default acronym dict.""" fdp = atheris.FuzzedDataProvider(data) - text = fdp.ConsumeUnicodeNoSurrogates(500) + raw_text = fdp.ConsumeUnicodeNoSurrogates(500) + text = xml.sax.saxutils.escape(raw_text) with suppress(ValueError, TypeError): apply_acronym_aliases(text, dict(_DEFAULT_ACRONYMS)) @@ -43,7 +45,8 @@ def fuzz_apply_acronym_aliases(data): def fuzz_wrap_ssml(data): """Fuzz wrap_ssml with random text, voice, and rate strings.""" fdp = atheris.FuzzedDataProvider(data) - text = fdp.ConsumeUnicodeNoSurrogates(200) + raw_text = fdp.ConsumeUnicodeNoSurrogates(200) + text = xml.sax.saxutils.escape(raw_text) voice = fdp.ConsumeUnicodeNoSurrogates(50) rate = fdp.ConsumeUnicodeNoSurrogates(10) with suppress(ValueError, TypeError): From 0c845906c1bd21aca99b678e5301fce7811f0525 Mon Sep 17 00:00:00 2001 From: auyidi Date: Thu, 30 Apr 2026 05:58:12 +0000 Subject: [PATCH 42/68] fix(skills): add ValidateNotNullOrEmpty, fix Pester skip, cache regex - Invoke-EmbedAudio.ps1: add [ValidateNotNullOrEmpty()] to $InputPath - TtsVoiceoverHelpers.psm1: add [ValidateNotNullOrEmpty()] to $SkillRoot and $VenvDir - TtsVoiceoverHelpers.Tests.ps1: replace if/Set-ItResult with -Skip on It block, add symmetric Windows test context - generate_voiceover.py: cache compiled regex via lru_cache, tighten _make_entra_config return type to tuple[Any, float] --- .../scripts/Invoke-EmbedAudio.ps1 | 1 + .../scripts/Modules/TtsVoiceoverHelpers.psm1 | 2 ++ .../scripts/generate_voiceover.py | 14 ++++++++++---- .../tests/skills/TtsVoiceoverHelpers.Tests.ps1 | 18 ++++++++++-------- 4 files changed, 23 insertions(+), 12 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 b/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 index 490dfe1d8..5a0d02610 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 +++ b/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 @@ -41,6 +41,7 @@ [CmdletBinding()] param( [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] [string]$InputPath, [Parameter()] diff --git a/.github/skills/experimental/tts-voiceover/scripts/Modules/TtsVoiceoverHelpers.psm1 b/.github/skills/experimental/tts-voiceover/scripts/Modules/TtsVoiceoverHelpers.psm1 index 313a842ba..1eb47b08c 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/Modules/TtsVoiceoverHelpers.psm1 +++ b/.github/skills/experimental/tts-voiceover/scripts/Modules/TtsVoiceoverHelpers.psm1 @@ -43,6 +43,7 @@ function Initialize-PythonEnvironment { [OutputType([System.Void])] param( [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] [string]$SkillRoot ) @@ -67,6 +68,7 @@ function Get-VenvPythonPath { [OutputType([string])] param( [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] [string]$VenvDir ) diff --git a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py index cf28e5b0f..d313d5a06 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py +++ b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py @@ -16,6 +16,7 @@ from __future__ import annotations import argparse +import functools import logging import os import re @@ -73,6 +74,13 @@ def load_acronyms(path: Path) -> dict[str, str]: return dict(_DEFAULT_ACRONYMS) +@functools.lru_cache(maxsize=8) +def _compile_acronym_pattern(keys: tuple[str, ...]) -> re.Pattern[str]: + """Compile and cache a regex matching all acronym keys, longest first.""" + sorted_keys = sorted(keys, key=len, reverse=True) + return re.compile("|".join(re.escape(k) for k in sorted_keys)) + + def apply_acronym_aliases(text: str, acronyms: dict[str, str]) -> str: """Replace acronyms with SSML ```` elements. @@ -90,9 +98,7 @@ def apply_acronym_aliases(text: str, acronyms: dict[str, str]) -> str: """ if not acronyms: return text - # Build pattern matching all acronyms, longest first - sorted_keys = sorted(acronyms.keys(), key=len, reverse=True) - pattern = re.compile("|".join(re.escape(k) for k in sorted_keys)) + pattern = _compile_acronym_pattern(tuple(acronyms.keys())) def _replace(m: re.Match) -> str: acronym = m.group(0) @@ -143,7 +149,7 @@ def _make_entra_config( credential: Any, resource_id: str, region: str, -) -> tuple: +) -> tuple[Any, float]: """Create a SpeechConfig with a fresh Entra ID token. Returns (config, expires_at). diff --git a/scripts/tests/skills/TtsVoiceoverHelpers.Tests.ps1 b/scripts/tests/skills/TtsVoiceoverHelpers.Tests.ps1 index 83f1f73b2..96ef333f4 100644 --- a/scripts/tests/skills/TtsVoiceoverHelpers.Tests.ps1 +++ b/scripts/tests/skills/TtsVoiceoverHelpers.Tests.ps1 @@ -95,14 +95,16 @@ Describe 'Initialize-PythonEnvironment' -Tag 'Unit' { Describe 'Get-VenvPythonPath' -Tag 'Unit' { Context 'On non-Windows platforms' { - It 'Returns bin/python path' { - # Only run on non-Windows - if (-not $IsWindows) { - $result = Get-VenvPythonPath -VenvDir '/tmp/test-venv' - $result | Should -Be '/tmp/test-venv/bin/python' - } else { - Set-ItResult -Skipped -Because 'Test runs on Linux/macOS only' - } + It 'Returns bin/python path' -Skip:($IsWindows) { + $result = Get-VenvPythonPath -VenvDir '/tmp/test-venv' + $result | Should -Be '/tmp/test-venv/bin/python' + } + } + + Context 'On Windows' { + It 'Returns Scripts/python.exe path' -Skip:(-not $IsWindows) { + $result = Get-VenvPythonPath -VenvDir 'C:\venv' + $result | Should -Be 'C:\venv\Scripts/python.exe' } } From 79b35f19b14bbd8d5925916ff3ad8e0008b6f068 Mon Sep 17 00:00:00 2001 From: auyidi Date: Thu, 30 Apr 2026 06:23:49 +0000 Subject: [PATCH 43/68] style(skills): remove non-standard module-level synopsis block from TtsVoiceoverHelpers --- .../scripts/Modules/TtsVoiceoverHelpers.psm1 | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/scripts/Modules/TtsVoiceoverHelpers.psm1 b/.github/skills/experimental/tts-voiceover/scripts/Modules/TtsVoiceoverHelpers.psm1 index 1eb47b08c..ca923dee9 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/Modules/TtsVoiceoverHelpers.psm1 +++ b/.github/skills/experimental/tts-voiceover/scripts/Modules/TtsVoiceoverHelpers.psm1 @@ -4,16 +4,6 @@ # Purpose: Shared helper functions for tts-voiceover skill PowerShell wrappers. #Requires -Version 7.0 -<# -.SYNOPSIS - Shared helper functions for tts-voiceover skill PowerShell wrappers. - -.DESCRIPTION - Provides uv availability checking, Python virtual environment setup, - and venv Python path resolution used by both Invoke-GenerateVoiceover.ps1 - and Invoke-EmbedAudio.ps1. -#> - function Test-UvAvailability { <# .SYNOPSIS From 204fdd6cfef018198cc8b63cab88b9bbfc68d5ed Mon Sep 17 00:00:00 2001 From: auyidi Date: Thu, 30 Apr 2026 07:05:12 +0000 Subject: [PATCH 44/68] fix(skills): fix sidebar_position collision and add AAA test structure - docs/getting-started/tts-voiceover.md: change sidebar_position from 5 to 9 to avoid collision with first-research.md - test_embed_audio.py: add # Arrange / # Act / # Assert comments to all multi-phase tests per python-test.instructions.md - test_generate_voiceover.py: same AAA structure applied throughout --- .../tts-voiceover/tests/test_embed_audio.py | 37 ++++++++++++++---- .../tests/test_generate_voiceover.py | 39 ++++++++++++++++++- docs/getting-started/tts-voiceover.md | 2 +- 3 files changed, 68 insertions(+), 10 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/tests/test_embed_audio.py b/.github/skills/experimental/tts-voiceover/tests/test_embed_audio.py index 25c83790f..f0258a0f1 100644 --- a/.github/skills/experimental/tts-voiceover/tests/test_embed_audio.py +++ b/.github/skills/experimental/tts-voiceover/tests/test_embed_audio.py @@ -30,15 +30,23 @@ class TestGetWavDurationMs: """Tests for get_wav_duration_ms.""" def test_given_1s_wav_when_get_duration_then_includes_buffer(self, tmp_path): + # Arrange wav = _make_wav(tmp_path, duration_ms=1000) + + # Act result = get_wav_duration_ms(wav) - # 1000ms audio + 1500ms buffer = ~2500ms + + # Assert — 1000ms audio + 1500ms buffer = ~2500ms assert 2400 <= result <= 2600 def test_given_short_wav_when_get_duration_then_includes_buffer(self, tmp_path): + # Arrange wav = _make_wav(tmp_path, duration_ms=50) + + # Act result = get_wav_duration_ms(wav) - # 50ms audio + 1500ms buffer = ~1550ms + + # Assert — 50ms audio + 1500ms buffer = ~1550ms assert result >= 1500 @@ -49,19 +57,20 @@ def test_given_slide_xml_when_add_timing_then_timing_element_appended(self): """Verify p:timing is added with the correct spid attribute.""" from lxml import etree + # Arrange nsmap = {"p": "http://schemas.openxmlformats.org/presentationml/2006/main"} slide_xml = etree.Element(f"{{{nsmap['p']}}}sld", nsmap=nsmap) mock_slide = MagicMock() mock_slide._element = slide_xml + # Act _add_narration_timing(mock_slide, shape_id=42, duration_ms=5000) + # Assert timing = slide_xml.find( "{http://schemas.openxmlformats.org/presentationml/2006/main}timing" ) assert timing is not None - - # Verify spid references the correct shape xml_str = etree.tostring(timing, encoding="unicode") assert 'spid="42"' in xml_str assert 'dur="5000"' in xml_str @@ -70,16 +79,18 @@ def test_given_existing_timing_when_add_timing_then_old_replaced(self): """Verify existing p:timing is removed before adding new one.""" from lxml import etree + # Arrange ns = "http://schemas.openxmlformats.org/presentationml/2006/main" slide_xml = etree.Element(f"{{{ns}}}sld") old_timing = etree.SubElement(slide_xml, f"{{{ns}}}timing") etree.SubElement(old_timing, "old-content") - mock_slide = MagicMock() mock_slide._element = slide_xml + # Act _add_narration_timing(mock_slide, shape_id=10, duration_ms=3000) + # Assert timings = slide_xml.findall(f"{{{ns}}}timing") assert len(timings) == 1 xml_str = etree.tostring(timings[0], encoding="unicode") @@ -91,29 +102,41 @@ class TestEmbedSlideAudio: """Tests for embed_slide_audio.""" def test_given_valid_slide_when_embed_then_returns_true(self, tmp_path, mocker): + # Arrange wav = _make_wav(tmp_path) mock_slide = MagicMock() mock_slide.shapes.add_movie.return_value = MagicMock() - mocker.patch("embed_audio._find_audio_shape_id", return_value=42) mocker.patch("embed_audio._add_narration_timing") mocker.patch("embed_audio._set_slide_transition") + + # Act result = embed_slide_audio(mock_slide, wav) + + # Assert assert result is True def test_given_no_shape_id_when_embed_then_returns_false(self, tmp_path, mocker): + # Arrange wav = _make_wav(tmp_path) mock_slide = MagicMock() mock_slide.shapes.add_movie.return_value = MagicMock() - mocker.patch("embed_audio._find_audio_shape_id", return_value=None) + + # Act result = embed_slide_audio(mock_slide, wav) + + # Assert assert result is False def test_given_exception_when_embed_audio_then_returns_false(self, tmp_path): + # Arrange wav = _make_wav(tmp_path) mock_slide = MagicMock() mock_slide.shapes.add_movie.side_effect = RuntimeError("test error") + # Act result = embed_slide_audio(mock_slide, wav) + + # Assert assert result is False diff --git a/.github/skills/experimental/tts-voiceover/tests/test_generate_voiceover.py b/.github/skills/experimental/tts-voiceover/tests/test_generate_voiceover.py index 082d28bfa..7e253118c 100644 --- a/.github/skills/experimental/tts-voiceover/tests/test_generate_voiceover.py +++ b/.github/skills/experimental/tts-voiceover/tests/test_generate_voiceover.py @@ -15,20 +15,33 @@ class TestResolveLexicon: """Tests for _resolve_lexicon.""" def test_given_explicit_arg_when_resolved_then_returns_arg(self, tmp_path): + # Arrange explicit = tmp_path / "custom.yaml" + + # Act result = _resolve_lexicon(explicit, tmp_path) + + # Assert assert result == explicit def test_given_content_dir_lexicon_when_resolved_then_returns_it(self, tmp_path): + # Arrange lexicon = tmp_path / "acronyms.yaml" lexicon.write_text("acronyms:\n FOO: bar\n", encoding="utf-8") + + # Act result = _resolve_lexicon(None, tmp_path) + + # Assert assert result == lexicon def test_given_no_lexicon_and_no_content_file_when_resolved_then_returns_default( self, ): + # Act result = _resolve_lexicon(None, Path("/nonexistent")) + + # Assert assert result == Path("acronyms.yaml") @@ -36,8 +49,11 @@ class TestCreateParser: """Tests for create_parser.""" def test_given_defaults_when_parsed_then_has_expected_values(self): + # Act parser = create_parser() args = parser.parse_args(["--content-dir", "c", "--output-dir", "o"]) + + # Assert assert str(args.content_dir) == "c" assert str(args.output_dir) == "o" assert args.dry_run is False @@ -45,13 +61,17 @@ def test_given_defaults_when_parsed_then_has_expected_values(self): assert args.rate is not None def test_given_dry_run_flag_when_parsed_then_dry_run_true(self): + # Act parser = create_parser() args = parser.parse_args( ["--content-dir", "c", "--output-dir", "o", "--dry-run"] ) + + # Assert assert args.dry_run is True def test_given_custom_voice_when_parsed_then_voice_set(self): + # Act parser = create_parser() args = parser.parse_args( [ @@ -63,6 +83,8 @@ def test_given_custom_voice_when_parsed_then_voice_set(self): "en-US-Jenny", ] ) + + # Assert assert args.voice == "en-US-Jenny" @@ -72,6 +94,7 @@ class TestRunDryRun: def test_given_valid_content_when_dry_run_then_returns_success(self, tmp_path): from generate_voiceover import _run + # Arrange content = tmp_path / "content" slide = content / "slide-001" slide.mkdir(parents=True) @@ -86,7 +109,6 @@ def test_given_valid_content_when_dry_run_then_returns_success(self, tmp_path): encoding="utf-8", ) output = tmp_path / "output" - parser = create_parser() args = parser.parse_args( [ @@ -97,12 +119,17 @@ def test_given_valid_content_when_dry_run_then_returns_success(self, tmp_path): "--dry-run", ] ) + + # Act rc = _run(args) + + # Assert assert rc == 0 def test_given_missing_content_dir_when_run_then_returns_failure(self, tmp_path): from generate_voiceover import _run + # Arrange parser = create_parser() args = parser.parse_args( [ @@ -113,12 +140,17 @@ def test_given_missing_content_dir_when_run_then_returns_failure(self, tmp_path) "--dry-run", ] ) + + # Act rc = _run(args) + + # Assert assert rc == 1 def test_given_empty_notes_when_dry_run_then_slide_skipped(self, tmp_path, capsys): from generate_voiceover import _run + # Arrange content = tmp_path / "content" slide = content / "slide-001" slide.mkdir(parents=True) @@ -127,7 +159,6 @@ def test_given_empty_notes_when_dry_run_then_slide_skipped(self, tmp_path, capsy encoding="utf-8", ) output = tmp_path / "output" - parser = create_parser() args = parser.parse_args( [ @@ -138,5 +169,9 @@ def test_given_empty_notes_when_dry_run_then_slide_skipped(self, tmp_path, capsy "--dry-run", ] ) + + # Act rc = _run(args) + + # Assert assert rc == 0 diff --git a/docs/getting-started/tts-voiceover.md b/docs/getting-started/tts-voiceover.md index 8403f522e..026088a40 100644 --- a/docs/getting-started/tts-voiceover.md +++ b/docs/getting-started/tts-voiceover.md @@ -1,7 +1,7 @@ --- title: TTS Voice-Over Skill description: Generate per-slide WAV voice-over files from YAML speaker notes using Azure Speech SDK -sidebar_position: 5 +sidebar_position: 9 author: Microsoft ms.date: 2026-04-23 ms.topic: how-to From 19f5d0c01fe7ee1902884264c82ea15e49125eee Mon Sep 17 00:00:00 2001 From: auyidi Date: Thu, 30 Apr 2026 07:32:53 +0000 Subject: [PATCH 45/68] fix(skills): wrap token refresh in try/except, fix OutputType convention - generate_voiceover.py: wrap mid-loop _make_entra_config call in try/except to prevent uncaught exceptions on network/auth errors during long slide decks - TtsVoiceoverHelpers.psm1: change [OutputType([System.Void])] to [OutputType([void])] per PS conventions --- .../scripts/Modules/TtsVoiceoverHelpers.psm1 | 2 +- .../tts-voiceover/scripts/generate_voiceover.py | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/scripts/Modules/TtsVoiceoverHelpers.psm1 b/.github/skills/experimental/tts-voiceover/scripts/Modules/TtsVoiceoverHelpers.psm1 index ca923dee9..02afdc06f 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/Modules/TtsVoiceoverHelpers.psm1 +++ b/.github/skills/experimental/tts-voiceover/scripts/Modules/TtsVoiceoverHelpers.psm1 @@ -30,7 +30,7 @@ function Initialize-PythonEnvironment { Root directory of the skill containing pyproject.toml. #> [CmdletBinding()] - [OutputType([System.Void])] + [OutputType([void])] param( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] diff --git a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py index d313d5a06..30a291ba2 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py +++ b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py @@ -335,10 +335,13 @@ def _run(args: argparse.Namespace) -> int: and not speech_key and time.time() > token_expires_at - 300 ): - speech_config, token_expires_at = _make_entra_config( - speechsdk, credential, speech_resource_id, speech_region - ) - logger.info("Refreshed Entra ID token") + try: + speech_config, token_expires_at = _make_entra_config( + speechsdk, credential, speech_resource_id, speech_region + ) + logger.info("Refreshed Entra ID token") + except Exception: # network/auth errors during refresh + logger.exception("Token refresh failed; using existing token") wav_path = output_dir / f"{slide_dir.name}.wav" logger.info("Generating %s: %s ...", slide_dir.name, title) From 6086ee89f53d66160e086d85e48ca06ff6cb09e7 Mon Sep 17 00:00:00 2001 From: auyidi Date: Thu, 30 Apr 2026 10:59:59 +0000 Subject: [PATCH 46/68] refactor(skills): co-locate Pester test inside tts-voiceover skill package Move TtsVoiceoverHelpers.Tests.ps1 from scripts/tests/skills/ to .github/skills/experimental/tts-voiceover/tests/ to match the Python test layout and keep the skill self-contained per pester conventions. --- .../tts-voiceover/tests}/TtsVoiceoverHelpers.Tests.ps1 | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {scripts/tests/skills => .github/skills/experimental/tts-voiceover/tests}/TtsVoiceoverHelpers.Tests.ps1 (100%) diff --git a/scripts/tests/skills/TtsVoiceoverHelpers.Tests.ps1 b/.github/skills/experimental/tts-voiceover/tests/TtsVoiceoverHelpers.Tests.ps1 similarity index 100% rename from scripts/tests/skills/TtsVoiceoverHelpers.Tests.ps1 rename to .github/skills/experimental/tts-voiceover/tests/TtsVoiceoverHelpers.Tests.ps1 From 617550a48361c53c87daea123469ea9d0d3b581a Mon Sep 17 00:00:00 2001 From: auyidi Date: Thu, 30 Apr 2026 11:01:30 +0000 Subject: [PATCH 47/68] docs(skills): clarify lxml is a direct and transitive dependency --- .github/skills/experimental/tts-voiceover/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/skills/experimental/tts-voiceover/pyproject.toml b/.github/skills/experimental/tts-voiceover/pyproject.toml index 18323d906..69765178a 100644 --- a/.github/skills/experimental/tts-voiceover/pyproject.toml +++ b/.github/skills/experimental/tts-voiceover/pyproject.toml @@ -5,7 +5,7 @@ requires-python = ">=3.11" dependencies = [ "azure-cognitiveservices-speech>=1.41", "azure-identity>=1.19", - "lxml>=6.1.0", # pinned explicitly: transitive dep of python-pptx; ensures CVE patches + "lxml>=6.1.0", # direct dep (embed_audio.py) and transitive via python-pptx; explicit pin ensures CVE patches "python-pptx>=1.0", "pyyaml>=6.0", ] From 52899f2e6040b709dbdcd0c7415bde4ca53020f5 Mon Sep 17 00:00:00 2001 From: auyidi Date: Thu, 30 Apr 2026 23:45:42 +0000 Subject: [PATCH 48/68] fix(skills): align embed_audio exit code with generate_voiceover on partial failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - track failed_count and return EXIT_FAILURE when any slide embed fails - revert unrelated vscode-playwright maturity tags from collection YAMLs 🐛 - Generated by Copilot --- .../experimental/tts-voiceover/scripts/embed_audio.py | 9 +++++++++ collections/experimental.collection.yml | 1 - collections/hve-core-all.collection.yml | 1 - 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py index d376a9c8a..3f61207fb 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py +++ b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py @@ -222,6 +222,7 @@ def _run(args: argparse.Namespace) -> int: prs = Presentation(str(input_path)) embedded_count = 0 + failed_count = 0 for idx, slide in enumerate(prs.slides, start=1): wav_name = f"slide-{idx:03d}.wav" @@ -235,6 +236,7 @@ def _run(args: argparse.Namespace) -> int: logger.info("Embedded %s into slide %d", wav_name, idx) else: logger.error("FAILED to embed %s into slide %d", wav_name, idx) + failed_count += 1 output_path.parent.mkdir(parents=True, exist_ok=True) prs.save(str(output_path)) @@ -242,6 +244,13 @@ def _run(args: argparse.Namespace) -> int: if embedded_count == 0: return EXIT_FAILURE + if failed_count > 0: + logger.warning( + "Completed with %d failure(s); %d slide(s) embedded successfully.", + failed_count, + embedded_count, + ) + return EXIT_FAILURE return EXIT_SUCCESS diff --git a/collections/experimental.collection.yml b/collections/experimental.collection.yml index b115ce82e..8be716e09 100644 --- a/collections/experimental.collection.yml +++ b/collections/experimental.collection.yml @@ -25,7 +25,6 @@ items: kind: skill - path: .github/skills/experimental/vscode-playwright kind: skill - maturity: experimental # Instructions - path: .github/instructions/experimental/experiment-designer.instructions.md kind: instruction diff --git a/collections/hve-core-all.collection.yml b/collections/hve-core-all.collection.yml index 2ace72e0a..20e5ed406 100644 --- a/collections/hve-core-all.collection.yml +++ b/collections/hve-core-all.collection.yml @@ -582,7 +582,6 @@ items: kind: skill - path: .github/skills/experimental/vscode-playwright kind: skill - maturity: experimental - path: .github/skills/gitlab/gitlab kind: skill - path: .github/skills/installer/hve-core-installer From ea52f30136d86d0460f7c2e5a33bd3288ecaa4ed Mon Sep 17 00:00:00 2001 From: auyidi Date: Fri, 1 May 2026 18:05:08 +0000 Subject: [PATCH 49/68] fix(skills): address PR review feedback for tts-voiceover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reorder _run() before main() per python-script convention - fix AccessToken.expires_on type annotation from float to int - defer prs.save() until after embedded_count == 0 check - add environment variable documentation to bash wrapper scripts 🐛 - Generated by Copilot --- .../tts-voiceover/scripts/embed-audio.sh | 3 ++ .../tts-voiceover/scripts/embed_audio.py | 6 ++-- .../scripts/generate-voiceover.sh | 8 +++++ .../scripts/generate_voiceover.py | 30 +++++++++---------- 4 files changed, 30 insertions(+), 17 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/scripts/embed-audio.sh b/.github/skills/experimental/tts-voiceover/scripts/embed-audio.sh index 0c263d044..2a45d8ef1 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/embed-audio.sh +++ b/.github/skills/experimental/tts-voiceover/scripts/embed-audio.sh @@ -5,6 +5,9 @@ # embed-audio.sh # Wrapper for embed_audio.py — embeds per-slide WAV voice-over files # into a PowerPoint deck. +# +# No environment variables required. This script embeds pre-generated +# WAV files and does not call Azure services. set -euo pipefail diff --git a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py index 3f61207fb..b0684f9e1 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py +++ b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py @@ -239,11 +239,13 @@ def _run(args: argparse.Namespace) -> int: failed_count += 1 output_path.parent.mkdir(parents=True, exist_ok=True) - prs.save(str(output_path)) - logger.info("Saved %s with %d embedded audio files", output_path, embedded_count) if embedded_count == 0: return EXIT_FAILURE + + prs.save(str(output_path)) + logger.info("Saved %s with %d embedded audio files", output_path, embedded_count) + if failed_count > 0: logger.warning( "Completed with %d failure(s); %d slide(s) embedded successfully.", diff --git a/.github/skills/experimental/tts-voiceover/scripts/generate-voiceover.sh b/.github/skills/experimental/tts-voiceover/scripts/generate-voiceover.sh index b70bc1dd7..34ffb4f3c 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/generate-voiceover.sh +++ b/.github/skills/experimental/tts-voiceover/scripts/generate-voiceover.sh @@ -5,6 +5,14 @@ # generate-voiceover.sh # Wrapper for generate_voiceover.py — generates per-slide TTS voice-over # from YAML speaker notes via Azure Speech SDK. +# +# Required Environment Variables (key-based auth): +# SPEECH_KEY - Azure Speech resource key +# SPEECH_REGION - Azure region (e.g., eastus) +# +# Required Environment Variables (Entra ID auth): +# SPEECH_RESOURCE_ID - Cognitive Services resource ID +# SPEECH_REGION - Azure region set -euo pipefail diff --git a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py index 30a291ba2..93f1a0bb5 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py +++ b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py @@ -149,7 +149,7 @@ def _make_entra_config( credential: Any, resource_id: str, region: str, -) -> tuple[Any, float]: +) -> tuple[Any, int]: """Create a SpeechConfig with a fresh Entra ID token. Returns (config, expires_at). @@ -214,20 +214,6 @@ def create_parser() -> argparse.ArgumentParser: return parser -def main() -> int: - """Entry point for TTS voice-over generation.""" - logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") - parser = create_parser() - args = parser.parse_args() - try: - return _run(args) - except KeyboardInterrupt: - return 130 - except BrokenPipeError: - sys.stderr.close() - return 1 - - def _run(args: argparse.Namespace) -> int: """Execute TTS generation logic.""" @@ -368,5 +354,19 @@ def _run(args: argparse.Namespace) -> int: return EXIT_FAILURE if failed_count > 0 else EXIT_SUCCESS +def main() -> int: + """Entry point for TTS voice-over generation.""" + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + parser = create_parser() + args = parser.parse_args() + try: + return _run(args) + except KeyboardInterrupt: + return 130 + except BrokenPipeError: + sys.stderr.close() + return 1 + + if __name__ == "__main__": sys.exit(main()) From 528baf2dbe9691afc755808c1a8c59413e43d23e Mon Sep 17 00:00:00 2001 From: auyidi Date: Fri, 1 May 2026 20:35:17 +0000 Subject: [PATCH 50/68] fix(skills): address additional tts-voiceover review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - warn on acronym keys with XML-special characters that cannot match - hoist use_entra_auth flag for token-refresh readability - return EXIT_FAILURE when no slides with speaker_notes are found 🐛 - Generated by Copilot --- .../scripts/generate_voiceover.py | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py index 93f1a0bb5..74cc142a9 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py +++ b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py @@ -67,6 +67,13 @@ def load_acronyms(path: Path) -> dict[str, str]: for k, v in acronyms.items() if k is not None and v is not None } + xml_special = {k for k in clean if any(c in k for c in ("&", "<", ">"))} + if xml_special: + logger.warning( + "Acronym keys with XML-special characters will never match " + "(input text is pre-escaped): %s", + ", ".join(sorted(xml_special)), + ) if clean: logger.info("Loaded %d acronyms from %s", len(clean), path) return clean @@ -274,6 +281,8 @@ def _run(args: argparse.Namespace) -> int: ) return EXIT_ERROR + use_entra_auth = bool(speech_resource_id and not speech_key) + total_duration = 0.0 slide_count = 0 failed_count = 0 @@ -315,12 +324,7 @@ def _run(args: argparse.Namespace) -> int: continue # Refresh Entra ID token before expiry. - if ( - speechsdk is not None - and speech_resource_id - and not speech_key - and time.time() > token_expires_at - 300 - ): + if use_entra_auth and time.time() > token_expires_at - 300: try: speech_config, token_expires_at = _make_entra_config( speechsdk, credential, speech_resource_id, speech_region @@ -342,6 +346,13 @@ def _run(args: argparse.Namespace) -> int: if args.dry_run: print(f"\n--- Dry run complete: {slide_count} slides processed ---") else: + if slide_count == 0: + logger.warning( + "No slides with speaker_notes found in %s. " + "Verify --content-dir points to a PowerPoint skill content directory.", + content_dir, + ) + return EXIT_FAILURE logger.info( "Total narration: %.1fs (%.1f min) across %d slides", total_duration, From d3a05ea9cba7aca15c8bab1c0dcec89f649e59e8 Mon Sep 17 00:00:00 2001 From: auyidi Date: Fri, 1 May 2026 20:51:17 +0000 Subject: [PATCH 51/68] fix(skills): clean up orphaned audio shape and reorder _run before main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - remove embedded audio shape when narration timing fails to avoid partial state - reorder _run() before main() per python-script convention 🐛 - Generated by Copilot --- .../tts-voiceover/scripts/embed_audio.py | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py index b0684f9e1..ad7e911c3 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py +++ b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py @@ -149,8 +149,14 @@ def embed_slide_audio(slide: Slide, wav_path: Path) -> bool: _add_narration_timing(slide, shape_id, duration_ms) _set_slide_transition(slide, duration_ms) else: - logger.warning( - "Could not find audio shape for %s; narration timing not set", + # Remove the orphaned audio shape to avoid partial state + try: + sp = slide.shapes[-1]._element + sp.getparent().remove(sp) + except Exception: + pass + logger.error( + "Could not find audio shape for %s; removed orphaned embed", wav_path.name, ) return False @@ -188,20 +194,6 @@ def create_parser() -> argparse.ArgumentParser: return parser -def main() -> int: - """Entry point for audio embedding.""" - logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") - parser = create_parser() - args = parser.parse_args() - try: - return _run(args) - except KeyboardInterrupt: - return 130 - except BrokenPipeError: - sys.stderr.close() - return 1 - - def _run(args: argparse.Namespace) -> int: """Execute audio embedding logic.""" @@ -256,5 +248,19 @@ def _run(args: argparse.Namespace) -> int: return EXIT_SUCCESS +def main() -> int: + """Entry point for audio embedding.""" + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + parser = create_parser() + args = parser.parse_args() + try: + return _run(args) + except KeyboardInterrupt: + return 130 + except BrokenPipeError: + sys.stderr.close() + return 1 + + if __name__ == "__main__": sys.exit(main()) From 27b54daaded509ecb783bc249e7db1f10a8d9bde Mon Sep 17 00:00:00 2001 From: auyidi Date: Fri, 1 May 2026 21:16:15 +0000 Subject: [PATCH 52/68] fix(skills): address CodeQL finding and review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - replace silent except pass with debug-level log for orphaned shape cleanup - wrap prs.save() in try/except OSError for clean error messaging - quote SkillRoot variable in uv sync call to handle paths with spaces 🐛 - Generated by Copilot --- .../scripts/Modules/TtsVoiceoverHelpers.psm1 | 2 +- .../tts-voiceover/scripts/embed_audio.py | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/scripts/Modules/TtsVoiceoverHelpers.psm1 b/.github/skills/experimental/tts-voiceover/scripts/Modules/TtsVoiceoverHelpers.psm1 index 02afdc06f..d225965c5 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/Modules/TtsVoiceoverHelpers.psm1 +++ b/.github/skills/experimental/tts-voiceover/scripts/Modules/TtsVoiceoverHelpers.psm1 @@ -38,7 +38,7 @@ function Initialize-PythonEnvironment { ) Write-Host 'Syncing Python environment via uv...' - & uv sync --directory $SkillRoot + & uv sync --directory "$SkillRoot" if ($LASTEXITCODE -ne 0) { throw 'Failed to sync Python environment via uv.' } diff --git a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py index ad7e911c3..383773290 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py +++ b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py @@ -154,7 +154,11 @@ def embed_slide_audio(slide: Slide, wav_path: Path) -> bool: sp = slide.shapes[-1]._element sp.getparent().remove(sp) except Exception: - pass + logger.debug( + "Could not remove orphaned shape for %s", + wav_path.name, + exc_info=True, + ) logger.error( "Could not find audio shape for %s; removed orphaned embed", wav_path.name, @@ -235,7 +239,12 @@ def _run(args: argparse.Namespace) -> int: if embedded_count == 0: return EXIT_FAILURE - prs.save(str(output_path)) + try: + prs.save(str(output_path)) + except OSError as exc: + logger.error("Failed to save output PPTX %s: %s", output_path, exc) + return EXIT_FAILURE + logger.info("Saved %s with %d embedded audio files", output_path, embedded_count) if failed_count > 0: From 35a4721d0060c63d1faad094dd865fdf3f3a4e1f Mon Sep 17 00:00:00 2001 From: auyidi Date: Fri, 1 May 2026 21:39:54 +0000 Subject: [PATCH 53/68] refactor(skills): capture add_movie() return value, remove _find_audio_shape_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - use movie_shape directly instead of O(n) scan for shape ID - remove dead _find_audio_shape_id helper - use movie_shape._element for exact cleanup instead of slide.shapes[-1] 🔧 - Generated by Copilot --- .../tts-voiceover/scripts/embed_audio.py | 25 +++---------------- .../tts-voiceover/tests/test_embed_audio.py | 10 +++++--- 2 files changed, 9 insertions(+), 26 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py index 383773290..7e4f4a9d9 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py +++ b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py @@ -108,24 +108,6 @@ def _set_slide_transition(slide: Slide, duration_ms: int) -> None: slide._element.append(transition) -def _find_audio_shape_id(slide: Slide) -> int | None: - """Find the shape ID of the audio/movie shape on a slide.""" - for shape in slide.shapes: - sp = shape._element - for tag_suffix in ("nvPicPr", "nvSpPr"): - nv = sp.find(qn(f"p:{tag_suffix}")) - if nv is None: - continue - nvPr = nv.find(qn("p:nvPr")) - if nvPr is None: - continue - if nvPr.find(qn("a:audioFile")) is not None: - return shape.shape_id - if nvPr.find(qn("a:videoFile")) is not None: - return shape.shape_id - return None - - def embed_slide_audio(slide: Slide, wav_path: Path) -> bool: """Embed a WAV file into a slide as a media object. @@ -135,7 +117,7 @@ def embed_slide_audio(slide: Slide, wav_path: Path) -> bool: Returns True on success, False on failure. """ try: - slide.shapes.add_movie( + movie_shape = slide.shapes.add_movie( str(wav_path), left=0, top=0, @@ -143,7 +125,7 @@ def embed_slide_audio(slide: Slide, wav_path: Path) -> bool: height=ICON_SIZE, mime_type=AUDIO_MIME_TYPE, ) - shape_id = _find_audio_shape_id(slide) + shape_id: int | None = movie_shape.shape_id if shape_id is not None: duration_ms = get_wav_duration_ms(wav_path) _add_narration_timing(slide, shape_id, duration_ms) @@ -151,8 +133,7 @@ def embed_slide_audio(slide: Slide, wav_path: Path) -> bool: else: # Remove the orphaned audio shape to avoid partial state try: - sp = slide.shapes[-1]._element - sp.getparent().remove(sp) + movie_shape._element.getparent().remove(movie_shape._element) except Exception: logger.debug( "Could not remove orphaned shape for %s", diff --git a/.github/skills/experimental/tts-voiceover/tests/test_embed_audio.py b/.github/skills/experimental/tts-voiceover/tests/test_embed_audio.py index f0258a0f1..404a97df1 100644 --- a/.github/skills/experimental/tts-voiceover/tests/test_embed_audio.py +++ b/.github/skills/experimental/tts-voiceover/tests/test_embed_audio.py @@ -105,8 +105,9 @@ def test_given_valid_slide_when_embed_then_returns_true(self, tmp_path, mocker): # Arrange wav = _make_wav(tmp_path) mock_slide = MagicMock() - mock_slide.shapes.add_movie.return_value = MagicMock() - mocker.patch("embed_audio._find_audio_shape_id", return_value=42) + mock_shape = MagicMock() + mock_shape.shape_id = 42 + mock_slide.shapes.add_movie.return_value = mock_shape mocker.patch("embed_audio._add_narration_timing") mocker.patch("embed_audio._set_slide_transition") @@ -120,8 +121,9 @@ def test_given_no_shape_id_when_embed_then_returns_false(self, tmp_path, mocker) # Arrange wav = _make_wav(tmp_path) mock_slide = MagicMock() - mock_slide.shapes.add_movie.return_value = MagicMock() - mocker.patch("embed_audio._find_audio_shape_id", return_value=None) + mock_shape = MagicMock() + mock_shape.shape_id = None + mock_slide.shapes.add_movie.return_value = mock_shape # Act result = embed_slide_audio(mock_slide, wav) From 95a3b0a4343ff59002c5b3ad8d332b7096ef5c14 Mon Sep 17 00:00:00 2001 From: auyidi Date: Fri, 1 May 2026 22:22:25 +0000 Subject: [PATCH 54/68] =?UTF-8?q?fix(skills):=20address=20review=20feedbac?= =?UTF-8?q?k=20=E2=80=94=20license,=20types,=20tests,=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix charset-normalizer license comment (MIT, not LGPL) - add type narrowing assert for speech_resource_id in token refresh - add input==output guard in embed_audio to prevent silent overwrite - add unit tests for apply_acronym_aliases and wrap_ssml (8 new tests) 🐛 - Generated by Copilot --- .../tts-voiceover/scripts/embed_audio.py | 6 ++ .../scripts/generate_voiceover.py | 1 + .../tests/test_generate_voiceover.py | 96 +++++++++++++++++++ .github/workflows/dependency-review.yml | 3 +- 4 files changed, 105 insertions(+), 1 deletion(-) diff --git a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py index 7e4f4a9d9..78a0cd597 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py +++ b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py @@ -197,6 +197,12 @@ def _run(args: argparse.Namespace) -> int: f"{input_path.stem}-narrated.pptx" ) + if output_path.resolve() == input_path.resolve(): + logger.error( + "Output path must differ from input path to avoid overwriting the source" + ) + return EXIT_ERROR + prs = Presentation(str(input_path)) embedded_count = 0 failed_count = 0 diff --git a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py index 74cc142a9..025918bd1 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py +++ b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py @@ -325,6 +325,7 @@ def _run(args: argparse.Namespace) -> int: # Refresh Entra ID token before expiry. if use_entra_auth and time.time() > token_expires_at - 300: + assert speech_resource_id is not None # guaranteed by use_entra_auth try: speech_config, token_expires_at = _make_entra_config( speechsdk, credential, speech_resource_id, speech_region diff --git a/.github/skills/experimental/tts-voiceover/tests/test_generate_voiceover.py b/.github/skills/experimental/tts-voiceover/tests/test_generate_voiceover.py index 7e253118c..2a634ea75 100644 --- a/.github/skills/experimental/tts-voiceover/tests/test_generate_voiceover.py +++ b/.github/skills/experimental/tts-voiceover/tests/test_generate_voiceover.py @@ -7,7 +7,9 @@ import yaml from generate_voiceover import ( _resolve_lexicon, + apply_acronym_aliases, create_parser, + wrap_ssml, ) @@ -175,3 +177,97 @@ def test_given_empty_notes_when_dry_run_then_slide_skipped(self, tmp_path, capsy # Assert assert rc == 0 + + +class TestApplyAcronymAliases: + """Tests for apply_acronym_aliases.""" + + def test_given_known_acronym_when_applied_then_wraps_in_sub(self): + # Arrange + text = "Use OWASP guidelines" + acronyms = {"OWASP": "Oh wasp"} + + # Act + result = apply_acronym_aliases(text, acronyms) + + # Assert + assert 'OWASP' in result + + def test_given_empty_acronyms_when_applied_then_returns_unchanged(self): + # Arrange + text = "no replacements here" + + # Act + result = apply_acronym_aliases(text, {}) + + # Assert + assert result == text + + def test_given_escaped_input_when_applied_then_no_double_escape(self): + # Arrange + text = "& <tag>" + + # Act + result = apply_acronym_aliases(text, {}) + + # Assert + assert result == text + + def test_given_multiple_acronyms_when_applied_then_all_replaced(self): + # Arrange + text = "Use API and SDK" + acronyms = {"API": "A P I", "SDK": "S D K"} + + # Act + result = apply_acronym_aliases(text, acronyms) + + # Assert + assert 'API' in result + assert 'SDK' in result + + +class TestWrapSsml: + """Tests for wrap_ssml.""" + + def test_given_text_when_wrapped_then_contains_speak_element(self): + # Arrange + text = "Hello world" + + # Act + result = wrap_ssml(text, voice="en-US-AriaNeural", rate="0%") + + # Assert + assert "" in result + + def test_given_voice_when_wrapped_then_voice_attribute_present(self): + # Arrange + voice = "en-US-AriaNeural" + + # Act + result = wrap_ssml("test", voice=voice, rate="0%") + + # Assert + assert voice in result + + def test_given_rate_when_wrapped_then_prosody_rate_set(self): + # Arrange + rate = "-10%" + + # Act + result = wrap_ssml("test", voice="en-US-AriaNeural", rate=rate) + + # Assert + assert rate in result + + def test_given_ssml_output_when_parsed_then_valid_xml(self): + # Arrange + import xml.etree.ElementTree as ET + + text = "Hello & world" + + # Act + result = wrap_ssml(text, voice="en-US-AriaNeural", rate="0%") + + # Assert + ET.fromstring(result) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 9893c4c67..7662102e3 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -57,7 +57,8 @@ jobs: # from bundled code; distributed licenses are permissive. # pkg:npm/hve-core is the private root package (never published to npm). # pkg:pypi/certifi uses MPL-2.0 (Mozilla CA bundle). - # pkg:pypi/charset-normalizer uses LGPL for detection code; MIT for API. + # pkg:pypi/charset-normalizer is MIT licensed; allowed for Azure SDK + # transitive dependency chain. allow-dependencies-licenses: >- pkg:pypi/lxml, pkg:pypi/typing-extensions, From 92aedeab43ae364bff0388dffbbc943abe2260f9 Mon Sep 17 00:00:00 2001 From: auyidi Date: Fri, 1 May 2026 22:39:11 +0000 Subject: [PATCH 55/68] refactor(skills): extract configure_logging in embed_audio.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🔧 - Generated by Copilot --- .../experimental/tts-voiceover/scripts/embed_audio.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py index 78a0cd597..a523104e3 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py +++ b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py @@ -244,9 +244,15 @@ def _run(args: argparse.Namespace) -> int: return EXIT_SUCCESS +def configure_logging(verbose: bool = False) -> None: + """Configure logging based on verbosity level.""" + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig(level=level, format="%(levelname)s: %(message)s") + + def main() -> int: """Entry point for audio embedding.""" - logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + configure_logging() parser = create_parser() args = parser.parse_args() try: From f524234118ce0a8030b2823503e00e49db7ab9d1 Mon Sep 17 00:00:00 2001 From: auyidi Date: Fri, 1 May 2026 23:25:47 +0000 Subject: [PATCH 56/68] fix(skills): wire verbose flag, replace assert, explicit Mandatory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add --verbose/-v flag to embed_audio.py and wire to configure_logging - replace assert with RuntimeError for speech_resource_id type narrowing - add explicit Mandatory = $false on all optional PS1 parameters 🐛 - Generated by Copilot --- .../tts-voiceover/scripts/Invoke-EmbedAudio.ps1 | 6 +++--- .../scripts/Invoke-GenerateVoiceover.ps1 | 14 +++++++------- .../tts-voiceover/scripts/embed_audio.py | 8 +++++++- .../tts-voiceover/scripts/generate_voiceover.py | 5 ++++- 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 b/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 index 5a0d02610..9d4de7a5c 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 +++ b/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 @@ -44,13 +44,13 @@ param( [ValidateNotNullOrEmpty()] [string]$InputPath, - [Parameter()] + [Parameter(Mandatory = $false)] [string]$AudioDir, - [Parameter()] + [Parameter(Mandatory = $false)] [string]$OutputPath, - [Parameter()] + [Parameter(Mandatory = $false)] [switch]$SkipVenvSetup ) diff --git a/.github/skills/experimental/tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 b/.github/skills/experimental/tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 index b09898526..cc1ef10ec 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 +++ b/.github/skills/experimental/tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 @@ -52,25 +52,25 @@ [CmdletBinding()] param( - [Parameter()] + [Parameter(Mandatory = $false)] [switch]$DryRun, - [Parameter()] + [Parameter(Mandatory = $false)] [string]$Voice, - [Parameter()] + [Parameter(Mandatory = $false)] [string]$Rate, - [Parameter()] + [Parameter(Mandatory = $false)] [string]$ContentDir, - [Parameter()] + [Parameter(Mandatory = $false)] [string]$OutputDir, - [Parameter()] + [Parameter(Mandatory = $false)] [string]$Lexicon, - [Parameter()] + [Parameter(Mandatory = $false)] [switch]$SkipVenvSetup ) diff --git a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py index a523104e3..a4983716e 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py +++ b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py @@ -176,6 +176,12 @@ def create_parser() -> argparse.ArgumentParser: default=None, help="Output PPTX file path (default: input stem + '-narrated.pptx')", ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Enable verbose (DEBUG) logging", + ) return parser @@ -252,9 +258,9 @@ def configure_logging(verbose: bool = False) -> None: def main() -> int: """Entry point for audio embedding.""" - configure_logging() parser = create_parser() args = parser.parse_args() + configure_logging(verbose=args.verbose) try: return _run(args) except KeyboardInterrupt: diff --git a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py index 025918bd1..0a8705e7d 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py +++ b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py @@ -325,7 +325,10 @@ def _run(args: argparse.Namespace) -> int: # Refresh Entra ID token before expiry. if use_entra_auth and time.time() > token_expires_at - 300: - assert speech_resource_id is not None # guaranteed by use_entra_auth + if speech_resource_id is None: + raise RuntimeError( + "speech_resource_id must not be None when use_entra_auth is True" + ) try: speech_config, token_expires_at = _make_entra_config( speechsdk, credential, speech_resource_id, speech_region From 0f819d559f68f7b5802a735ade4a6a2d9cd23cb1 Mon Sep 17 00:00:00 2001 From: auyidi Date: Fri, 1 May 2026 23:48:00 +0000 Subject: [PATCH 57/68] refactor(skills): extract configure_logging and add --verbose to generate_voiceover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🔧 - Generated by Copilot --- .../tts-voiceover/scripts/generate_voiceover.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py index 0a8705e7d..ddceb040e 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py +++ b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py @@ -218,6 +218,12 @@ def create_parser() -> argparse.ArgumentParser: default=None, help="Path to custom acronyms.yaml lexicon file", ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Enable verbose (DEBUG) logging", + ) return parser @@ -369,11 +375,17 @@ def _run(args: argparse.Namespace) -> int: return EXIT_FAILURE if failed_count > 0 else EXIT_SUCCESS +def configure_logging(verbose: bool = False) -> None: + """Configure logging based on verbosity level.""" + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig(level=level, format="%(levelname)s: %(message)s") + + def main() -> int: """Entry point for TTS voice-over generation.""" - logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") parser = create_parser() args = parser.parse_args() + configure_logging(verbose=args.verbose) try: return _run(args) except KeyboardInterrupt: From 9fb9d1a2a64a3fc38148a57f5d297782ec3c7fbc Mon Sep 17 00:00:00 2001 From: auyidi Date: Sat, 2 May 2026 00:16:24 +0000 Subject: [PATCH 58/68] fix(skills): add diagnostic log when no audio files are embedded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐛 - Generated by Copilot --- .../skills/experimental/tts-voiceover/scripts/embed_audio.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py index a4983716e..c16ea7e0c 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py +++ b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py @@ -230,6 +230,10 @@ def _run(args: argparse.Namespace) -> int: output_path.parent.mkdir(parents=True, exist_ok=True) if embedded_count == 0: + logger.error( + "No audio files were embedded. Verify that slide-NNN.wav files exist in %s", + audio_dir, + ) return EXIT_FAILURE try: From 1343bc90b58407a23d47aaf4779589c74019895f Mon Sep 17 00:00:00 2001 From: auyidi Date: Mon, 4 May 2026 15:07:38 +0000 Subject: [PATCH 59/68] fix(skills): address tts-voiceover review feedback round 11 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * SKILL.md: convert unordered list markers from - to * * SKILL.md: document --verbose/-v flag in both parameter tables * SKILL.md: remove non-standard Copilot meta-commentary line * docs/getting-started/tts-voiceover.md: remove markdownlint-disable and Copilot meta lines * generate_voiceover.py: scope use_entra_auth inside auth setup; replace dead None guard with assert * Invoke-EmbedAudio.ps1, Invoke-GenerateVoiceover.ps1: forward -Verbose to Python --verbose 🔧 - Generated by Copilot --- .../skills/experimental/tts-voiceover/SKILL.md | 16 ++++++++-------- .../tts-voiceover/scripts/Invoke-EmbedAudio.ps1 | 1 + .../scripts/Invoke-GenerateVoiceover.ps1 | 1 + .../tts-voiceover/scripts/generate_voiceover.py | 9 ++++----- docs/getting-started/tts-voiceover.md | 4 ---- 5 files changed, 14 insertions(+), 17 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/SKILL.md b/.github/skills/experimental/tts-voiceover/SKILL.md index e2955a0f5..b446343be 100644 --- a/.github/skills/experimental/tts-voiceover/SKILL.md +++ b/.github/skills/experimental/tts-voiceover/SKILL.md @@ -16,9 +16,9 @@ This skill reads `content.yaml` files from a PowerPoint skill content directory, ## Prerequisites -- **Azure Speech resource** — Free tier provides 500K characters per month. -- **Authentication** — Key-based (`SPEECH_KEY`) or Microsoft Entra ID (`SPEECH_RESOURCE_ID`). -- **Python 3.11+** with `uv` for virtual environment management. +* **Azure Speech resource** — Free tier provides 500K characters per month. +* **Authentication** — Key-based (`SPEECH_KEY`) or Microsoft Entra ID (`SPEECH_RESOURCE_ID`). +* **Python 3.11+** with `uv` for virtual environment management. ### Key-Based Auth @@ -75,6 +75,7 @@ uv run scripts/embed_audio.py --input deck.pptx --audio-dir voice-over --output | `--content-dir` | path | `content` | Path to slide content directory | | `--output-dir` | path | `voice-over` | Path to WAV output directory | | `--lexicon` | path | *(auto-detect)* | Custom acronyms.yaml path | +| `--verbose` / `-v` | flag | `false` | Enable verbose (DEBUG) logging output | ### embed_audio.py @@ -84,9 +85,10 @@ XML so PowerPoint recognizes the audio for video export via | Parameter | Type | Default | Description | |:--------------|:-----|:------------------|:-----------------------------| -| `--input` | path | *(required)* | Source PPTX file path | -| `--audio-dir` | path | `voice-over` | Directory with slide-NNN.wav | -| `--output` | path | `*-narrated.pptx` | Output PPTX file path | +| `--input` | path | *(required)* | Source PPTX file path | +| `--audio-dir` | path | `voice-over` | Directory with slide-NNN.wav | +| `--output` | path | `*-narrated.pptx` | Output PPTX file path | +| `--verbose` / `-v` | flag | `false` | Enable verbose (DEBUG) logging output | ## Script Reference @@ -178,6 +180,4 @@ Each `content.yaml` should contain a `speaker_notes:` field with the narration t | Audio icon visible in PPTX | Reposition or resize the audio object in PowerPoint after embedding. | | Video export shows "No timings recorded" | Re-embed audio with the updated `embed_audio.py` which adds narration timing XML automatically. | -*🤖 Crafted with precision by ✨Copilot following brilliant human instruction, then carefully refined by our team of discerning human reviewers.* - > Brought to you by microsoft/hve-core diff --git a/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 b/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 index 9d4de7a5c..4ed005a33 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 +++ b/.github/skills/experimental/tts-voiceover/scripts/Invoke-EmbedAudio.ps1 @@ -82,6 +82,7 @@ if ($MyInvocation.InvocationName -ne '.') { if ($AudioDir) { $PythonArgs += '--audio-dir', $AudioDir } if ($OutputPath) { $PythonArgs += '--output', $OutputPath } + if ($VerbosePreference -ne 'SilentlyContinue') { $PythonArgs += '--verbose' } & $python $script @PythonArgs if ($LASTEXITCODE -ne 0) { diff --git a/.github/skills/experimental/tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 b/.github/skills/experimental/tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 index cc1ef10ec..03e0e40cb 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 +++ b/.github/skills/experimental/tts-voiceover/scripts/Invoke-GenerateVoiceover.ps1 @@ -106,6 +106,7 @@ if ($MyInvocation.InvocationName -ne '.') { if ($ContentDir) { $PythonArgs += '--content-dir', $ContentDir } if ($OutputDir) { $PythonArgs += '--output-dir', $OutputDir } if ($Lexicon) { $PythonArgs += '--lexicon', $Lexicon } + if ($VerbosePreference -ne 'SilentlyContinue') { $PythonArgs += '--verbose' } & $python $script @PythonArgs if ($LASTEXITCODE -ne 0) { diff --git a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py index ddceb040e..be8e835f7 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py +++ b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py @@ -249,6 +249,7 @@ def _run(args: argparse.Namespace) -> int: speech_key: str | None = None speech_region: str = "eastus" speech_resource_id: str | None = None + use_entra_auth = False if not args.dry_run: try: import azure.cognitiveservices.speech as speechsdk # noqa: PLC0415 @@ -287,7 +288,7 @@ def _run(args: argparse.Namespace) -> int: ) return EXIT_ERROR - use_entra_auth = bool(speech_resource_id and not speech_key) + use_entra_auth = bool(speech_resource_id and not speech_key) total_duration = 0.0 slide_count = 0 @@ -331,10 +332,8 @@ def _run(args: argparse.Namespace) -> int: # Refresh Entra ID token before expiry. if use_entra_auth and time.time() > token_expires_at - 300: - if speech_resource_id is None: - raise RuntimeError( - "speech_resource_id must not be None when use_entra_auth is True" - ) + # speech_resource_id is guaranteed non-None by use_entra_auth definition. + assert speech_resource_id is not None try: speech_config, token_expires_at = _make_entra_config( speechsdk, credential, speech_resource_id, speech_region diff --git a/docs/getting-started/tts-voiceover.md b/docs/getting-started/tts-voiceover.md index 026088a40..9c59345e1 100644 --- a/docs/getting-started/tts-voiceover.md +++ b/docs/getting-started/tts-voiceover.md @@ -148,7 +148,3 @@ content/ --- > Brought to you by microsoft/hve-core - - -*🤖 Crafted with precision by ✨Copilot following brilliant human instruction, -then carefully refined by our team of discerning human reviewers.* From f94e8ab36d21714de6b33077084dd1d67f63dd61 Mon Sep 17 00:00:00 2001 From: auyidi Date: Mon, 4 May 2026 15:37:59 +0000 Subject: [PATCH 60/68] fix(skills): address tts-voiceover review feedback round 12 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * generate_voiceover.py: warn when both SPEECH_KEY and SPEECH_RESOURCE_ID set * embed_audio.py: warn when replacing slide timing with authored animations * fuzz_harness.py: guard fuzz target bodies with FUZZING check 🔧 - Generated by Copilot --- .../experimental/tts-voiceover/scripts/embed_audio.py | 10 ++++++++++ .../tts-voiceover/scripts/generate_voiceover.py | 6 ++++++ .../experimental/tts-voiceover/tests/fuzz_harness.py | 6 ++++++ 3 files changed, 22 insertions(+) diff --git a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py index c16ea7e0c..25b7ee8d0 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py +++ b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py @@ -56,6 +56,16 @@ def _add_narration_timing(slide: Slide, shape_id: int, duration_ms: int) -> None """ existing = slide._element.find(qn("p:timing")) if existing is not None: + # Warn if the existing timing has multiple sequences, indicating + # authored animations (entrance effects, click sequences) beyond the + # standard narration mainSeq that will be overwritten. + child_seqs = existing.findall(f".//{qn('p:seq')}") + if len(child_seqs) > 1: + logger.warning( + "Replacing existing slide timing that contains %d sequences; " + "authored animations on this slide will be overwritten.", + len(child_seqs), + ) slide._element.remove(existing) timing_xml = ( diff --git a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py index be8e835f7..10174fd1e 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py +++ b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py @@ -264,6 +264,12 @@ def _run(args: argparse.Namespace) -> int: speech_region = os.environ.get("SPEECH_REGION", "eastus") speech_resource_id = os.environ.get("SPEECH_RESOURCE_ID") + if speech_key and speech_resource_id: + logger.warning( + "Both SPEECH_KEY and SPEECH_RESOURCE_ID are set; " + "using key-based auth. Unset SPEECH_KEY to use Entra ID auth." + ) + if speech_key: speech_config = speechsdk.SpeechConfig( subscription=speech_key, region=speech_region diff --git a/.github/skills/experimental/tts-voiceover/tests/fuzz_harness.py b/.github/skills/experimental/tts-voiceover/tests/fuzz_harness.py index c2feb1e33..f194302a5 100644 --- a/.github/skills/experimental/tts-voiceover/tests/fuzz_harness.py +++ b/.github/skills/experimental/tts-voiceover/tests/fuzz_harness.py @@ -35,6 +35,8 @@ def fuzz_apply_acronym_aliases(data): """Fuzz apply_acronym_aliases with random text and the default acronym dict.""" + if not FUZZING: + return fdp = atheris.FuzzedDataProvider(data) raw_text = fdp.ConsumeUnicodeNoSurrogates(500) text = xml.sax.saxutils.escape(raw_text) @@ -44,6 +46,8 @@ def fuzz_apply_acronym_aliases(data): def fuzz_wrap_ssml(data): """Fuzz wrap_ssml with random text, voice, and rate strings.""" + if not FUZZING: + return fdp = atheris.FuzzedDataProvider(data) raw_text = fdp.ConsumeUnicodeNoSurrogates(200) text = xml.sax.saxutils.escape(raw_text) @@ -55,6 +59,8 @@ def fuzz_wrap_ssml(data): def fuzz_load_acronyms(data): """Fuzz load_acronyms with random YAML content written to a temp file.""" + if not FUZZING: + return fdp = atheris.FuzzedDataProvider(data) content = fdp.ConsumeUnicodeNoSurrogates(300) with tempfile.NamedTemporaryFile( From fbb38bf5bfc2489fd4fe68e0058abb05ef61b17d Mon Sep 17 00:00:00 2001 From: auyidi Date: Mon, 4 May 2026 17:23:57 +0000 Subject: [PATCH 61/68] fix(skills): address tts-voiceover review feedback round 13 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * embed_audio.py: warn on any existing p:timing replacement, include shape_id * generate_voiceover.py: replace assert with explicit RuntimeError (assert stripped under -O) * SKILL.md: document animation overwrite in Troubleshooting table 🔧 - Generated by Copilot --- .github/skills/experimental/tts-voiceover/SKILL.md | 1 + .../experimental/tts-voiceover/scripts/embed_audio.py | 11 ++++++----- .../tts-voiceover/scripts/generate_voiceover.py | 8 ++++++-- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/SKILL.md b/.github/skills/experimental/tts-voiceover/SKILL.md index b446343be..ceedbe009 100644 --- a/.github/skills/experimental/tts-voiceover/SKILL.md +++ b/.github/skills/experimental/tts-voiceover/SKILL.md @@ -178,6 +178,7 @@ Each `content.yaml` should contain a `speaker_notes:` field with the narration t | Mispronounced acronyms | Add entries to `acronyms.yaml` with phonetic aliases. | | `azure-cognitiveservices-speech package is required` | Run `uv sync` in the skill directory. | | Audio icon visible in PPTX | Reposition or resize the audio object in PowerPoint after embedding. | +| Authored slide animations missing after embedding | `embed_audio.py` replaces existing `p:timing` with narration timing; re-apply animations in PowerPoint after embedding audio. | | Video export shows "No timings recorded" | Re-embed audio with the updated `embed_audio.py` which adds narration timing XML automatically. | > Brought to you by microsoft/hve-core diff --git a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py index 25b7ee8d0..68e91c47a 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py +++ b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py @@ -56,15 +56,16 @@ def _add_narration_timing(slide: Slide, shape_id: int, duration_ms: int) -> None """ existing = slide._element.find(qn("p:timing")) if existing is not None: - # Warn if the existing timing has multiple sequences, indicating - # authored animations (entrance effects, click sequences) beyond the - # standard narration mainSeq that will be overwritten. + # Warn whenever an existing timing element is replaced, since any + # authored animation (entrance effect, click sequence) produces at + # least one p:seq that will be overwritten. child_seqs = existing.findall(f".//{qn('p:seq')}") - if len(child_seqs) > 1: + if child_seqs: logger.warning( - "Replacing existing slide timing that contains %d sequences; " + "Replacing existing slide timing (%d sequence(s)) for shape %d; " "authored animations on this slide will be overwritten.", len(child_seqs), + shape_id, ) slide._element.remove(existing) diff --git a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py index 10174fd1e..707ebbf95 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py +++ b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py @@ -338,8 +338,12 @@ def _run(args: argparse.Namespace) -> int: # Refresh Entra ID token before expiry. if use_entra_auth and time.time() > token_expires_at - 300: - # speech_resource_id is guaranteed non-None by use_entra_auth definition. - assert speech_resource_id is not None + # Explicit guard rather than assert: assert is stripped under -O. + if speech_resource_id is None: + raise RuntimeError( + "Unexpected state: speech_resource_id is None when " + "use_entra_auth is True" + ) try: speech_config, token_expires_at = _make_entra_config( speechsdk, credential, speech_resource_id, speech_region From 1363016f475fe318d72326f69720cffe51befa40 Mon Sep 17 00:00:00 2001 From: auyidi Date: Mon, 4 May 2026 18:08:22 +0000 Subject: [PATCH 62/68] fix(skills): drop unused xmlns:a and set advClick=0 for audio-driven advance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * embed_audio.py: remove unused DrawingML namespace from timing XML * embed_audio.py: set advClick=0 so slides advance only on timer, not click 🔧 - Generated by Copilot --- .../experimental/tts-voiceover/scripts/embed_audio.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py index 68e91c47a..660bcee4f 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py +++ b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py @@ -70,8 +70,7 @@ def _add_narration_timing(slide: Slide, shape_id: int, duration_ms: int) -> None slide._element.remove(existing) timing_xml = ( - '' + '' "" '' "" @@ -108,9 +107,11 @@ def _set_slide_transition(slide: Slide, duration_ms: int) -> None: if existing is not None: slide._element.remove(existing) + # advClick="0" prevents accidental click-to-skip during audio playback; + # slides advance only when the audio timer expires. transition = slide._element.makeelement( qn("p:transition"), - {"advClick": "1", "advTm": str(duration_ms)}, + {"advClick": "0", "advTm": str(duration_ms)}, ) timing = slide._element.find(qn("p:timing")) if timing is not None: From 701d1c9c46181cb14b0a682f6c6d814fae118248 Mon Sep 17 00:00:00 2001 From: auyidi Date: Mon, 4 May 2026 20:45:04 +0000 Subject: [PATCH 63/68] fix(skills): add word boundaries to acronym regex to prevent partial matches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🔧 - Generated by Copilot --- .../experimental/tts-voiceover/scripts/generate_voiceover.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py index 707ebbf95..cdfe8b489 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py +++ b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py @@ -85,7 +85,9 @@ def load_acronyms(path: Path) -> dict[str, str]: def _compile_acronym_pattern(keys: tuple[str, ...]) -> re.Pattern[str]: """Compile and cache a regex matching all acronym keys, longest first.""" sorted_keys = sorted(keys, key=len, reverse=True) - return re.compile("|".join(re.escape(k) for k in sorted_keys)) + return re.compile( + r"\b(?:" + "|".join(re.escape(k) for k in sorted_keys) + r")\b" + ) def apply_acronym_aliases(text: str, acronyms: dict[str, str]) -> str: From c892868b52aa923aa6f9b0295f268b6a738f0cda Mon Sep 17 00:00:00 2001 From: auyidi Date: Mon, 4 May 2026 21:46:13 +0000 Subject: [PATCH 64/68] fix(skills): move configure_logging before _run and document --verbose in shell wrappers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * embed_audio.py, generate_voiceover.py: reorder configure_logging between create_parser and _run * embed-audio.sh, generate-voiceover.sh: add -v/--verbose to usage Options block 🔧 - Generated by Copilot --- .../tts-voiceover/scripts/embed-audio.sh | 1 + .../tts-voiceover/scripts/embed_audio.py | 12 ++++++------ .../tts-voiceover/scripts/generate-voiceover.sh | 1 + .../tts-voiceover/scripts/generate_voiceover.py | 16 +++++++--------- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/scripts/embed-audio.sh b/.github/skills/experimental/tts-voiceover/scripts/embed-audio.sh index 2a45d8ef1..b0bd582e8 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/embed-audio.sh +++ b/.github/skills/experimental/tts-voiceover/scripts/embed-audio.sh @@ -29,6 +29,7 @@ Options: --input Source PPTX file path (required) --audio-dir Directory containing slide-NNN.wav files (default: voice-over) --output Output PPTX file path (default: input stem + '-narrated.pptx') + -v, --verbose Enable verbose (DEBUG) logging output --skip-venv-setup Skip virtual environment setup -h, --help Show this help message EOF diff --git a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py index 660bcee4f..6e45bb8e7 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py +++ b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py @@ -197,6 +197,12 @@ def create_parser() -> argparse.ArgumentParser: return parser +def configure_logging(verbose: bool = False) -> None: + """Configure logging based on verbosity level.""" + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig(level=level, format="%(levelname)s: %(message)s") + + def _run(args: argparse.Namespace) -> int: """Execute audio embedding logic.""" @@ -266,12 +272,6 @@ def _run(args: argparse.Namespace) -> int: return EXIT_SUCCESS -def configure_logging(verbose: bool = False) -> None: - """Configure logging based on verbosity level.""" - level = logging.DEBUG if verbose else logging.INFO - logging.basicConfig(level=level, format="%(levelname)s: %(message)s") - - def main() -> int: """Entry point for audio embedding.""" parser = create_parser() diff --git a/.github/skills/experimental/tts-voiceover/scripts/generate-voiceover.sh b/.github/skills/experimental/tts-voiceover/scripts/generate-voiceover.sh index 34ffb4f3c..796400ec4 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/generate-voiceover.sh +++ b/.github/skills/experimental/tts-voiceover/scripts/generate-voiceover.sh @@ -37,6 +37,7 @@ Options: --content-dir Path to slide content directory (default: content) --output-dir Path to WAV output directory (default: voice-over) --lexicon Path to custom acronyms.yaml lexicon file + -v, --verbose Enable verbose (DEBUG) logging output --skip-venv-setup Skip virtual environment setup -h, --help Show this help message EOF diff --git a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py index cdfe8b489..2ca68cc0c 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py +++ b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py @@ -85,9 +85,7 @@ def load_acronyms(path: Path) -> dict[str, str]: def _compile_acronym_pattern(keys: tuple[str, ...]) -> re.Pattern[str]: """Compile and cache a regex matching all acronym keys, longest first.""" sorted_keys = sorted(keys, key=len, reverse=True) - return re.compile( - r"\b(?:" + "|".join(re.escape(k) for k in sorted_keys) + r")\b" - ) + return re.compile(r"\b(?:" + "|".join(re.escape(k) for k in sorted_keys) + r")\b") def apply_acronym_aliases(text: str, acronyms: dict[str, str]) -> str: @@ -229,6 +227,12 @@ def create_parser() -> argparse.ArgumentParser: return parser +def configure_logging(verbose: bool = False) -> None: + """Configure logging based on verbosity level.""" + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig(level=level, format="%(levelname)s: %(message)s") + + def _run(args: argparse.Namespace) -> int: """Execute TTS generation logic.""" @@ -386,12 +390,6 @@ def _run(args: argparse.Namespace) -> int: return EXIT_FAILURE if failed_count > 0 else EXIT_SUCCESS -def configure_logging(verbose: bool = False) -> None: - """Configure logging based on verbosity level.""" - level = logging.DEBUG if verbose else logging.INFO - logging.basicConfig(level=level, format="%(levelname)s: %(message)s") - - def main() -> int: """Entry point for TTS voice-over generation.""" parser = create_parser() From d4a5857de03d48e9bab19861035eed6f232f2ae2 Mon Sep 17 00:00:00 2001 From: auyidi Date: Tue, 5 May 2026 16:24:48 +0000 Subject: [PATCH 65/68] fix(skills): clean up partial WAV on failure and use static timing XML template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * generate_voiceover.py: unlink partial WAV after failed synthesis * embed_audio.py: replace f-string timing XML with static template + xpath attribute set 🔧 - Generated by Copilot --- .../tts-voiceover/scripts/embed_audio.py | 22 ++++++++++++++----- .../scripts/generate_voiceover.py | 5 +++++ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py index 6e45bb8e7..f7b405412 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py +++ b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py @@ -69,13 +69,14 @@ def _add_narration_timing(slide: Slide, shape_id: int, duration_ms: int) -> None ) slide._element.remove(existing) - timing_xml = ( - '' + _PPTX_NS = "http://schemas.openxmlformats.org/presentationml/2006/main" + _TIMING_TEMPLATE = ( + f'' "" '' "" '' - f'' + '' "" '' '' @@ -84,8 +85,8 @@ def _add_narration_timing(slide: Slide, shape_id: int, duration_ms: int) -> None '' "" '' - f'' - f'' + '' + '' "" "" "" @@ -98,7 +99,16 @@ def _add_narration_timing(slide: Slide, shape_id: int, duration_ms: int) -> None "" "" ) - slide._element.append(etree.fromstring(timing_xml)) + + timing = etree.fromstring(_TIMING_TEMPLATE) + ns = {"p": _PPTX_NS} + sp_tgt = timing.find(".//p:spTgt", ns) + if sp_tgt is not None: + sp_tgt.set("spid", str(shape_id)) + ctn_dur = timing.find(".//p:cTn[@id='5']", ns) + if ctn_dur is not None: + ctn_dur.set("dur", str(duration_ms)) + slide._element.append(timing) def _set_slide_transition(slide: Slide, duration_ms: int) -> None: diff --git a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py index 2ca68cc0c..6f2037f57 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py +++ b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py @@ -367,6 +367,11 @@ def _run(args: argparse.Namespace) -> int: else: logger.error(" FAILED: %s", wav_path.name) failed_count += 1 + # Remove potentially partial file left by the SDK on failure + # so embed_audio.py does not embed a corrupt zero-duration WAV. + if wav_path.is_file(): + wav_path.unlink(missing_ok=True) + logger.debug("Removed partial file: %s", wav_path.name) if args.dry_run: print(f"\n--- Dry run complete: {slide_count} slides processed ---") From 5986338c74db7d1792079d49d76613f4de2a843e Mon Sep 17 00:00:00 2001 From: auyidi Date: Tue, 5 May 2026 17:42:38 +0000 Subject: [PATCH 66/68] fix(skills): add defensive warnings for missing timing template elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * embed_audio.py: log warning when spTgt or cTn[@id='5'] not found in parsed template 🔧 - Generated by Copilot --- .../tts-voiceover/scripts/embed_audio.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py index f7b405412..c307ef3be 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py +++ b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py @@ -105,9 +105,21 @@ def _add_narration_timing(slide: Slide, shape_id: int, duration_ms: int) -> None sp_tgt = timing.find(".//p:spTgt", ns) if sp_tgt is not None: sp_tgt.set("spid", str(shape_id)) + else: + logger.warning( + "spTgt element not found in timing template for shape %d; " + "audio shape link will be missing.", + shape_id, + ) ctn_dur = timing.find(".//p:cTn[@id='5']", ns) if ctn_dur is not None: ctn_dur.set("dur", str(duration_ms)) + else: + logger.warning( + "cTn[@id='5'] not found in timing template for shape %d; " + "audio duration will be unset.", + shape_id, + ) slide._element.append(timing) From 9adda5173841f1fcc3733cb0272a4e258b5ac468 Mon Sep 17 00:00:00 2001 From: auyidi Date: Tue, 5 May 2026 19:04:37 +0000 Subject: [PATCH 67/68] fix(skills): move timing template to module level and tighten shape_id type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * embed_audio.py: promote _PPTX_NS and _TIMING_TEMPLATE to module-level constants * embed_audio.py: change shape_id annotation from int | None to int (always an int in python-pptx) * embed_audio.py: remove dead None branch for shape_id 🔧 - Generated by Copilot --- .../tts-voiceover/scripts/embed_audio.py | 86 ++++++++----------- 1 file changed, 35 insertions(+), 51 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py index c307ef3be..869adfdb2 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py +++ b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py @@ -38,6 +38,37 @@ ICON_SIZE = Inches(0.1) TIMING_BUFFER_MS = 1500 +_PPTX_NS = "http://schemas.openxmlformats.org/presentationml/2006/main" +_TIMING_TEMPLATE = ( + f'' + "" + '' + "" + '' + '' + "" + '' + '' + "" + '' + '' + "" + '' + '' + '' + "" + "" + "" + "" + '' + "" + "" + '' + "" + "" + "" +) + def get_wav_duration_ms(wav_path: Path) -> int: """Return WAV file duration in milliseconds with buffer.""" @@ -69,37 +100,6 @@ def _add_narration_timing(slide: Slide, shape_id: int, duration_ms: int) -> None ) slide._element.remove(existing) - _PPTX_NS = "http://schemas.openxmlformats.org/presentationml/2006/main" - _TIMING_TEMPLATE = ( - f'' - "" - '' - "" - '' - '' - "" - '' - '' - "" - '' - '' - "" - '' - '' - '' - "" - "" - "" - "" - '' - "" - "" - '' - "" - "" - "" - ) - timing = etree.fromstring(_TIMING_TEMPLATE) ns = {"p": _PPTX_NS} sp_tgt = timing.find(".//p:spTgt", ns) @@ -159,26 +159,10 @@ def embed_slide_audio(slide: Slide, wav_path: Path) -> bool: height=ICON_SIZE, mime_type=AUDIO_MIME_TYPE, ) - shape_id: int | None = movie_shape.shape_id - if shape_id is not None: - duration_ms = get_wav_duration_ms(wav_path) - _add_narration_timing(slide, shape_id, duration_ms) - _set_slide_transition(slide, duration_ms) - else: - # Remove the orphaned audio shape to avoid partial state - try: - movie_shape._element.getparent().remove(movie_shape._element) - except Exception: - logger.debug( - "Could not remove orphaned shape for %s", - wav_path.name, - exc_info=True, - ) - logger.error( - "Could not find audio shape for %s; removed orphaned embed", - wav_path.name, - ) - return False + shape_id: int = movie_shape.shape_id + duration_ms = get_wav_duration_ms(wav_path) + _add_narration_timing(slide, shape_id, duration_ms) + _set_slide_transition(slide, duration_ms) return True except Exception as exc: # python-pptx raises varied internal exceptions logger.exception( From f8c4cccf46ba95bdf67689a9cc89af8612efbea2 Mon Sep 17 00:00:00 2001 From: auyidi Date: Tue, 5 May 2026 21:42:18 +0000 Subject: [PATCH 68/68] fix(skills): guard credential None at token refresh and use WAV file mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * generate_voiceover.py: add credential is None to RuntimeError guard at token refresh * embed_audio.py: build WAV mapping from directory names instead of deriving from enumerate index 🔧 - Generated by Copilot --- .../tts-voiceover/scripts/embed_audio.py | 22 ++++++++++++++----- .../scripts/generate_voiceover.py | 6 ++--- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py index 869adfdb2..4076a0408 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py +++ b/.github/skills/experimental/tts-voiceover/scripts/embed_audio.py @@ -237,18 +237,28 @@ def _run(args: argparse.Namespace) -> int: embedded_count = 0 failed_count = 0 + # Build a mapping from slide number to WAV path so embedding matches + # the directory names used by generate_voiceover.py rather than + # re-deriving names from the enumerate index. + wav_files: dict[int, Path] = {} + for wav in sorted(audio_dir.glob("slide-*.wav")): + try: + num = int(wav.stem.split("-")[1]) + wav_files[num] = wav + except (IndexError, ValueError): + logger.warning("Ignoring unexpected file: %s", wav.name) + for idx, slide in enumerate(prs.slides, start=1): - wav_name = f"slide-{idx:03d}.wav" - wav_path = audio_dir / wav_name - if not wav_path.is_file(): - logger.info("SKIP slide %d: %s not found", idx, wav_name) + wav_path = wav_files.get(idx) + if wav_path is None: + logger.info("SKIP slide %d: no WAV found", idx) continue if embed_slide_audio(slide, wav_path): embedded_count += 1 - logger.info("Embedded %s into slide %d", wav_name, idx) + logger.info("Embedded %s into slide %d", wav_path.name, idx) else: - logger.error("FAILED to embed %s into slide %d", wav_name, idx) + logger.error("FAILED to embed %s into slide %d", wav_path.name, idx) failed_count += 1 output_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py index 6f2037f57..f4b85a935 100644 --- a/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py +++ b/.github/skills/experimental/tts-voiceover/scripts/generate_voiceover.py @@ -345,10 +345,10 @@ def _run(args: argparse.Namespace) -> int: # Refresh Entra ID token before expiry. if use_entra_auth and time.time() > token_expires_at - 300: # Explicit guard rather than assert: assert is stripped under -O. - if speech_resource_id is None: + if speech_resource_id is None or credential is None: raise RuntimeError( - "Unexpected state: speech_resource_id is None when " - "use_entra_auth is True" + "Unexpected state: speech_resource_id or credential is None " + "when use_entra_auth is True" ) try: speech_config, token_expires_at = _make_entra_config(