diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87485419..b1867762 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,12 +135,14 @@ jobs: - name: System deps (PortAudio + ffmpeg) run: sudo apt-get update && sudo apt-get install -y libportaudio2 ffmpeg - # The local pytest hook runs `python -m pytest`, so the package + dev group - # must be importable. `pip install --group` needs pip >= 25.1, so upgrade first. + # The local pytest hook runs `uv run --frozen python -m pytest`, so the tests + # resolve the LOCKED dependency versions (uv.lock) rather than the newest + # release `pip install` would pull — which is what keeps the byte-exact + # `--help` snapshots stable. Install uv and materialize the frozen env here. - name: Install run: | - python -m pip install --upgrade pip - python -m pip install -e . --group dev + python -m pip install --upgrade pip uv + uv sync --frozen - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b7b91f93..af2ad0e8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -41,16 +41,25 @@ repos: - repo: local hooks: + # Run through `uv run --frozen` so tests resolve the LOCKED dependency + # versions (uv.lock), not whatever `pip install -e .` happened to pull. The + # byte-exact `--help` snapshots are pinned to the locked deps (e.g. the + # assemblyai SDK's SpeechModel enum); an unpinned `python -m pytest` breaks + # them the moment an upstream release adds a value. - id: pytest name: pytest - entry: python -m pytest -q -m "not e2e" + # Mirror check.sh: exclude both e2e (real API) and install_script (builds a + # wheel + runs install.sh / pip --user). install_script has its own dedicated + # "package install — real" CI jobs; running it here is slow and breaks under + # the locked .venv (the user-site install can't import aai_cli). + entry: uv run --frozen python -m pytest -q -m "not e2e and not install_script" language: system types: [python] pass_filenames: false always_run: true - id: pytest-e2e name: pytest-e2e (real API; skips without ASSEMBLYAI_API_KEY + kokoro) - entry: python -m pytest -q -m e2e + entry: uv run --frozen python -m pytest -q -m e2e language: system types: [python] pass_filenames: false diff --git a/AGENTS.md b/AGENTS.md index 2cf1fad2..a21ccaac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,8 @@ uv run pytest -m install_script # builds a wheel and runs install.sh for real; CLI output is pinned by **syrupy snapshot tests** (`tests/__snapshots__/*.ambr`). Changing help text, tables, or rendered output will fail those tests until you regenerate them with `uv run pytest --snapshot-update` and commit the updated `.ambr` files. The auto-format hook only touches `*.py`, and pre-commit's whitespace fixers deliberately skip `tests/__snapshots__/` (syrupy's indentation must stay byte-for-byte), so never hand-edit a snapshot — always regenerate. +The suite is hermetic by construction, enforced three ways (`tests/conftest.py` + `pyproject.toml` `[tool.pytest.ini_options]`): **pytest-randomly** shuffles order, an autouse `pin_timezone` fixture pins `TZ` to a fixed non-UTC zone (UTC-normalized rendering must be unaffected; use **time-machine** to freeze `now`), and **pytest-socket** (`--disable-socket`) blocks real network so an unmocked SDK/HTTP call fails loudly instead of hitting the API. A test that only binds a loopback server opts back in with the tight `@pytest.mark.allow_hosts(["127.0.0.1"])` (still blocks external hosts). The `e2e`/`install`/`install_script` marker suites legitimately reach the real network in-process (PyPI reachability probes, real-API runs), so a `pytest_collection_modifyitems` hook in `conftest.py` auto-grants them full sockets — adding a network marker is all that's needed, no per-test `enable_socket`. + ## Naming & packaging gotchas - The **package/module** is `aai_cli`; the **distribution** name is `aai-cli`; the **console command** is `aai` (`[project.scripts] aai = "aai_cli.main:run"`). diff --git a/aai_cli/init/templates/audio-transcription/static/app.js b/aai_cli/init/templates/audio-transcription/static/app.js index 858aad46..248f827a 100644 --- a/aai_cli/init/templates/audio-transcription/static/app.js +++ b/aai_cli/init/templates/audio-transcription/static/app.js @@ -2,12 +2,12 @@ const APP_CONFIG = { sampleUrl: "https://assembly.ai/wildfires.mp3", pollIntervalMs: 2000, speakerPalette: [ - "#171717", - "#525252", - "#737373", - "#262626", - "#404040", - "#a3a3a3", + "#3923c7", + "#1d1b16", + "#614fd2", + "#4a4945", + "#887bdd", + "#777673", ], }; diff --git a/aai_cli/init/templates/audio-transcription/static/index.html b/aai_cli/init/templates/audio-transcription/static/index.html index 8ad004ba..3d4ced9c 100644 --- a/aai_cli/init/templates/audio-transcription/static/index.html +++ b/aai_cli/init/templates/audio-transcription/static/index.html @@ -8,9 +8,14 @@
+ + + +
@@ -38,6 +43,11 @@

Transcribe a pre-recorded file

+ +
diff --git a/aai_cli/init/templates/audio-transcription/static/styles.css b/aai_cli/init/templates/audio-transcription/static/styles.css index 67cbf5fe..c45851bc 100644 --- a/aai_cli/init/templates/audio-transcription/static/styles.css +++ b/aai_cli/init/templates/audio-transcription/static/styles.css @@ -1,47 +1,98 @@ -/* THEME TOKENS: edit this block first for monotone colors, spacing, radii, and width. */ +/* THEME TOKENS — AssemblyAI design system. Edit this block first to retheme. + Brand fonts hotlink from assemblyai.com (CORS-open) with system fallbacks. */ +@font-face { + font-family: "Oceanic Text"; + src: url("https://www.assemblyai.com/_aai/fonts/rebrand/OceanicText-Regular.otf") + format("opentype"); + font-weight: 400; + font-display: swap; +} +@font-face { + font-family: "Oceanic Text"; + src: url("https://www.assemblyai.com/_aai/fonts/rebrand/OceanicText-Medium.otf") + format("opentype"); + font-weight: 500; + font-display: swap; +} +@font-face { + font-family: "UN 11ST"; + src: url("https://www.assemblyai.com/_aai/fonts/rebrand/UN_11ST_Regular.woff2") + format("woff2"); + font-weight: 400; + font-display: swap; +} +@font-face { + font-family: "UN 11ST"; + src: url("https://www.assemblyai.com/_aai/fonts/rebrand/UN_11ST_Bold.woff2") + format("woff2"); + font-weight: 700; + font-display: swap; +} +@font-face { + font-family: "Modern Gothic Mono"; + src: url("https://www.assemblyai.com/_aai/fonts/rebrand/ModernGothicMono-Regular.woff2") + format("woff2"); + font-weight: 400; + font-display: swap; +} + :root { - --font-sans: - -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; - --background: oklch(0.985 0 0); - --foreground: oklch(0.12 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.12 0 0); - --primary: oklch(0.12 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.965 0 0); - --secondary-foreground: oklch(0.38 0 0); - --muted: oklch(0.94 0 0); - --muted-foreground: oklch(0.58 0 0); - --accent: oklch(0.965 0 0); - --accent-foreground: oklch(0.12 0 0); - --border: oklch(0.9 0 0); - --input: oklch(0.9 0 0); - --ring: oklch(0.5 0 0); - --shadow-panel: 0 1px 3px oklch(0 0 0 / 0.05), 0 1px 1px oklch(0 0 0 / 0.03); - --shadow-focus: - 0 0 0 1px oklch(0 0 0 / 0.08), 0 2px 8px -2px oklch(0 0 0 / 0.08); - --color-bg: var(--background); - --color-surface: var(--card); - --color-text: var(--foreground); - --color-text-muted: var(--muted-foreground); - --color-text-soft: var(--secondary-foreground); - --color-border: var(--border); - --color-accent: var(--primary); - --color-accent-contrast: var(--primary-foreground); - --color-accent-soft: var(--secondary); - --color-accent-tint: var(--muted); - --color-success: var(--foreground); - --color-success-soft: var(--secondary); - --color-danger: var(--foreground); - --color-danger-soft: var(--secondary); - --color-neutral: var(--secondary-foreground); - --color-neutral-soft: var(--secondary); - --radius-panel: 8px; + /* Brand fonts */ + --font-display: "Oceanic Text", Georgia, serif; + --font-body: "UN 11ST", system-ui, sans-serif; + --font-mono: "Modern Gothic Mono", "JetBrains Mono", monospace; + + /* Cobolt (brand purple — the only accent) */ + --color-cobolt-500: #3923c7; + --color-cobolt-300: #887bdd; + --color-cobolt-200: #b0a7e9; + --color-cobolt-100: #d7d3f4; + + /* Warm neutrals */ + --color-black-500: #1d1b16; + --color-black-400: #4a4945; + --color-black-300: #777673; + --color-black-200: #a5a4a2; + --color-white-100: #ffffff; + --color-white-200: #fdfcf8; + --color-white-300: #f5f3eb; + --color-neutral-100: #ecebe5; + --color-neutral-200: #dad7cb; + --color-neutral-300: #c7c3b2; + + /* Blush (error highlight only) */ + --color-blush-200: #f4d4d0; + --color-blush-500: #e39389; + + /* Semantic aliases — downstream rules use these */ + --color-bg: var(--color-white-200); + --color-surface: var(--color-white-100); + --color-text: var(--color-black-400); + --color-text-dark: var(--color-black-500); + --color-text-muted: var(--color-black-300); + --color-text-soft: var(--color-black-400); + --color-border: var(--color-neutral-300); + --color-accent: var(--color-cobolt-500); + --color-accent-hover: var(--color-cobolt-300); + --color-accent-contrast: var(--color-white-100); + --color-accent-soft: var(--color-cobolt-100); + --color-accent-tint: var(--color-white-300); + --color-success: var(--color-cobolt-500); + --color-success-soft: var(--color-cobolt-100); + --color-danger: var(--color-blush-500); + --color-danger-soft: var(--color-blush-200); + --color-neutral: var(--color-black-300); + --color-neutral-soft: var(--color-neutral-200); + + --shadow-focus: 0 0 0 3px var(--color-cobolt-100); + + --radius-cta: 4px; --radius-control: 8px; - --radius-pill: 999px; - --space-page-block: 48px; - --space-page-inline: 16px; - --space-panel: 20px; + --radius-panel: 12px; + --radius-pill: 9999px; + --space-page-block: 64px; + --space-page-inline: 24px; + --space-panel: 24px; --content-width: 760px; } @@ -56,12 +107,24 @@ body { padding: var(--space-page-block) var(--space-page-inline); background: var(--color-bg); color: var(--color-text); - font: 15px/1.6 var(--font-sans); + font: 16px/1.3 var(--font-body); -moz-osx-font-smoothing: grayscale; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; } +h1, +h2, +h3, +h4, +h5, +h6 { + font-family: var(--font-display); + font-weight: 400; + line-height: 1; + color: var(--color-text-dark); +} + button, input { font: inherit; @@ -81,27 +144,83 @@ button { margin: 0 auto; } +/* BRAND CHROME */ +.brand { + display: inline-flex; + align-items: center; + margin-bottom: 40px; +} + +.brand-logo { + display: block; + width: auto; + height: 20px; +} + +.eyebrow { + display: inline-flex; + align-items: center; + margin-bottom: 16px; + padding: 8px 16px; + border: 1px solid var(--color-border); + border-radius: var(--radius-cta); + color: var(--color-black-400); + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: 1.2px; + text-transform: uppercase; + font-feature-settings: "ss09" 1; +} + .page-header { - margin-bottom: 24px; + margin-bottom: 32px; } .page-title { - margin: 0 0 4px; - font-size: 22px; - line-height: 1.25; + margin: 0 0 12px; + font-size: 48px; + letter-spacing: -2.4px; } .page-subtitle { - max-width: 68ch; + max-width: 60ch; margin: 0; + font-size: 18px; + color: var(--color-text-muted); +} + +.page-footer { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 64px; + padding-top: 24px; + border-top: 1px solid var(--color-border); color: var(--color-text-muted); + font-size: 14px; } +.footer-link { + color: var(--color-accent); + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: 1.2px; + text-transform: uppercase; + text-decoration: none; + transition: color 0.2s ease; +} + +.footer-link:hover { + color: var(--color-accent-hover); +} + +/* PANELS */ .panel { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-panel); - box-shadow: var(--shadow-panel); padding: var(--space-panel); } @@ -121,10 +240,11 @@ button { min-width: 0; border: 1px solid var(--color-border); border-radius: var(--radius-control); - padding: 11px 13px; + padding: 10px 13px; background: var(--color-surface); color: var(--color-text); - font-size: 14px; + font-size: 16px; + transition: border-color 0.15s ease; } .text-input:focus { @@ -137,22 +257,25 @@ button { --button-bg: var(--color-accent); --button-fg: var(--color-accent-contrast); display: inline-flex; - min-height: 44px; + min-height: 40px; align-items: center; justify-content: center; border: 0; - border-radius: var(--radius-control); + border-radius: var(--radius-cta); background: var(--button-bg); color: var(--button-fg); cursor: pointer; + font-family: var(--font-mono); font-size: 14px; - font-weight: 600; - padding: 0 18px; + letter-spacing: 1.4px; + text-transform: uppercase; + padding: 0 16px; white-space: nowrap; + transition: background-color 0.2s ease; } .button:hover:not(:disabled) { - filter: brightness(0.96); + --button-bg: var(--color-accent-hover); } .button:focus-visible, @@ -173,7 +296,10 @@ button { gap: 12px; margin: 16px 0; color: var(--color-text-muted); - font-size: 13px; + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: 1.2px; + text-transform: uppercase; } .divider::before, @@ -190,9 +316,10 @@ button { border-radius: var(--radius-control); color: var(--color-text-muted); cursor: pointer; - font-size: 14px; + font-size: 15px; padding: 16px; text-align: center; + transition: all 0.2s ease; } .upload-dropzone:hover { @@ -202,12 +329,15 @@ button { /* STATUS STATES */ .status-pill { - display: inline-block; + display: inline-flex; + align-items: center; margin: 18px 0; + border: 1px solid var(--color-neutral-300); border-radius: var(--radius-pill); - font-size: 13px; - font-weight: 600; - padding: 6px 13px; + background: var(--color-neutral-200); + color: var(--color-black-300); + font-size: 14px; + padding: 5px 14px; } .status-pill:empty { @@ -215,23 +345,27 @@ button { } .status-pill[data-state="working"] { - background: var(--color-accent-soft); - color: var(--color-accent); + border-color: var(--color-cobolt-200); + background: var(--color-cobolt-100); + color: var(--color-cobolt-500); } .status-pill[data-state="done"] { - background: var(--color-success-soft); - color: var(--color-success); + border-color: var(--color-cobolt-500); + background: var(--color-cobolt-500); + color: var(--color-white-100); } .status-pill[data-state="error"] { - background: var(--color-danger-soft); - color: var(--color-danger); + border-color: var(--color-blush-500); + background: var(--color-blush-200); + color: var(--color-black-500); } /* TRANSCRIPT Q&A */ .answer-panel { margin-top: 12px; + border-left: 3px solid var(--color-accent); border-radius: var(--radius-control); background: var(--color-accent-tint); color: var(--color-text); @@ -253,13 +387,21 @@ button { .tab-button { border: 1px solid var(--color-border); - border-radius: var(--radius-pill); + border-radius: var(--radius-cta); background: var(--color-surface); - color: var(--color-text-soft); + color: var(--color-text); cursor: pointer; - font-size: 13px; - font-weight: 600; + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: 1.2px; + text-transform: uppercase; padding: 6px 14px; + transition: all 0.2s ease; +} + +.tab-button:hover { + border-color: var(--color-accent); + color: var(--color-accent); } .tab-button.is-active { @@ -271,6 +413,7 @@ button { /* RESULT VIEWS */ .timestamp { color: var(--color-text-muted); + font-family: var(--font-mono); font-size: 12px; font-variant-numeric: tabular-nums; } @@ -282,7 +425,13 @@ button { } .speaker-label { - font-weight: 700; + display: inline-block; + margin-bottom: 2px; + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: 1.2px; + text-transform: uppercase; + color: var(--color-text-dark); } .transcript-text { @@ -298,7 +447,8 @@ button { .chapter-card h4 { margin: 0 0 2px; - font-size: 15px; + font-size: 24px; + letter-spacing: -1.2px; } .chapter-summary { @@ -308,6 +458,7 @@ button { .entity-label { color: var(--color-text-muted); + font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.05em; text-transform: uppercase; @@ -316,6 +467,7 @@ button { .entity-tag { display: inline-block; margin: 4px 4px 0 0; + border: 1px solid var(--color-cobolt-200); border-radius: var(--radius-pill); background: var(--color-accent-soft); color: var(--color-accent); @@ -335,16 +487,18 @@ button { border-radius: var(--radius-pill); background: var(--color-accent-soft); color: var(--color-accent); + font-family: var(--font-mono); font-size: 12px; - font-weight: 700; padding: 2px 9px; } .sentiment-pill { margin-left: 8px; border-radius: var(--radius-pill); + font-family: var(--font-mono); font-size: 11px; - font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; padding: 1px 8px; } @@ -355,7 +509,7 @@ button { .sentiment-pill[data-sentiment="negative"] { background: var(--color-danger-soft); - color: var(--color-danger); + color: var(--color-black-500); } .sentiment-pill[data-sentiment="neutral"] { @@ -369,9 +523,14 @@ button { white-space: pre-wrap; } -@media (max-width: 640px) { +@media (max-width: 768px) { :root { - --space-page-block: 32px; + --space-page-block: 40px; + } + + .page-title { + font-size: 30px; + letter-spacing: -1.5px; } .control-row, diff --git a/aai_cli/init/templates/live-captions/static/index.html b/aai_cli/init/templates/live-captions/static/index.html index 82d9a81e..0539cdd2 100644 --- a/aai_cli/init/templates/live-captions/static/index.html +++ b/aai_cli/init/templates/live-captions/static/index.html @@ -8,9 +8,14 @@
+ + + +
@@ -21,6 +26,11 @@

Live captions

+ +
diff --git a/aai_cli/init/templates/live-captions/static/styles.css b/aai_cli/init/templates/live-captions/static/styles.css index 7fe51e1e..93e8dec3 100644 --- a/aai_cli/init/templates/live-captions/static/styles.css +++ b/aai_cli/init/templates/live-captions/static/styles.css @@ -1,37 +1,81 @@ -/* THEME TOKENS: edit this block first for monotone colors, spacing, radii, and width. */ +/* THEME TOKENS — AssemblyAI design system. Edit this block first to retheme. + Brand fonts hotlink from assemblyai.com (CORS-open) with system fallbacks. */ +@font-face { + font-family: "Oceanic Text"; + src: url("https://www.assemblyai.com/_aai/fonts/rebrand/OceanicText-Regular.otf") + format("opentype"); + font-weight: 400; + font-display: swap; +} +@font-face { + font-family: "UN 11ST"; + src: url("https://www.assemblyai.com/_aai/fonts/rebrand/UN_11ST_Regular.woff2") + format("woff2"); + font-weight: 400; + font-display: swap; +} +@font-face { + font-family: "UN 11ST"; + src: url("https://www.assemblyai.com/_aai/fonts/rebrand/UN_11ST_Bold.woff2") + format("woff2"); + font-weight: 700; + font-display: swap; +} +@font-face { + font-family: "Modern Gothic Mono"; + src: url("https://www.assemblyai.com/_aai/fonts/rebrand/ModernGothicMono-Regular.woff2") + format("woff2"); + font-weight: 400; + font-display: swap; +} + :root { - --font-sans: - -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; - --background: oklch(0.985 0 0); - --foreground: oklch(0.12 0 0); - --card: oklch(1 0 0); - --primary: oklch(0.12 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.965 0 0); - --secondary-foreground: oklch(0.38 0 0); - --muted-foreground: oklch(0.58 0 0); - --border: oklch(0.9 0 0); - --ring: oklch(0.5 0 0); - --shadow-panel: 0 1px 3px oklch(0 0 0 / 0.05), 0 1px 1px oklch(0 0 0 / 0.03); - --shadow-focus: - 0 0 0 1px oklch(0 0 0 / 0.08), 0 2px 8px -2px oklch(0 0 0 / 0.08); - --color-bg: var(--background); - --color-surface: var(--card); - --color-text: var(--foreground); - --color-text-muted: var(--muted-foreground); - --color-border: var(--border); - --color-accent: var(--primary); - --color-accent-contrast: var(--primary-foreground); - --color-danger: var(--foreground); - --color-danger-strong: var(--foreground); - --color-danger-soft: var(--secondary); - --color-neutral: var(--secondary-foreground); - --color-neutral-soft: var(--secondary); - --radius-panel: 8px; + /* Brand fonts */ + --font-display: "Oceanic Text", Georgia, serif; + --font-body: "UN 11ST", system-ui, sans-serif; + --font-mono: "Modern Gothic Mono", "JetBrains Mono", monospace; + + /* Cobolt (brand purple — the only accent) */ + --color-cobolt-500: #3923c7; + --color-cobolt-300: #887bdd; + --color-cobolt-200: #b0a7e9; + --color-cobolt-100: #d7d3f4; + + /* Warm neutrals */ + --color-black-500: #1d1b16; + --color-black-400: #4a4945; + --color-black-300: #777673; + --color-white-100: #ffffff; + --color-white-200: #fdfcf8; + --color-white-300: #f5f3eb; + --color-neutral-100: #ecebe5; + --color-neutral-200: #dad7cb; + --color-neutral-300: #c7c3b2; + + /* Blush (error highlight only) */ + --color-blush-200: #f4d4d0; + --color-blush-500: #e39389; + + /* Semantic aliases — downstream rules use these */ + --color-bg: var(--color-white-200); + --color-surface: var(--color-white-100); + --color-text: var(--color-black-400); + --color-text-dark: var(--color-black-500); + --color-text-muted: var(--color-black-300); + --color-border: var(--color-neutral-300); + --color-accent: var(--color-cobolt-500); + --color-accent-hover: var(--color-cobolt-300); + --color-accent-contrast: var(--color-white-100); + --color-live: var(--color-black-500); + + --shadow-focus: 0 0 0 3px var(--color-cobolt-100); + + --radius-cta: 4px; --radius-control: 8px; - --radius-pill: 999px; - --space-page-block: 48px; - --space-page-inline: 16px; + --radius-panel: 12px; + --radius-pill: 9999px; + --space-page-block: 64px; + --space-page-inline: 24px; --content-width: 720px; } @@ -46,12 +90,24 @@ body { padding: var(--space-page-block) var(--space-page-inline); background: var(--color-bg); color: var(--color-text); - font: 15px/1.6 var(--font-sans); + font: 16px/1.3 var(--font-body); -moz-osx-font-smoothing: grayscale; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; } +h1, +h2, +h3, +h4, +h5, +h6 { + font-family: var(--font-display); + font-weight: 400; + line-height: 1; + color: var(--color-text-dark); +} + button { appearance: none; font: inherit; @@ -63,22 +119,78 @@ button { margin: 0 auto; } +/* BRAND CHROME */ +.brand { + display: inline-flex; + align-items: center; + margin-bottom: 40px; +} + +.brand-logo { + display: block; + width: auto; + height: 20px; +} + +.eyebrow { + display: inline-flex; + align-items: center; + margin-bottom: 16px; + padding: 8px 16px; + border: 1px solid var(--color-border); + border-radius: var(--radius-cta); + color: var(--color-black-400); + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: 1.2px; + text-transform: uppercase; + font-feature-settings: "ss09" 1; +} + .page-header { - margin-bottom: 24px; + margin-bottom: 32px; } .page-title { - margin: 0 0 4px; - font-size: 22px; - line-height: 1.25; + margin: 0 0 12px; + font-size: 48px; + letter-spacing: -2.4px; } .page-subtitle { - max-width: 68ch; + max-width: 60ch; margin: 0; + font-size: 18px; color: var(--color-text-muted); } +.page-footer { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 64px; + padding-top: 24px; + border-top: 1px solid var(--color-border); + color: var(--color-text-muted); + font-size: 14px; +} + +.footer-link { + color: var(--color-accent); + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: 1.2px; + text-transform: uppercase; + text-decoration: none; + transition: color 0.2s ease; +} + +.footer-link:hover { + color: var(--color-accent-hover); +} + .control-bar { display: flex; align-items: center; @@ -91,22 +203,25 @@ button { --button-bg: var(--color-accent); --button-fg: var(--color-accent-contrast); display: inline-flex; - min-height: 44px; + min-height: 40px; align-items: center; justify-content: center; border: 0; - border-radius: var(--radius-control); + border-radius: var(--radius-cta); background: var(--button-bg); color: var(--button-fg); cursor: pointer; + font-family: var(--font-mono); font-size: 14px; - font-weight: 600; - padding: 0 20px; + letter-spacing: 1.4px; + text-transform: uppercase; + padding: 0 18px; white-space: nowrap; + transition: background-color 0.2s ease; } .button:hover:not(:disabled) { - filter: brightness(0.96); + --button-bg: var(--color-accent-hover); } .button:focus-visible { @@ -120,57 +235,76 @@ button { } .record-button[data-state="recording"] { - --button-bg: var(--color-danger-strong); + --button-bg: var(--color-live); +} + +.record-button[data-state="recording"]:hover:not(:disabled) { + --button-bg: var(--color-black-400); } /* STATUS STATES */ .status-pill { - display: inline-block; + display: inline-flex; + align-items: center; + border: 1px solid var(--color-neutral-300); border-radius: var(--radius-pill); - font-size: 13px; - font-weight: 600; - padding: 5px 12px; + background: var(--color-neutral-200); + color: var(--color-black-300); + font-size: 14px; + padding: 5px 14px; } .status-pill:empty { display: none; } -.status-pill[data-state="live"], -.status-pill[data-state="error"] { - background: var(--color-danger-soft); - color: var(--color-danger); +.status-pill[data-state="live"] { + border-color: var(--color-cobolt-200); + background: var(--color-cobolt-100); + color: var(--color-cobolt-500); } .status-pill[data-state="idle"] { - background: var(--color-neutral-soft); - color: var(--color-neutral); + border-color: var(--color-neutral-300); + background: var(--color-neutral-200); + color: var(--color-black-300); +} + +.status-pill[data-state="error"] { + border-color: var(--color-blush-500); + background: var(--color-blush-200); + color: var(--color-black-500); } /* CAPTION VIEW */ .caption-output { - min-height: 120px; + min-height: 140px; margin-top: 24px; border: 1px solid var(--color-border); border-radius: var(--radius-panel); background: var(--color-surface); - box-shadow: var(--shadow-panel); - font-size: 19px; - line-height: 1.7; - padding: 18px; + font-size: 28px; + line-height: 1.45; + letter-spacing: -0.2px; + padding: 24px; } .caption-final { - color: var(--color-text); + color: var(--color-text-dark); } .caption-partial { color: var(--color-text-muted); } -@media (max-width: 640px) { +@media (max-width: 768px) { :root { - --space-page-block: 32px; + --space-page-block: 40px; + } + + .page-title { + font-size: 30px; + letter-spacing: -1.5px; } .button { diff --git a/aai_cli/init/templates/voice-agent/static/index.html b/aai_cli/init/templates/voice-agent/static/index.html index 8c5f7e6b..311794f6 100644 --- a/aai_cli/init/templates/voice-agent/static/index.html +++ b/aai_cli/init/templates/voice-agent/static/index.html @@ -8,9 +8,14 @@
+ + + +
@@ -19,6 +24,11 @@

Talk to a voice agent

+ +
diff --git a/aai_cli/init/templates/voice-agent/static/styles.css b/aai_cli/init/templates/voice-agent/static/styles.css index c3223409..2ea6cc6f 100644 --- a/aai_cli/init/templates/voice-agent/static/styles.css +++ b/aai_cli/init/templates/voice-agent/static/styles.css @@ -1,37 +1,82 @@ -/* THEME TOKENS: edit this block first for monotone colors, spacing, radii, and width. */ +/* THEME TOKENS — AssemblyAI design system. Edit this block first to retheme. + Brand fonts hotlink from assemblyai.com (CORS-open) with system fallbacks. */ +@font-face { + font-family: "Oceanic Text"; + src: url("https://www.assemblyai.com/_aai/fonts/rebrand/OceanicText-Regular.otf") + format("opentype"); + font-weight: 400; + font-display: swap; +} +@font-face { + font-family: "UN 11ST"; + src: url("https://www.assemblyai.com/_aai/fonts/rebrand/UN_11ST_Regular.woff2") + format("woff2"); + font-weight: 400; + font-display: swap; +} +@font-face { + font-family: "UN 11ST"; + src: url("https://www.assemblyai.com/_aai/fonts/rebrand/UN_11ST_Bold.woff2") + format("woff2"); + font-weight: 700; + font-display: swap; +} +@font-face { + font-family: "Modern Gothic Mono"; + src: url("https://www.assemblyai.com/_aai/fonts/rebrand/ModernGothicMono-Regular.woff2") + format("woff2"); + font-weight: 400; + font-display: swap; +} + :root { - --font-sans: - -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; - --background: oklch(0.985 0 0); - --foreground: oklch(0.12 0 0); - --card: oklch(1 0 0); - --primary: oklch(0.12 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.965 0 0); - --secondary-foreground: oklch(0.38 0 0); - --muted-foreground: oklch(0.58 0 0); - --border: oklch(0.9 0 0); - --shadow-focus: - 0 0 0 1px oklch(0 0 0 / 0.08), 0 2px 8px -2px oklch(0 0 0 / 0.08); - --color-bg: var(--background); - --color-surface: var(--card); - --color-text: var(--foreground); - --color-text-muted: var(--muted-foreground); - --color-border: var(--border); - --color-accent: var(--primary); - --color-accent-contrast: var(--primary-foreground); - --color-user: oklch(0.42 0 0); - --color-success: var(--foreground); - --color-success-soft: var(--secondary); - --color-danger: var(--foreground); - --color-danger-strong: var(--foreground); - --color-danger-soft: var(--secondary); - --color-neutral: var(--secondary-foreground); - --color-neutral-soft: var(--secondary); + /* Brand fonts */ + --font-display: "Oceanic Text", Georgia, serif; + --font-body: "UN 11ST", system-ui, sans-serif; + --font-mono: "Modern Gothic Mono", "JetBrains Mono", monospace; + + /* Cobolt (brand purple — the only accent) */ + --color-cobolt-500: #3923c7; + --color-cobolt-300: #887bdd; + --color-cobolt-200: #b0a7e9; + --color-cobolt-100: #d7d3f4; + + /* Warm neutrals */ + --color-black-500: #1d1b16; + --color-black-400: #4a4945; + --color-black-300: #777673; + --color-white-100: #ffffff; + --color-white-200: #fdfcf8; + --color-white-300: #f5f3eb; + --color-neutral-100: #ecebe5; + --color-neutral-200: #dad7cb; + --color-neutral-300: #c7c3b2; + + /* Blush (error highlight only) */ + --color-blush-200: #f4d4d0; + --color-blush-500: #e39389; + + /* Semantic aliases — downstream rules use these */ + --color-bg: var(--color-white-200); + --color-surface: var(--color-white-100); + --color-text: var(--color-black-400); + --color-text-dark: var(--color-black-500); + --color-text-muted: var(--color-black-300); + --color-border: var(--color-neutral-300); + --color-accent: var(--color-cobolt-500); + --color-accent-hover: var(--color-cobolt-300); + --color-accent-contrast: var(--color-white-100); + --color-user: var(--color-black-400); + --color-connected: var(--color-black-500); + + --shadow-focus: 0 0 0 3px var(--color-cobolt-100); + + --radius-cta: 4px; --radius-control: 8px; - --radius-pill: 999px; - --space-page-block: 48px; - --space-page-inline: 16px; + --radius-panel: 12px; + --radius-pill: 9999px; + --space-page-block: 64px; + --space-page-inline: 24px; --content-width: 720px; } @@ -46,12 +91,24 @@ body { padding: var(--space-page-block) var(--space-page-inline); background: var(--color-bg); color: var(--color-text); - font: 15px/1.6 var(--font-sans); + font: 16px/1.3 var(--font-body); -moz-osx-font-smoothing: grayscale; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; } +h1, +h2, +h3, +h4, +h5, +h6 { + font-family: var(--font-display); + font-weight: 400; + line-height: 1; + color: var(--color-text-dark); +} + button { appearance: none; font: inherit; @@ -63,22 +120,78 @@ button { margin: 0 auto; } +/* BRAND CHROME */ +.brand { + display: inline-flex; + align-items: center; + margin-bottom: 40px; +} + +.brand-logo { + display: block; + width: auto; + height: 20px; +} + +.eyebrow { + display: inline-flex; + align-items: center; + margin-bottom: 16px; + padding: 8px 16px; + border: 1px solid var(--color-border); + border-radius: var(--radius-cta); + color: var(--color-black-400); + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: 1.2px; + text-transform: uppercase; + font-feature-settings: "ss09" 1; +} + .page-header { - margin-bottom: 24px; + margin-bottom: 32px; } .page-title { - margin: 0 0 4px; - font-size: 22px; - line-height: 1.25; + margin: 0 0 12px; + font-size: 48px; + letter-spacing: -2.4px; } .page-subtitle { - max-width: 68ch; + max-width: 60ch; margin: 0; + font-size: 18px; color: var(--color-text-muted); } +.page-footer { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 64px; + padding-top: 24px; + border-top: 1px solid var(--color-border); + color: var(--color-text-muted); + font-size: 14px; +} + +.footer-link { + color: var(--color-accent); + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: 1.2px; + text-transform: uppercase; + text-decoration: none; + transition: color 0.2s ease; +} + +.footer-link:hover { + color: var(--color-accent-hover); +} + .control-bar { display: flex; align-items: center; @@ -91,22 +204,25 @@ button { --button-bg: var(--color-accent); --button-fg: var(--color-accent-contrast); display: inline-flex; - min-height: 44px; + min-height: 40px; align-items: center; justify-content: center; border: 0; - border-radius: var(--radius-control); + border-radius: var(--radius-cta); background: var(--button-bg); color: var(--button-fg); cursor: pointer; + font-family: var(--font-mono); font-size: 14px; - font-weight: 600; - padding: 0 20px; + letter-spacing: 1.4px; + text-transform: uppercase; + padding: 0 18px; white-space: nowrap; + transition: background-color 0.2s ease; } .button:hover:not(:disabled) { - filter: brightness(0.96); + --button-bg: var(--color-accent-hover); } .button:focus-visible { @@ -114,17 +230,29 @@ button { outline: none; } +.button:disabled { + cursor: default; + opacity: 0.55; +} + .connection-button[data-state="connected"] { - --button-bg: var(--color-danger-strong); + --button-bg: var(--color-connected); +} + +.connection-button[data-state="connected"]:hover:not(:disabled) { + --button-bg: var(--color-black-400); } /* STATUS STATES */ .status-pill { - display: inline-block; + display: inline-flex; + align-items: center; + border: 1px solid var(--color-neutral-300); border-radius: var(--radius-pill); - font-size: 13px; - font-weight: 600; - padding: 5px 12px; + background: var(--color-neutral-200); + color: var(--color-black-300); + font-size: 14px; + padding: 5px 14px; } .status-pill:empty { @@ -132,18 +260,21 @@ button { } .status-pill[data-state="live"] { - background: var(--color-success-soft); - color: var(--color-success); + border-color: var(--color-cobolt-200); + background: var(--color-cobolt-100); + color: var(--color-cobolt-500); } .status-pill[data-state="idle"] { - background: var(--color-neutral-soft); - color: var(--color-neutral); + border-color: var(--color-neutral-300); + background: var(--color-neutral-200); + color: var(--color-black-300); } .status-pill[data-state="error"] { - background: var(--color-danger-soft); - color: var(--color-danger); + border-color: var(--color-blush-500); + background: var(--color-blush-200); + color: var(--color-black-500); } /* CONVERSATION VIEW */ @@ -167,7 +298,10 @@ button { } .turn-speaker { - font-weight: 700; + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: 1.2px; + text-transform: uppercase; } .conversation-turn[data-speaker="you"] .turn-speaker { @@ -178,9 +312,14 @@ button { color: var(--color-accent); } -@media (max-width: 640px) { +@media (max-width: 768px) { :root { - --space-page-block: 32px; + --space-page-block: 40px; + } + + .page-title { + font-size: 30px; + letter-spacing: -1.5px; } .button, diff --git a/aai_cli/main.py b/aai_cli/main.py index dab589b8..de4d7537 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -95,11 +95,21 @@ def parse_args(self, ctx: ClickContext, args: list[str]) -> list[str]: return super().parse_args(ctx, args) -# Typer renders option flags and command names in "bold cyan" by default; retint -# both to the brand accent (the logo blue) so the help screen matches the rest of -# the CLI. Set before the app renders any help. +# Typer's default help palette is a rainbow: option flags/command names in "bold cyan", +# the short switch (e.g. -p) in "bold green", and the type metavar (e.g. TEXT) in "bold +# yellow". Retint the whole panel into the Cobolt brand family so help reads as one +# monochrome hierarchy: flags and command names in the bold primary accent, their short +# aliases matching, and the type metavar in the lighter secondary Cobolt so it recedes. +# Set before the app renders any help. rich_utils.STYLE_OPTION = f"bold {theme.BRAND}" rich_utils.STYLE_COMMANDS_TABLE_FIRST_COLUMN = f"bold {theme.BRAND}" +rich_utils.STYLE_SWITCH = f"bold {theme.BRAND}" +rich_utils.STYLE_METAVAR = theme.ACCENT +# The usage line ("Usage: aai [OPTIONS] COMMAND [ARGS]...") defaults to yellow. Keep the +# program name in the bold brand accent so it matches command names elsewhere, but drop +# the "Usage:" label and arg spec to muted warm gray — it's boilerplate that should recede. +rich_utils.STYLE_USAGE = theme.MUTED +rich_utils.STYLE_USAGE_COMMAND = f"bold {theme.BRAND}" # Typer's built-in `--show-completion` help is long enough to wrap several lines in # the options panel. Trim it so it fits on fewer rows. The OptionInfo objects live on diff --git a/aai_cli/theme.py b/aai_cli/theme.py index 68f0af45..00e223fe 100644 --- a/aai_cli/theme.py +++ b/aai_cli/theme.py @@ -6,11 +6,29 @@ from rich.text import Text from rich.theme import Theme -# AssemblyAI brand accent. Defined once so the whole CLI can be re-tinted here. -BRAND = "#2545D3" -# Secondary accent — a darker blue used where a second hue is needed (agent label, -# links), kept in the blue family rather than the old cyan. -DARK_BLUE = "#1E3A8A" +# Cobolt — the AssemblyAI brand purple, taken from the marketing design system. +# The website's primary CTA is Cobolt 500; its dark-mode primary is Cobolt 300. +# A terminal's background is unknown (dark or light), so the CLI uses the mid step, +# Cobolt 400, as its accent: bright enough to read on black, saturated enough to read +# on white, and unambiguously the brand purple either way. +COBOLT_500 = "#3923c7" # web primary CTA / brand purple +COBOLT_400 = "#614fd2" # CLI primary accent (terminal-legible mid step) +COBOLT_300 = "#887bdd" # web dark-mode primary / hover; CLI secondary accent + +# Brand accent. Defined once so the whole CLI can be re-tinted here. +BRAND = COBOLT_400 +# Secondary accent — the lighter Cobolt 300 used where a second hue is needed (agent +# label, links), so it stays in the brand family yet reads distinct from BRAND. +ACCENT = COBOLT_300 + +# Semantic error red, matching the design system's --color-error. Rich downsamples it +# to plain red on terminals without truecolor. +ERROR = "#F04438" + +# Muted warm gray, matching the design system's --color-text-muted (Black 300). A true +# mid-gray that reads on both dark and light terminals; used for quiet labels (e.g. the +# help "Usage:" header) that should recede rather than carry the brand accent. +MUTED = "#777673" # A fixed affordance vocabulary used across all human-facing output, mirroring the # Vercel/Supabase convention of a small, consistent status alphabet: one glyph per @@ -22,7 +40,7 @@ SYMBOL_HINT = "›" # noqa: RUF001 — deliberate angle-quote glyph, not a '>' typo # Per-speaker label colors, rotated deterministically by speaker_style(). Deliberately -# excludes the brand blue: that hue is reserved for "you" (aai.you) so a diarized system +# excludes the brand purple: that hue is reserved for "you" (aai.you) so a diarized system # speaker can never be tinted the same color as your own mic. SPEAKER_STYLES: tuple[str, ...] = ( "aai.speaker.0", @@ -41,15 +59,15 @@ # for a diarized speaker, see SPEAKER_STYLES), the agent gets a distinct hue so # "you:" and "agent:" are easy to tell apart at a glance. "aai.you": BRAND, - "aai.agent": DARK_BLUE, - # Links/URLs in the dark-blue secondary accent so a clickable target stands out - # from prose without shouting (Vercel/Supabase use a cool accent for the same). - "aai.url": DARK_BLUE, + "aai.agent": ACCENT, + # Links/URLs in the lighter Cobolt secondary accent so a clickable target stands + # out from prose without shouting (Vercel/Supabase use a cool accent for the same). + "aai.url": ACCENT, # Semantic status colors. Success is bold so the ✓ reads as a confident "done" - # (Supabase-style); error/warn follow the universal red/yellow; muted secondary - # text stays dim so it recedes (the Vercel "quiet by default" look). + # (Supabase-style); error uses the brand's semantic red, warn the universal yellow; + # muted secondary text stays dim so it recedes (the Vercel "quiet by default" look). "aai.success": "bold green", - "aai.error": "bold red", + "aai.error": f"bold {ERROR}", "aai.warn": "yellow", "aai.muted": "dim", "aai.speaker.0": "dark_orange", diff --git a/pyproject.toml b/pyproject.toml index db627800..2da7eef3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,14 @@ dev = [ "pytest-mock>=3.14.0", "pytest-randomly>=3.16.0", "pytest-xdist>=3.6.0", + # Hermeticity guards beyond order-randomization (pytest-randomly): + # pytest-socket blocks real network in the unit suite so an unmocked SDK/HTTP + # call fails loudly instead of silently hitting the API (see addopts below); + # time-machine lets tests freeze the clock, complementing the TZ pin in + # tests/conftest.py so time rendering is deterministic across machines. + # Floors sit at the second-newest release so safe-chain's min-age gate can resolve. + "pytest-socket>=0.7.0", + "time-machine>=3.1.0", "hypothesis>=6.155.1", "ruff>=0.15.15", "mypy>=2.1.0", @@ -106,6 +114,14 @@ exclude = ["**/__pycache__", "**/*.pyc"] [tool.pytest.ini_options] testpaths = ["tests"] +# Block real network in every pytest invocation (pytest-socket). The unit suite is +# fully mocked, so an accidental real SDK/HTTP call now raises SocketBlockedError +# instead of silently reaching the API — hard-enforcing the unit/e2e split we keep +# by convention. --allow-unix-socket keeps AF_UNIX (used by some libs) working. Tests +# that bind a loopback server (OAuth callback, dev server, tunnel) opt back in with +# `@pytest.mark.allow_hosts([...])`. The e2e/install suites drive the CLI as a +# subprocess, whose sockets this in-process patch never sees, so they're unaffected. +addopts = "--disable-socket --allow-unix-socket" # Treat warnings as errors so a deprecation or resource leak introduced in a change # fails the build instead of scrolling past. Listed-later filters take precedence, so # the targeted ignores below override the blanket `error`. diff --git a/tests/conftest.py b/tests/conftest.py index 7cfce098..05d23408 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ import os +import time import keyring import pytest @@ -9,6 +10,19 @@ # unit tests still run fully isolated. REAL_API_KEY = os.environ.get("ASSEMBLYAI_API_KEY") +# Suites that legitimately reach the real network in-process (PyPI reachability +# probes before a real install, real-API e2e) are gated behind these markers. They +# opt out of the suite-wide --disable-socket; every other test stays blocked, so an +# unmocked call in the unit suite still fails loudly. Tests that only bind a loopback +# server use the tighter `@pytest.mark.allow_hosts(["127.0.0.1"])` instead. +_NETWORK_MARKERS = ("e2e", "install", "install_script") + + +def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: + for item in items: + if any(item.get_closest_marker(name) for name in _NETWORK_MARKERS): + item.add_marker(pytest.mark.enable_socket) + @pytest.fixture def real_api_key(): @@ -54,6 +68,19 @@ def isolate_env(monkeypatch): monkeypatch.delenv(var, raising=False) +@pytest.fixture(autouse=True) +def pin_timezone(monkeypatch): + # Pin the host timezone so any time rendering is deterministic across machines and + # CI, regardless of the contributor's local zone. A fixed *non-UTC* zone is + # deliberate: the CLI normalizes everything to UTC (timeparse.format_utc_*), so + # correct code is unaffected, while a future naive/local clock would visibly shift + # here instead of only on a laptop that happens to sit in that zone. Tests that + # need a frozen "now" use time-machine on top of this. + monkeypatch.setenv("TZ", "America/New_York") + if hasattr(time, "tzset"): + time.tzset() + + @pytest.fixture(autouse=True) def reset_active_environment(): # The active environment is a process-global (set at CLI startup); pin it to diff --git a/tests/test_auth_loopback.py b/tests/test_auth_loopback.py index c587fdbf..8290d1c0 100644 --- a/tests/test_auth_loopback.py +++ b/tests/test_auth_loopback.py @@ -8,6 +8,11 @@ from aai_cli.auth import endpoints, loopback from aai_cli.errors import APIError +# These tests bind a real loopback HTTP server and connect to it, so they opt back +# into sockets past the suite-wide --disable-socket (see pyproject pytest config). +# Restricting to 127.0.0.1 keeps the external-network block intact. +pytestmark = pytest.mark.allow_hosts(["127.0.0.1"]) + @pytest.fixture(autouse=True) def _unique_loopback_port(monkeypatch): diff --git a/tests/test_init_runner.py b/tests/test_init_runner.py index 04e157f4..903f49a1 100644 --- a/tests/test_init_runner.py +++ b/tests/test_init_runner.py @@ -60,11 +60,17 @@ def test_serve_command_uv_and_venv(): ] +# These two bind a real loopback socket, so they opt back into sockets past the +# suite-wide --disable-socket (see pyproject pytest config); 127.0.0.1 only, so the +# external-network block stays in force. test_find_free_port_raises_when_all_taken +# monkeypatches the probe and needs no socket. +@pytest.mark.allow_hosts(["127.0.0.1"]) def test_find_free_port_returns_preferred_when_open(): port = runner.find_free_port(0) # 0 -> OS assigns a free port assert isinstance(port, int) and port > 0 +@pytest.mark.allow_hosts(["127.0.0.1"]) def test_find_free_port_skips_taken_port(): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.bind(("127.0.0.1", 0)) diff --git a/tests/test_setup_render.py b/tests/test_setup_render.py index 9c4df89f..8efd4bb2 100644 --- a/tests/test_setup_render.py +++ b/tests/test_setup_render.py @@ -19,10 +19,14 @@ def test_render_steps_colors_status() -> None: assert "[aai.heading]" in rendered # ...and renders to real ANSI through the themed console. buf = io.StringIO() - console = theme.make_console(file=buf, force_terminal=True, color_system="truecolor") + # _environ={} pins the color depth: Rich reads ambient color env at render time, + # which leaks across tests and otherwise flips truecolor to a 16-color downsample. + console = theme.make_console( + file=buf, force_terminal=True, color_system="truecolor", _environ={} + ) console.print(rendered) out = buf.getvalue() assert "installed" in out assert "failed" in out assert "\x1b[1;32m" in out # aai.success (bold green) → "installed" - assert "\x1b[1;31m" in out # aai.error (bold red) → "failed" + assert "\x1b[1;38;2;240;68;56m" in out # aai.error (bold brand red #F04438) → "failed" diff --git a/tests/test_transcripts.py b/tests/test_transcripts.py index e53ab24c..ebfaaf13 100644 --- a/tests/test_transcripts.py +++ b/tests/test_transcripts.py @@ -134,10 +134,13 @@ def test_list_table_colors_status(monkeypatch, mocker): config.set_api_key("default", "sk_live") monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: False) - # Force a real color terminal so styling produces ANSI we can assert on. + # Pin a truecolor console with an empty _environ so the rendered ANSI is + # deterministic: Rich otherwise reads ambient color env (NO_COLOR/COLORTERM/...) + # at render time, which leaks across tests and flips the color depth. With + # _environ={} the depth is fixed by color_system alone. monkeypatch.setattr( "aai_cli.output.console", - make_console(force_terminal=True, color_system="truecolor"), + make_console(force_terminal=True, color_system="truecolor", _environ={}), ) rows = [ {"id": "t1", "status": "completed", "created": "2026-01-01"}, @@ -151,4 +154,4 @@ def test_list_table_colors_status(monkeypatch, mocker): assert "completed" in result.output assert "error" in result.output assert "\x1b[1;32m" in result.output # aai.success (bold green) → "completed" cell - assert "\x1b[1;31m" in result.output # aai.error (bold red) → "error" cell + assert "\x1b[1;38;2;240;68;56m" in result.output # aai.error (bold #F04438) → "error" cell diff --git a/uv.lock b/uv.lock index 342b3638..e9bafbcd 100644 --- a/uv.lock +++ b/uv.lock @@ -45,11 +45,13 @@ dev = [ { name = "pytest-cov" }, { name = "pytest-mock" }, { name = "pytest-randomly" }, + { name = "pytest-socket" }, { name = "pytest-xdist" }, { name = "python-dotenv" }, { name = "python-multipart" }, { name = "ruff" }, { name = "syrupy" }, + { name = "time-machine" }, { name = "uvicorn" }, { name = "vulture" }, { name = "xenon" }, @@ -90,11 +92,13 @@ dev = [ { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "pytest-mock", specifier = ">=3.14.0" }, { name = "pytest-randomly", specifier = ">=3.16.0" }, + { name = "pytest-socket", specifier = ">=0.7.0" }, { name = "pytest-xdist", specifier = ">=3.6.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "python-multipart", specifier = ">=0.0.9" }, { name = "ruff", specifier = ">=0.15.15" }, { name = "syrupy", specifier = ">=4.0" }, + { name = "time-machine", specifier = ">=3.1.0" }, { name = "uvicorn", specifier = ">=0.30.0" }, { name = "vulture", specifier = ">=2.14" }, { name = "xenon", specifier = ">=0.9.3" }, @@ -1492,6 +1496,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/db/2df9a1fca597a273f957a559c20c2d95d629928384507b2afa43ba6909d1/pytest_randomly-4.1.0-py3-none-any.whl", hash = "sha256:f55e89e53367b090c0c053697d7f9d77595543d0e0516c93978b50c0f6b252f9", size = 8353, upload-time = "2026-04-20T13:01:50.382Z" }, ] +[[package]] +name = "pytest-socket" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/3c/f9b58e57830e58980dbe8867d0e348f45701d3f3ea065672d448f4366da5/pytest_socket-0.8.0.tar.gz", hash = "sha256:af9bb5f487da72be63573a6194cfac033b6c7a1c1561e150521105970f9e99f2", size = 13912, upload-time = "2026-05-21T16:50:22.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/e8/4a8568580bae3dcd678599ed8e86a82d505a44df71c1ced4246c1aa14b4b/pytest_socket-0.8.0-py3-none-any.whl", hash = "sha256:81821ba59f07d7600fe2b551d8714f40b068bd46e8b6704c48664e9d60cdacb8", size = 8414, upload-time = "2026-05-21T16:50:21.022Z" }, +] + [[package]] name = "pytest-xdist" version = "3.8.0" @@ -1771,6 +1787,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/18/dc99a152bea18a898a8ac387bfeb9ec0829e0f5bed11cfec2e2ca189c5a2/syrupy-5.2.0-py3-none-any.whl", hash = "sha256:798cb493a6e20f4839e58ea8f10eb1b0d85684c676442f79786e219bf32618e6", size = 51828, upload-time = "2026-05-16T21:11:34.984Z" }, ] +[[package]] +name = "time-machine" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/fc/37b02f6094dbb1f851145330460532176ed2f1dc70511a35828166c41e52/time_machine-3.2.0.tar.gz", hash = "sha256:a4ddd1cea17b8950e462d1805a42b20c81eb9aafc8f66b392dd5ce997e037d79", size = 14804, upload-time = "2025-12-17T23:33:02.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/8b/080c8eedcd67921a52ba5bd0e075362062509ab63c86fc1a0442fad241a6/time_machine-3.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc4bee5b0214d7dc4ebc91f4a4c600f1a598e9b5606ac751f42cb6f6740b1dbb", size = 19255, upload-time = "2025-12-17T23:31:58.057Z" }, + { url = "https://files.pythonhosted.org/packages/66/17/0e5291e9eb705bf8a5a1305f826e979af307bbeb79def4ddbf4b3f9a81e0/time_machine-3.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ca036304b4460ae2fdc1b52dd8b1fa7cf1464daa427fc49567413c09aa839c1", size = 15360, upload-time = "2025-12-17T23:31:59.048Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/9ab87b71d2e2b62463b9b058b7ae7ac09fb57f8fcd88729dec169d304340/time_machine-3.2.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5442735b41d7a2abc2f04579b4ca6047ed4698a8338a4fec92c7c9423e7938cb", size = 33029, upload-time = "2025-12-17T23:32:00.413Z" }, + { url = "https://files.pythonhosted.org/packages/4b/26/b5ca19da6f25ea905b3e10a0ea95d697c1aeba0404803a43c68f1af253e6/time_machine-3.2.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:97da3e971e505cb637079fb07ab0bcd36e33279f8ecac888ff131f45ef1e4d8d", size = 34579, upload-time = "2025-12-17T23:32:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/79/ca/6ac7ad5f10ea18cc1d9de49716ba38c32132c7b64532430d92ef240c116b/time_machine-3.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3cdda6dee4966e38aeb487309bb414c6cb23a81fc500291c77a8fcd3098832e7", size = 35961, upload-time = "2025-12-17T23:32:02.521Z" }, + { url = "https://files.pythonhosted.org/packages/33/67/390dd958bed395ab32d79a9fe61fe111825c0dd4ded54dbba7e867f171e6/time_machine-3.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:33d9efd302a6998bcc8baa4d84f259f8a4081105bd3d7f7af7f1d0abd3b1c8aa", size = 34668, upload-time = "2025-12-17T23:32:03.585Z" }, + { url = "https://files.pythonhosted.org/packages/da/57/c88fff034a4e9538b3ae7c68c9cfb283670b14d17522c5a8bc17d29f9a4b/time_machine-3.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3a0b0a33971f14145853c9bd95a6ab0353cf7e0019fa2a7aa1ae9fddfe8eab50", size = 32891, upload-time = "2025-12-17T23:32:04.656Z" }, + { url = "https://files.pythonhosted.org/packages/2d/70/ebbb76022dba0fec8f9156540fc647e4beae1680c787c01b1b6200e56d70/time_machine-3.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2d0be9e5f22c38082d247a2cdcd8a936504e9db60b7b3606855fb39f299e9548", size = 34080, upload-time = "2025-12-17T23:32:06.146Z" }, + { url = "https://files.pythonhosted.org/packages/db/9a/2ca9e7af3df540dc1c79e3de588adeddb7dcc2107829248e6969c4f14167/time_machine-3.2.0-cp312-cp312-win32.whl", hash = "sha256:3f74623648b936fdce5f911caf386c0a0b579456410975de8c0dfeaaffece1d8", size = 17371, upload-time = "2025-12-17T23:32:07.164Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ce/21d23efc9c2151939af1b7ee4e60d86d661b74ef32b8eaa148f6fe8c899c/time_machine-3.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:34e26a41d994b5e4b205136a90e9578470386749cc9a2ecf51ca18f83ce25e23", size = 18132, upload-time = "2025-12-17T23:32:08.447Z" }, + { url = "https://files.pythonhosted.org/packages/2f/34/c2b70be483accf6db9e5d6c3139bce3c38fe51f898ccf64e8d3fe14fbf4d/time_machine-3.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:0615d3d82c418d6293f271c348945c5091a71f37e37173653d5c26d0e74b13a8", size = 16930, upload-time = "2025-12-17T23:32:09.477Z" }, + { url = "https://files.pythonhosted.org/packages/ee/cd/43ad5efc88298af3c59b66769cea7f055567a85071579ed40536188530c1/time_machine-3.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c421a8eb85a4418a7675a41bf8660224318c46cc62e4751c8f1ceca752059090", size = 19318, upload-time = "2025-12-17T23:32:10.518Z" }, + { url = "https://files.pythonhosted.org/packages/b0/f6/084010ef7f4a3f38b5a4900923d7c85b29e797655c4f6ee4ce54d903cca8/time_machine-3.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f4e758f7727d0058c4950c66b58200c187072122d6f7a98b610530a4233ea7b", size = 15390, upload-time = "2025-12-17T23:32:11.625Z" }, + { url = "https://files.pythonhosted.org/packages/25/aa/1cabb74134f492270dc6860cb7865859bf40ecf828be65972827646e91ad/time_machine-3.2.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:154bd3f75c81f70218b2585cc12b60762fb2665c507eec5ec5037d8756d9b4e0", size = 33115, upload-time = "2025-12-17T23:32:13.219Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/78c5d7dfa366924eb4dbfcc3fc917c39a4280ca234b12819cc1f16c03d88/time_machine-3.2.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d50cfe5ebea422c896ad8d278af9648412b7533b8ea6adeeee698a3fd9b1d3b7", size = 34705, upload-time = "2025-12-17T23:32:14.29Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/d5e877c24541f674c6869ff6e9c56833369796010190252e92c9d7ae5f0f/time_machine-3.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:636576501724bd6a9124e69d86e5aef263479e89ef739c5db361469f0463a0a1", size = 36104, upload-time = "2025-12-17T23:32:15.354Z" }, + { url = "https://files.pythonhosted.org/packages/22/1c/d4bae72f388f67efc9609f89b012e434bb19d9549c7a7b47d6c7d9e5c55d/time_machine-3.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:40e6f40c57197fcf7ec32d2c563f4df0a82c42cdcc3cab27f688e98f6060df10", size = 34765, upload-time = "2025-12-17T23:32:16.434Z" }, + { url = "https://files.pythonhosted.org/packages/1d/c3/ac378cf301d527d8dfad2f0db6bad0dfb1ab73212eaa56d6b96ee5d9d20b/time_machine-3.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a1bcf0b846bbfc19a79bc19e3fa04d8c7b1e8101c1b70340ffdb689cd801ea53", size = 33010, upload-time = "2025-12-17T23:32:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/06/35/7ce897319accda7a6970b288a9a8c52d25227342a7508505a2b3d235b649/time_machine-3.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ae55a56c179f4fe7a62575ad5148b6ed82f6c7e5cf2f9a9ec65f2f5b067db5f5", size = 34185, upload-time = "2025-12-17T23:32:18.566Z" }, + { url = "https://files.pythonhosted.org/packages/bf/28/f922022269749cb02eee2b62919671153c4088994fa955a6b0e50327ff81/time_machine-3.2.0-cp313-cp313-win32.whl", hash = "sha256:a66fe55a107e46916007a391d4030479df8864ec6ad6f6a6528221befc5c886e", size = 17397, upload-time = "2025-12-17T23:32:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/ee/dc/fd87cde397f4a7bea493152f0aca8fd569ec709cad9e0f2ca7011eb8c7f7/time_machine-3.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:30c9ce57165df913e4f74e285a8ab829ff9b7aa3e5ec0973f88f642b9a7b3d15", size = 18139, upload-time = "2025-12-17T23:32:20.991Z" }, + { url = "https://files.pythonhosted.org/packages/75/81/b8ce58233addc5d7d54d2fabc49dcbc02d79e3f079d150aa1bec3d5275ef/time_machine-3.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:89cad7e179e9bdcc84dcf09efe52af232c4cc7a01b3de868356bbd59d95bd9b8", size = 16964, upload-time = "2025-12-17T23:32:22.075Z" }, + { url = "https://files.pythonhosted.org/packages/67/e7/487f0ba5fe6c58186a5e1af2a118dfa2c160fedb37ef53a7e972d410408e/time_machine-3.2.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:59d71545e62525a4b85b6de9ab5c02ee3c61110fd7f636139914a2335dcbfc9c", size = 20000, upload-time = "2025-12-17T23:32:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/e1/17/eb2c0054c8d44dd42df84ccd434539249a9c7d0b8eb53f799be2102500ab/time_machine-3.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:999672c621c35362bc28e03ca0c7df21500195540773c25993421fd8d6cc5003", size = 15657, upload-time = "2025-12-17T23:32:24.125Z" }, + { url = "https://files.pythonhosted.org/packages/43/21/93443b5d1dd850f8bb9442e90d817a9033dcce6bfbdd3aabbb9786251c80/time_machine-3.2.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5faf7397f0580c7b9d67288522c8d7863e85f0cffadc0f1fccdb2c3dfce5783e", size = 39216, upload-time = "2025-12-17T23:32:25.542Z" }, + { url = "https://files.pythonhosted.org/packages/9f/9e/18544cf8acc72bb1dc03762231c82ecc259733f4bb6770a7bbe5cd138603/time_machine-3.2.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3dd886ec49f1fa5a00e844f5947e5c0f98ce574750c24b7424c6f77fc1c3e87", size = 40764, upload-time = "2025-12-17T23:32:26.643Z" }, + { url = "https://files.pythonhosted.org/packages/27/f7/9fe9ce2795636a3a7467307af6bdf38bb613ddb701a8a5cd50ec713beb5e/time_machine-3.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da0ecd96bc7bbe450acaaabe569d84e81688f1be8ad58d1470e42371d145fb53", size = 43526, upload-time = "2025-12-17T23:32:27.693Z" }, + { url = "https://files.pythonhosted.org/packages/03/c1/a93e975ba9dec22e87ec92d18c28e67d36bd536f9119ffa439b2892b0c9c/time_machine-3.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:158220e946c1c4fb8265773a0282c88c35a7e3bb5d78e3561214e3b3231166f3", size = 41727, upload-time = "2025-12-17T23:32:28.985Z" }, + { url = "https://files.pythonhosted.org/packages/5f/fb/e3633e5a6bbed1c76bb2e9810dabc2f8467532ffcd29b9aed404b473061a/time_machine-3.2.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8c1aee29bc54356f248d5d7dfdd131e12ca825e850a08c0ebdb022266d073013", size = 38952, upload-time = "2025-12-17T23:32:30.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/3d/02e9fb2526b3d6b1b45bc8e4d912d95d1cd699d1a3f6df985817d37a0600/time_machine-3.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c8ed2224f09d25b1c2fc98683613aca12f90f682a427eabb68fc824d27014e4a", size = 39829, upload-time = "2025-12-17T23:32:31.075Z" }, + { url = "https://files.pythonhosted.org/packages/85/c8/c14265212436da8e0814c45463987b3f57de3eca4de023cc2eabb0c62ef3/time_machine-3.2.0-cp313-cp313t-win32.whl", hash = "sha256:3498719f8dab51da76d29a20c1b5e52ee7db083dddf3056af7fa69c1b94e1fe6", size = 17852, upload-time = "2025-12-17T23:32:32.079Z" }, + { url = "https://files.pythonhosted.org/packages/1d/bc/8acb13cf6149f47508097b158a9a8bec9ec4530a70cb406124e8023581f5/time_machine-3.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e0d90bee170b219e1d15e6a58164aa808f5170090e4f090bd0670303e34181b1", size = 18918, upload-time = "2025-12-17T23:32:33.106Z" }, + { url = "https://files.pythonhosted.org/packages/24/87/c443ee508c2708fd2514ccce9052f5e48888783ce690506919629ebc8eb0/time_machine-3.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:051de220fdb6e20d648111bbad423d9506fdbb2e44d4429cef3dc0382abf1fc2", size = 17261, upload-time = "2025-12-17T23:32:34.446Z" }, + { url = "https://files.pythonhosted.org/packages/61/70/b4b980d126ed155c78d1879c50d60c8dcbd47bd11cb14ee7be50e0dfc07f/time_machine-3.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:1398980c017fe5744d66f419e0115ee48a53b00b146d738e1416c225eb610b82", size = 19303, upload-time = "2025-12-17T23:32:35.796Z" }, + { url = "https://files.pythonhosted.org/packages/73/73/eaa33603c69a68fe2b6f54f9dd75481693d62f1d29676531002be06e2d1c/time_machine-3.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4f8f4e35f4191ef70c2ab8ff490761ee9051b891afce2bf86dde3918eb7b537b", size = 15431, upload-time = "2025-12-17T23:32:37.244Z" }, + { url = "https://files.pythonhosted.org/packages/76/10/b81e138e86cc7bab40cdb59d294b341e172201f4a6c84bb0ec080407977a/time_machine-3.2.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6db498686ecf6163c5aa8cf0bcd57bbe0f4081184f247edf3ee49a2612b584f9", size = 33206, upload-time = "2025-12-17T23:32:38.713Z" }, + { url = "https://files.pythonhosted.org/packages/d3/72/4deab446b579e8bd5dca91de98595c5d6bd6a17ce162abf5c5f2ce40d3d8/time_machine-3.2.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:027c1807efb74d0cd58ad16524dec94212fbe900115d70b0123399883657ac0f", size = 34792, upload-time = "2025-12-17T23:32:40.223Z" }, + { url = "https://files.pythonhosted.org/packages/2c/39/439c6b587ddee76d533fe972289d0646e0a5520e14dc83d0a30aeb5565f7/time_machine-3.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92432610c05676edd5e6946a073c6f0c926923123ce7caee1018dc10782c713d", size = 36187, upload-time = "2025-12-17T23:32:41.705Z" }, + { url = "https://files.pythonhosted.org/packages/4b/db/2da4368db15180989bab83746a857bde05ad16e78f326801c142bb747a06/time_machine-3.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c25586b62480eb77ef3d953fba273209478e1ef49654592cd6a52a68dfe56a67", size = 34855, upload-time = "2025-12-17T23:32:42.817Z" }, + { url = "https://files.pythonhosted.org/packages/88/84/120a431fee50bc4c241425bee4d3a4910df4923b7ab5f7dff1bf0c772f08/time_machine-3.2.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6bf3a2fa738d15e0b95d14469a0b8ea42635467408d8b490e263d5d45c9a177f", size = 33222, upload-time = "2025-12-17T23:32:43.94Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ea/89cfda82bb8c57ff91bb9a26751aa234d6d90e9b4d5ab0ad9dce0f9f0329/time_machine-3.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ce76b82276d7ad2a66cdc85dad4df19d1422b69183170a34e8fbc4c3f35502f7", size = 34270, upload-time = "2025-12-17T23:32:45.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/aa/235357da4f69a51a8d35fcbfcfa77cdc7dc24f62ae54025006570bda7e2d/time_machine-3.2.0-cp314-cp314-win32.whl", hash = "sha256:14d6778273c543441863dff712cd1d7803dee946b18de35921eb8df10714539d", size = 17544, upload-time = "2025-12-17T23:32:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/7b/51/6c8405a7276be79693b792cff22ce41067ec05db26a7d02f2d5b06324434/time_machine-3.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbf821da96dbc80d349fa9e7c36e670b41d68a878d28c8850057992fed430eef", size = 18423, upload-time = "2025-12-17T23:32:47.468Z" }, + { url = "https://files.pythonhosted.org/packages/d9/03/a3cf419e20c35fc203c6e4fed48b5b667c1a2b4da456d9971e605f73ecef/time_machine-3.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:71c75d71f8e68abc8b669bca26ed2ddd558430a6c171e32b8620288565f18c0e", size = 17050, upload-time = "2025-12-17T23:32:48.91Z" }, + { url = "https://files.pythonhosted.org/packages/86/a1/142de946dc4393f910bf4564b5c3ba819906e1f49b06c9cb557519c849e4/time_machine-3.2.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4e374779021446fc2b5c29d80457ec9a3b1a5df043dc2aae07d7c1415d52323c", size = 19991, upload-time = "2025-12-17T23:32:49.933Z" }, + { url = "https://files.pythonhosted.org/packages/ee/62/7f17def6289901f94726921811a16b9adce46e666362c75d45730c60274f/time_machine-3.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:122310a6af9c36e9a636da32830e591e7923e8a07bdd0a43276c3a36c6821c90", size = 15707, upload-time = "2025-12-17T23:32:50.969Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d3/3502fb9bd3acb159c18844b26c43220201a0d4a622c0c853785d07699a92/time_machine-3.2.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ba3eeb0f018cc362dd8128befa3426696a2e16dd223c3fb695fde184892d4d8c", size = 39207, upload-time = "2025-12-17T23:32:52.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/be/8b27f4aa296fda14a5a2ad7f588ddd450603c33415ab3f8e85b2f1a44678/time_machine-3.2.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:77d38ba664b381a7793f8786efc13b5004f0d5f672dae814430445b8202a67a6", size = 40764, upload-time = "2025-12-17T23:32:53.167Z" }, + { url = "https://files.pythonhosted.org/packages/42/cd/fe4c4e5c8ab6d48fab3624c32be9116fb120173a35fe67e482e5cf68b3d2/time_machine-3.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f09abeb8f03f044d72712207e0489a62098ad3ad16dac38927fcf80baca4d6a7", size = 43508, upload-time = "2025-12-17T23:32:54.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/28/5a3ba2fce85b97655a425d6bb20a441550acd2b304c96b2c19d3839f721a/time_machine-3.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6b28367ce4f73987a55e230e1d30a57a3af85da8eb1a140074eb6e8c7e6ef19f", size = 41712, upload-time = "2025-12-17T23:32:55.781Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/e38084be7fdabb4835db68a3a47e58c34182d79fc35df1ecbe0db2c5359f/time_machine-3.2.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:903c7751c904581da9f7861c3015bed7cdc40047321291d3694a3cdc783bbca3", size = 38939, upload-time = "2025-12-17T23:32:56.867Z" }, + { url = "https://files.pythonhosted.org/packages/40/d0/ad3feb0a392ef4e0c08bc32024950373ddc0669002cbdcbb9f3bf0c2d114/time_machine-3.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:528217cad85ede5f85c8bc78b0341868d3c3cfefc6ecb5b622e1cacb6c73247b", size = 39837, upload-time = "2025-12-17T23:32:58.283Z" }, + { url = "https://files.pythonhosted.org/packages/5b/9e/5f4b2ea63b267bd78f3245e76f5528836611b5f2d30b5e7300a722fe4428/time_machine-3.2.0-cp314-cp314t-win32.whl", hash = "sha256:75724762ffd517e7e80aaec1fad1ff5a7414bd84e2b3ee7a0bacfeb67c14926e", size = 18091, upload-time = "2025-12-17T23:32:59.403Z" }, + { url = "https://files.pythonhosted.org/packages/39/6f/456b1f4d2700ae02b19eba830f870596a4b89b74bac3b6c80666f1b108c5/time_machine-3.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2526abbd053c5bca898d1b3e7898eec34626b12206718d8c7ce88fd12c1c9c5c", size = 19208, upload-time = "2025-12-17T23:33:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2f/22/8063101427ecd3d2652aada4d21d0876b07a3dc789125bca2ee858fec3ed/time_machine-3.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:7f2fb6784b414edbe2c0b558bfaab0c251955ba27edd62946cce4a01675a992c", size = 17359, upload-time = "2025-12-17T23:33:01.54Z" }, +] + [[package]] name = "tomli" version = "2.4.1"