From b091562b09125006deb31cc33c75ce767ef4b4f1 Mon Sep 17 00:00:00 2001 From: Jelmer de Wit <1598297+jdwit@users.noreply.github.com> Date: Tue, 26 May 2026 23:04:02 +0200 Subject: [PATCH 1/8] docs: mkdocs-material site with auto-generated command reference New documentation site at docs/ powered by Material for MkDocs: - Topic pages: index, installation, OAuth setup, multi-channel profiles, bulk video updates, live broadcasts, comments, analytics, and an API quota cheatsheet. - Auto-generated command reference via an on_pre_build mkdocs hook that runs 'python -m typer ytstudio.main utils docs' so the reference page cannot drift from --help. Generation failures fail the build hard. - Material theme features: navigation tabs + instant nav, light/dark palette toggle, code copy, content tabs, admonitions, Mermaid diagrams via pymdownx.superfences, and Material icons via pymdownx.emoji. CI deploy via .github/workflows/docs.yml: strict build on PRs (no deploy), and 'mkdocs gh-deploy' on push to main (guarded by ref check and concurrency lock so two refs can't race to force-push gh-pages). uv sync uses --locked. pyproject.toml gets a 'docs' dependency group (mkdocs-material, pymdown-extensions, typer-cli). docs/reference.md and site/ are gitignored as build artefacts. Workflow informed by Context7 docs lookup and a Codex review pass. Codex found 2 high (videos and comments command names did not match the real CLI), 4 medium/low (hook fail-soft, missing PR guard on deploy, missing PR build, --locked, missing pymdownx.emoji, analytics quota wording); all applied. --- .github/workflows/docs.yml | 51 +++++ .gitignore | 4 + README.md | 2 + docs/analytics.md | 37 ++++ docs/api-quota.md | 38 ++++ docs/comments.md | 44 +++++ docs/index.md | 60 ++++++ docs/installation.md | 44 +++++ docs/livestreams.md | 96 ++++++++++ docs/profiles.md | 54 ++++++ docs/setup.md | 72 +++++++ docs/videos.md | 63 ++++++ mkdocs.yml | 100 ++++++++++ pyproject.toml | 6 + scripts/build_docs_reference.py | 51 +++++ uv.lock | 327 +++++++++++++++++++++++++++++++- 16 files changed, 1044 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/docs.yml create mode 100644 docs/analytics.md create mode 100644 docs/api-quota.md create mode 100644 docs/comments.md create mode 100644 docs/index.md create mode 100644 docs/installation.md create mode 100644 docs/livestreams.md create mode 100644 docs/profiles.md create mode 100644 docs/setup.md create mode 100644 docs/videos.md create mode 100644 mkdocs.yml create mode 100644 scripts/build_docs_reference.py diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..d9116ff --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,51 @@ +name: Docs + +on: + push: + branches: [main] + pull_request: + branches: [main] + paths: + - docs/** + - mkdocs.yml + - scripts/build_docs_reference.py + - pyproject.toml + - uv.lock + - .github/workflows/docs.yml + workflow_dispatch: + +permissions: + contents: write + +# Serialize deploys against the gh-pages branch; PR builds run in parallel +# because they only build and never push. +concurrency: + group: docs-${{ github.event_name == 'pull_request' && github.head_ref || 'deploy' }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + + - name: Set up Python + run: uv python install 3.12 + + - name: Install docs dependencies + run: uv sync --locked --group docs + + - name: Build (strict) + run: uv run mkdocs build --strict + + - name: Deploy to gh-pages + if: | + (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + && github.ref == 'refs/heads/main' + run: uv run mkdocs gh-deploy --force --no-history diff --git a/.gitignore b/.gitignore index 8292cfd..fb3ff9e 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,7 @@ htmlcov/ .vscode/ .DS_Store demo/ + +# Docs build artefacts and generated content +site/ +docs/reference.md diff --git a/README.md b/README.md index 8eecb65..0ffc569 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ uv tool install ytstudio-cli The CLI is installed as `ytstudio`; `yts` is a short alias for the same entry point. +Full documentation: [https://jdwit.github.io/ytstudio-cli/](https://jdwit.github.io/ytstudio-cli/). + ## Setup 1. Create a [Google Cloud project](https://console.cloud.google.com/) diff --git a/docs/analytics.md b/docs/analytics.md new file mode 100644 index 0000000..a21a862 --- /dev/null +++ b/docs/analytics.md @@ -0,0 +1,37 @@ +# Analytics + +`ytstudio analytics` queries the YouTube Analytics API: totals, daily +breakdowns, per-video and per-country views, watch time, and the rest. +Output defaults to a Rich table; pass `-o json` for scripting. + +## Common queries + +```bash +ytstudio analytics overview # channel summary for the last period +ytstudio analytics top --by views # top videos by views +ytstudio analytics video # one video's recent performance +ytstudio analytics by-country --metric views +ytstudio analytics by-day --metric views # last 7 days +``` + +Run `ytstudio analytics --help` (or open the +[Command reference](reference.md)) for every flag. + +## Output + +=== "Table" + + ```bash + ytstudio analytics top --by views + ``` + +=== "JSON" + + ```bash + ytstudio analytics top --by views -o json | jq '.rows[0]' + ``` + +## Quota + +Analytics reads are cheap (1 unit per call); you can hit them often without +worrying. See [API quota](api-quota.md) for the full picture. diff --git a/docs/api-quota.md b/docs/api-quota.md new file mode 100644 index 0000000..a1334aa --- /dev/null +++ b/docs/api-quota.md @@ -0,0 +1,38 @@ +# API quota + +The YouTube Data API enforces a daily quota of **10 000 units per project** +by default. Most read operations (`videos.list`, `comments.list`, channel +info) cost 1 unit, while write operations like `videos.update`, +`liveBroadcasts.insert`/`update`, and `comments.setModerationStatus` cost +about 50 units each. + +When you exceed the quota, the API returns HTTP 403 with a +`quotaExceeded` reason. ytstudio surfaces a clear error and exits. +Quota resets at midnight Pacific Time. + +The **YouTube Analytics API** is a separate API with its own quota model +(query-cost based, far more generous in practice), so analytics calls do +not eat into the table below. + +## Data API cost cheatsheet + +| Operation | Approx. quota cost | +|-------------------------------------|--------------------| +| `videos.list` | 1 unit | +| `commentThreads.list` | 1 unit | +| `channels.list` | 1 unit | +| `liveBroadcasts.list` | 1 unit | +| `videos.update` | 50 units | +| `comments.setModerationStatus` | 50 units | +| `liveBroadcasts.insert` | 50 units | +| `liveBroadcasts.update` | 50 units | +| `liveBroadcasts.transition` | 50 units | + +## Increasing the quota + +In the Google Cloud Console, go to **IAM & Admin** :material-arrow-right: +**Quotas** and file a quota increase request for the YouTube Data API. +Google's response time varies; expect a couple of days. + +Reference: +[YouTube quota documentation](https://developers.google.com/youtube/v3/getting-started#quota). diff --git a/docs/comments.md b/docs/comments.md new file mode 100644 index 0000000..9212d9a --- /dev/null +++ b/docs/comments.md @@ -0,0 +1,44 @@ +# Comments + +`ytstudio comments` lists comment threads and moderates them. It is built +for the scripted case where the YouTube Studio UI gets in the way: +filtering threads, approving or rejecting in bulk, exporting JSON for +downstream tooling. + +## List + +```bash +ytstudio comments list --video +ytstudio comments list --video -o json | jq + +# Held queue, ordered chronologically +ytstudio comments list --video --moderation-status held --order time +``` + +`--moderation-status` accepts `published`, `held`, or `spam` (mapped +internally to YouTube's `published`, `heldForReview`, and `likelySpam` +values). + +## Approve held comments + +```bash +ytstudio comments publish [ ...] +``` + +`publish` takes one or more comment IDs and transitions each to +`published`. + +## Reject + +```bash +ytstudio comments reject [ ...] +ytstudio comments reject --ban # also ban the author +``` + +`reject` hides the comment from public view. Pass `--ban` to also ban the +author from the channel. + +!!! note "Quota" + + `comments.setModerationStatus` costs about 50 quota units per call. For + larger moderation runs, see [API quota](api-quota.md). diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..a2cede1 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,60 @@ +# ytstudio + +Manage and analyze your YouTube channel from the terminal. Ideal for +automation and AI workflows. + +[![CI](https://github.com/jdwit/ytstudio-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/jdwit/ytstudio-cli/actions/workflows/ci.yml) +[![PyPI](https://img.shields.io/pypi/v/ytstudio-cli)](https://pypi.org/project/ytstudio-cli/) +[![Python](https://img.shields.io/pypi/pyversions/ytstudio-cli)](https://pypi.org/project/ytstudio-cli/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +## What it is + +A small CLI on top of the official +[YouTube Data API](https://developers.google.com/youtube/v3) and +[YouTube Analytics API](https://developers.google.com/youtube/analytics) that +covers what YouTube Studio cannot, or only at painful speed: bulk video +updates, scripted analytics queries, comment moderation, live broadcast +control, and multiple channels from one install. + +## Why it exists + +YouTube Studio does not let you bulk-rename 300 videos. ytstudio does. It +also gives you a scriptable surface for the rest of the channel: analytics +you can pipe through `jq`, comment moderation you can run in CI, live +broadcasts you can schedule from a one-liner. + +## Highlights + +- :material-account-multiple: **Multiple channels** in a single install, with + per-profile credential storage. See [Multi-channel profiles](profiles.md). +- :material-video-outline: **Bulk video updates** with search-and-replace on + titles, descriptions, and tags. See [Bulk video updates](videos.md). +- :material-chart-line: **Scripted analytics** with table or JSON output. + See [Analytics](analytics.md). +- :material-broadcast: **Live broadcast control** for the full lifecycle: + schedule, start (testing or live), update, stop, with stream-key handling. + See [Live broadcasts](livestreams.md). +- :material-shield-lock-outline: **OAuth credentials live owner-only + (`0600`)** under `~/.config/ytstudio-cli/`. The `init` step is one-shot. + +## Quick start + +```bash +uv tool install ytstudio-cli +ytstudio init --client-secrets path/to/client_secret.json +ytstudio login +ytstudio videos list +``` + +The CLI is installed as `ytstudio`; `yts` is a short alias for the same +entry point. + +Continue with [Installation](installation.md) for the full setup or the +[Command reference](reference.md) for every flag. + +## Disclaimer + +This project is not affiliated with or endorsed by Google. YouTube and +YouTube Studio are trademarks of Google. All channel data is accessed +exclusively through the official YouTube Data and Analytics APIs. diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..3f20378 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,44 @@ +# Installation + +ytstudio is published on [PyPI](https://pypi.org/project/ytstudio-cli/) and +runs on Python 3.12 and 3.13. + +## With uv (recommended) + +[uv](https://uv.io/) installs Python CLIs in isolated environments and keeps +them upgradable. + +```bash +uv tool install ytstudio-cli +``` + +To upgrade later: + +```bash +uv tool upgrade ytstudio-cli +``` + +## With pipx + +```bash +pipx install ytstudio-cli +pipx upgrade ytstudio-cli +``` + +## With pip + +```bash +pip install --user ytstudio-cli +``` + +## Verify + +Both names share the same entry point: + +```bash +ytstudio --version +yts --version +``` + +If `--version` prints a version number, you are ready for +[OAuth setup](setup.md). diff --git a/docs/livestreams.md b/docs/livestreams.md new file mode 100644 index 0000000..f60405b --- /dev/null +++ b/docs/livestreams.md @@ -0,0 +1,96 @@ +# Live broadcasts + +`ytstudio livestreams` drives YouTube's `liveBroadcasts` API: schedule +broadcasts, transition them through testing/live/complete, update settings, +and surface the bound stream's RTMP ingest URLs. The actual video push to +YouTube still happens through your encoder (OBS, Streamlabs, etc.). + +## Lifecycle + +```mermaid +flowchart LR + A[schedule] --> B[ready] + B --> C[testing] + B --> D[live] + C --> D + D --> E[complete] +``` + +## Listing and showing + +```bash +ytstudio livestreams list --status upcoming # only what is scheduled +ytstudio livestreams list --status all -n 50 # everything, capped at 50 +ytstudio livestreams list -o json # machine-readable +ytstudio livestreams show # details +ytstudio livestreams show --ingest # also the bound stream's RTMP URLs +ytstudio livestreams show --show-key # reveal the stream key +``` + +!!! warning "Stream keys are credentials" + + `--ingest` is safe to pipe into a script: the stream key is redacted to + its last four characters. Only pass `--show-key` when you need the + actual key for your encoder, and treat the printed line as a secret. + +## Scheduling + +`schedule` is **dry-run by default**: it prints the body it would send and +exits. Pass `--execute` to actually create the broadcast. + +```bash +ytstudio livestreams schedule \ + --title "Office hours" \ + --scheduled-start 2026-06-01T19:00:00+02:00 \ + --scheduled-end 2026-06-01T20:00:00+02:00 \ + --privacy unlisted \ + --not-made-for-kids +# review the preview, then: +ytstudio livestreams schedule \ + --title "Office hours" \ + --scheduled-start 2026-06-01T19:00:00+02:00 \ + --privacy unlisted --execute +``` + +!!! note "Quota" + + `liveBroadcasts.insert` and `liveBroadcasts.update` cost roughly 50 + quota units each. See [API quota](api-quota.md) for context. + +## Going live + +The `start` command has a `--to` flag that picks the target state. + +```bash +ytstudio livestreams start --to testing # monitor stream (preview) +ytstudio livestreams start # default: --to live +ytstudio livestreams stop # transition to complete +``` + +Transitions are asynchronous; the CLI prints the current status and a hint +that it may take a moment to settle. + +## Updating + +`update` accepts the same field flags as schedule plus the `contentDetails` +toggles. It is dry-run by default and only sends the parts that actually +changed, while preserving `contentDetails.monitorStream` (YouTube rejects +content-detail updates that omit it). + +```bash +ytstudio livestreams update --privacy unlisted --no-dvr +ytstudio livestreams update --latency low --auto-start --execute +``` + +`update` does not expose the made-for-kids flag, because +`liveBroadcasts.update` only accepts `privacyStatus` under `status`. Set the +COPPA flag at schedule time. + +## Demo mode + +For a quick offline tour without touching the API: + +```bash +YTSTUDIO_DEMO=1 ytstudio livestreams list --status all +YTSTUDIO_DEMO=1 ytstudio livestreams show demo-broadcast-upcoming-1 --ingest +``` diff --git a/docs/profiles.md b/docs/profiles.md new file mode 100644 index 0000000..b23a23d --- /dev/null +++ b/docs/profiles.md @@ -0,0 +1,54 @@ +# Multi-channel profiles + +ytstudio stores each YouTube login under its own named profile, so one +install can drive multiple channels. Every command operates on the +**active** profile unless you override it for a single invocation. + +## Commands + +```bash +ytstudio profile add work # authenticate a new channel and make it active +ytstudio profile add personal # add another channel +ytstudio profile list # show profiles; the active one is marked with * +ytstudio profile use work # switch the active profile +ytstudio profile status work # auth status for one profile (defaults to active) +ytstudio profile remove personal +``` + +## Per-command override + +Useful in scripts: switch channel for one command without changing global +state. + +```bash +YTSTUDIO_PROFILE=work ytstudio videos list +``` + +## Storage + +``` +~/.config/ytstudio-cli/ +├── client_secrets.json # shared OAuth client, written by init +├── state.json # active profile pointer (atomic writes) +└── profiles/ + ├── default/ + │ ├── credentials.json # 0600 + │ └── meta.json + └── work/ + ├── credentials.json + └── meta.json +``` + +Profile directories are `0700`; credential files are `0600`. State writes go +through an atomic temp + rename and are serialized under an `fcntl` lock, so +concurrent CLI invocations cannot leave the file partially written. + +## Migration from older installs + +If you upgraded from a single-channel install, the existing +`~/.config/ytstudio-cli/credentials.json` is moved into the `default` +profile on the next CLI invocation. The migration is one-shot, locked +against concurrent first-run commands, and a no-op once profiles exist. + +A plain `ytstudio login` (no `profile add`) authenticates the active +profile, which is `default` on a fresh setup. diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..a1bc390 --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,72 @@ +# OAuth setup + +ytstudio talks to YouTube through the official APIs, which means you bring +your own OAuth client. The flow is one-shot. + +## 1. Create a Google Cloud project + +1. Open the + [Google Cloud Console](https://console.cloud.google.com/) and create a + project. +2. Under **APIs & Services** :material-arrow-right: **Library**, enable both + **YouTube Data API v3** and **YouTube Analytics API**. + +## 2. Configure the OAuth consent screen + +1. Go to **APIs & Services** :material-arrow-right: **OAuth consent screen**. +2. Pick **External** and create the application. +3. Fill in app name and your contact email. +4. Skip scopes for now. +5. Add yourself as a test user. +6. Leave the app in **Testing** mode; you do not need full verification for + personal use. + +## 3. Create OAuth client credentials + +1. Go to **APIs & Services** :material-arrow-right: **Credentials**. +2. **Create credentials** :material-arrow-right: **OAuth client ID**. +3. Pick **Desktop app** as the application type. +4. Download the JSON file. + +## 4. Initialize ytstudio + +=== "From a file" + + ```bash + ytstudio init --client-secrets path/to/client_secret_.json + ytstudio login + ``` + +=== "Interactive" + + ```bash + ytstudio init + # prompts for client_id and client_secret + ytstudio login + ``` + +Credentials and the shared client secrets land under +`~/.config/ytstudio-cli/`. Files are stored owner-only (`0600`) and +directories owner-only (`0700`). + +## Headless login + +If you are setting things up on a server without a browser: + +```bash +ytstudio login --headless +``` + +The command prints a Google OAuth URL. Open it in any browser, approve +access, and copy the failed `127.0.0.1` redirect URL back into the terminal. +ytstudio finishes the exchange locally. + +## Status and logout + +```bash +ytstudio status # show the authenticated channel for the active profile +ytstudio logout # clear credentials for the active profile +``` + +If you run more than one channel, head to +[Multi-channel profiles](profiles.md) next. diff --git a/docs/videos.md b/docs/videos.md new file mode 100644 index 0000000..45e717b --- /dev/null +++ b/docs/videos.md @@ -0,0 +1,63 @@ +# Bulk video updates + +The whole reason ytstudio exists. Use this when you need to change titles, +descriptions, or tags across many videos and YouTube Studio makes you +click each one. + +## List and inspect + +```bash +ytstudio videos list # most recent uploads +ytstudio videos list -n 100 -o json # 100 items, JSON +ytstudio videos show # full metadata for one video +``` + +## Update one video + +```bash +ytstudio videos update --title "New title" +ytstudio videos update --description "New description" +ytstudio videos update --tags one,two,three +``` + +`update` targets a single video; pass any combination of `--title`, +`--description`, or `--tags`. + +## Search-and-replace across the channel + +`videos search-replace` walks every video on the active channel, applies +the substitution, and shows what would change. It is **dry-run by +default**; pass `--execute` to actually update videos. + +```bash +ytstudio videos search-replace --search "2024" --replace "2025" --field title +ytstudio videos search-replace \ + --search "old@email.com" --replace "new@email.com" --field description \ + --execute +ytstudio videos search-replace --search 'season \d' --replace 'season X' \ + --field title --regex +``` + +Flags: + +- `--search`, `--replace` (required): the substitution. +- `--field` (required): `title` or `description`. +- `--regex`: treat `--search` as a Python regex. +- `--limit`: cap how many matches to act on (default 10). +- `--execute`: actually apply the changes. + +!!! warning "Quota cost" + + `videos.update` costs about 50 quota units per video. A bulk run that + touches 200 videos eats the default daily quota (10 000 units). See + [API quota](api-quota.md) before kicking off large jobs. + +## Demo mode + +Run any video command without authenticating by setting `YTSTUDIO_DEMO=1`: + +```bash +YTSTUDIO_DEMO=1 ytstudio videos list +``` + +Demo mode uses bundled fixtures and never reaches the real API. diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..2daaba6 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,100 @@ +site_name: ytstudio +site_description: Manage and analyze your YouTube channel from the terminal. +site_url: https://jdwit.github.io/ytstudio-cli/ +site_author: Jelmer de Wit +repo_name: jdwit/ytstudio-cli +repo_url: https://github.com/jdwit/ytstudio-cli +edit_uri: edit/main/docs/ +copyright: Copyright © Jelmer de Wit + +theme: + name: material + language: en + icon: + repo: fontawesome/brands/github + features: + - navigation.tabs + - navigation.tabs.sticky + - navigation.sections + - navigation.top + - navigation.instant + - navigation.instant.progress + - navigation.indexes + - navigation.tracking + - search.suggest + - search.highlight + - content.code.copy + - content.code.annotate + - content.tabs.link + - toc.follow + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: indigo + accent: indigo + toggle: + icon: material/weather-sunny + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: indigo + accent: indigo + toggle: + icon: material/weather-night + name: Switch to light mode + +plugins: + - search + +markdown_extensions: + - abbr + - admonition + - attr_list + - def_list + - footnotes + - md_in_html + - tables + - toc: + permalink: true + - pymdownx.details + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true + - pymdownx.tasklist: + custom_checkbox: true + - pymdownx.keys + - pymdownx.smartsymbols + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + +hooks: + - scripts/build_docs_reference.py + +nav: + - Home: index.md + - Getting started: + - Installation: installation.md + - OAuth setup: setup.md + - Guides: + - Multi-channel profiles: profiles.md + - Live broadcasts: livestreams.md + - Bulk video updates: videos.md + - Analytics: analytics.md + - Comments: comments.md + - Reference: + - Commands: reference.md + - API quota: api-quota.md + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/jdwit + - icon: fontawesome/brands/python + link: https://pypi.org/project/ytstudio-cli/ diff --git a/pyproject.toml b/pyproject.toml index a6a79bf..d52feed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,12 @@ dev = [ "ruff>=0.15.0", "pre-commit>=3.0.0", ] +docs = [ + "mkdocs>=1.6", + "mkdocs-material>=9.5", + "pymdown-extensions>=10.7", + "typer-cli>=0.0.13", +] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/scripts/build_docs_reference.py b/scripts/build_docs_reference.py new file mode 100644 index 0000000..071456c --- /dev/null +++ b/scripts/build_docs_reference.py @@ -0,0 +1,51 @@ +"""mkdocs hook: regenerate docs/reference.md from the live typer app. + +Runs on every build so the command reference cannot drift from --help. We do +not check the generated file into git; instead it is produced under the +configured docs directory just before mkdocs walks the site. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + + +def on_pre_build(config) -> None: + docs_dir = Path(config["docs_dir"]) + reference = docs_dir / "reference.md" + + # typer ships its own CLI; using `python -m typer` avoids depending on a + # console-script being on PATH inside the docs build environment. + cmd = [ + sys.executable, + "-m", + "typer", + "ytstudio.main", + "utils", + "docs", + "--name", + "ytstudio", + "--output", + str(reference), + ] + result = subprocess.run(cmd, capture_output=True, text=True, check=False) + if result.returncode != 0: + # Fall back to a placeholder rather than failing the whole build; the + # CI log shows the captured stderr so we can fix the root cause. + sys.stderr.write("[build_reference] typer docs generation failed:\n") + sys.stderr.write(result.stderr) + reference.write_text( + "# Command reference\n\n" + "The auto-generated command reference could not be built. " + "Run `python -m typer ytstudio.main utils docs --name ytstudio` " + "locally to inspect the failure.\n" + ) + return + + # Lift the top-level heading so it matches the nav label. + text = reference.read_text() + if text.lstrip().startswith("# `ytstudio`"): + text = text.replace("# `ytstudio`", "# Command reference", 1) + reference.write_text(text) diff --git a/uv.lock b/uv.lock index e316684..8276415 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,15 @@ resolution-markers = [ "python_full_version < '3.13'", ] +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -15,6 +24,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "backrefs" +version = "7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/a7dd63622beef68cc0d3c3c36d472e143dd95443d5ebf14cd1a5b4dfbf11/backrefs-7.0.tar.gz", hash = "sha256:4989bb9e1e99eb23647c7160ed51fb21d0b41b5d200f2d3017da41e023097e82", size = 7012453, upload-time = "2026-04-28T16:28:04.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/39/39a31d7eae729ea14ed10c3ccef79371197177b9355a86cb3525709e8502/backrefs-7.0-py310-none-any.whl", hash = "sha256:b57cd227ea556b0aed3dc9b8da4628db4eabc0402c6d7fcfc69283a93955f7e9", size = 380824, upload-time = "2026-04-28T16:27:55.647Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b5/9302644225ba7dfa934a2ff2b9c7bb85701313a90dddb3dfaf693fa5bae2/backrefs-7.0-py311-none-any.whl", hash = "sha256:a0fa7360c63509e9e077e174ef4e6d3c21c8db94189b9d957289ae6d794b9475", size = 392626, upload-time = "2026-04-28T16:27:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/36/da/87912ddec6e06feffbaa3d7aa18fc6352bee2e8f1fee185d7d1690f8f4e8/backrefs-7.0-py312-none-any.whl", hash = "sha256:ca42ce6a49ace3d75684dfa9937f3373902a63284ecb385ce36d15e5dcb41c12", size = 398537, upload-time = "2026-04-28T16:27:58.913Z" }, + { url = "https://files.pythonhosted.org/packages/00/bb/90ba423612b6aa0adccc6b1874bcd4a9b44b660c0c16f346611e00f64ac3/backrefs-7.0-py313-none-any.whl", hash = "sha256:f2c52955d631b9e1ac4cd56209f0a3a946d592b98e7790e77699339ae01c102a", size = 400491, upload-time = "2026-04-28T16:28:00.928Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5c/fb93d3092640a24dfb7bd7727a24016d7c01774ca013e60efd3f683c8002/backrefs-7.0-py314-none-any.whl", hash = "sha256:a6448b28180e3ca01134c9cf09dcebafad8531072e09903c5451748a05f24bc9", size = 412349, upload-time = "2026-04-28T16:28:02.412Z" }, +] + [[package]] name = "certifi" version = "2026.1.4" @@ -313,6 +344,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, ] +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + [[package]] name = "google-api-core" version = "2.29.0" @@ -436,6 +479,27 @@ 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 = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -448,6 +512,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -457,6 +584,84 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/29/6d2bcf41ae40802c4beda2432396fff97b8456fb496371d1bc7aad6512ec/mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69", size = 4097959, upload-time = "2026-03-19T15:41:58.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba", size = 9305470, upload-time = "2026-03-19T15:41:55.217Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + [[package]] name = "nodeenv" version = "1.10.0" @@ -484,6 +689,24 @@ 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 = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + [[package]] name = "platformdirs" version = "4.5.1" @@ -674,6 +897,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pymdown-extensions" +version = "10.21.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/26/d1015444da4d952a1ca487a236b522eb979766f0295a0bd0c5fc089989a9/pymdown_extensions-10.21.3.tar.gz", hash = "sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354", size = 854140, upload-time = "2026-05-13T12:57:32.267Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl", hash = "sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6", size = 269002, upload-time = "2026-05-13T12:57:30.296Z" }, +] + [[package]] name = "pyparsing" version = "3.3.2" @@ -725,6 +961,18 @@ 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-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -771,6 +1019,18 @@ wheels = [ { 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 = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + [[package]] name = "requests" version = "2.32.5" @@ -867,19 +1127,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "typer" -version = "0.21.1" +version = "0.26.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "rich" }, { name = "shellingham" }, - { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/36/bf/8825b5929afd84d0dabd606c67cd57b8388cb3ec385f7ef19c5cc2202069/typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d", size = 110371, upload-time = "2026-01-06T11:21:10.989Z" } +sdist = { url = "https://files.pythonhosted.org/packages/08/26/8e9a4f2c98caefcf4ac25788d48939516a9dd4265fcf9bdd578a2a1b55dd/typer-0.26.1.tar.gz", hash = "sha256:537d27ae686d82967f6383382a952cb32ba4768898541effccb69ca75bbd5d23", size = 198884, upload-time = "2026-05-26T17:49:07.912Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/27/8a22d4833fe8aa0836ce7fa59096ad50d7e93b83be6d5383f11f9a140d54/typer-0.26.1-py3-none-any.whl", hash = "sha256:933e4f0083521f3c57d6a5aedf3b073271b2f95a19761b171b494dd6fdb21ff6", size = 123097, upload-time = "2026-05-26T17:49:09.065Z" }, +] + +[[package]] +name = "typer-cli" +version = "0.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/e5/4debd04edf65dbd72a099a1fbcd98f246da6bd0b26993ff02f68fa7d6eec/typer_cli-0.24.0.tar.gz", hash = "sha256:ce902c5ae858d9a8df684c950cbb2e351ae6d686f397fee8c8e77cf5d99041a1", size = 4616, upload-time = "2026-02-16T22:08:49.072Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ed/db873e6fe6154f5de6dc6096870b4aca2ad7f8e8f34e0b4e4e18d44f354c/typer_cli-0.24.0-py3-none-any.whl", hash = "sha256:e3bd84cf7c03fe92c5c53e68233abeaa81fb4d3dd9eff49bf2b19b788e85302d", size = 3220, upload-time = "2026-02-16T22:08:47.882Z" }, ] [[package]] @@ -935,6 +1216,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, ] +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + [[package]] name = "ytstudio-cli" version = "0.2.0" @@ -958,6 +1263,12 @@ dev = [ { name = "pytest-mock" }, { name = "ruff" }, ] +docs = [ + { name = "mkdocs" }, + { name = "mkdocs-material" }, + { name = "pymdown-extensions" }, + { name = "typer-cli" }, +] [package.metadata] requires-dist = [ @@ -979,3 +1290,9 @@ dev = [ { name = "pytest-mock", specifier = ">=3.12.0" }, { name = "ruff", specifier = ">=0.15.0" }, ] +docs = [ + { name = "mkdocs", specifier = ">=1.6" }, + { name = "mkdocs-material", specifier = ">=9.5" }, + { name = "pymdown-extensions", specifier = ">=10.7" }, + { name = "typer-cli", specifier = ">=0.0.13" }, +] From de222e8e7cbe0dd1b290fd00a8cfaf3e7d7be2eb Mon Sep 17 00:00:00 2001 From: Jelmer de Wit <1598297+jdwit@users.noreply.github.com> Date: Tue, 26 May 2026 23:07:10 +0200 Subject: [PATCH 2/8] fix: drop typer-cli; use typer.Exit in test_api Newer typer (0.26+) no longer pulls click as a runtime dep, which broke tests/test_api.py's 'from click.exceptions import Exit' when the docs group pulled in a typer-cli that bumped typer past the click cutoff. - Switch the test to 'from typer import Exit'; this is what every other caller uses, and typer re-exports Exit for exactly this purpose. - Drop typer-cli from the docs group: 'python -m typer ytstudio.main utils docs' works out of the box on modern typer, no extra package needed. --- pyproject.toml | 1 - tests/test_api.py | 2 +- uv.lock | 14 -------------- 3 files changed, 1 insertion(+), 16 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d52feed..36aa5ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,6 @@ docs = [ "mkdocs>=1.6", "mkdocs-material>=9.5", "pymdown-extensions>=10.7", - "typer-cli>=0.0.13", ] [tool.pytest.ini_options] diff --git a/tests/test_api.py b/tests/test_api.py index b3de5bc..076a444 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,8 +1,8 @@ from unittest.mock import MagicMock, patch import pytest -from click.exceptions import Exit from googleapiclient.errors import HttpError +from typer import Exit from typer.testing import CliRunner from ytstudio import api as api_module diff --git a/uv.lock b/uv.lock index 8276415..ee11872 100644 --- a/uv.lock +++ b/uv.lock @@ -1151,18 +1151,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/27/8a22d4833fe8aa0836ce7fa59096ad50d7e93b83be6d5383f11f9a140d54/typer-0.26.1-py3-none-any.whl", hash = "sha256:933e4f0083521f3c57d6a5aedf3b073271b2f95a19761b171b494dd6fdb21ff6", size = 123097, upload-time = "2026-05-26T17:49:09.065Z" }, ] -[[package]] -name = "typer-cli" -version = "0.24.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typer" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d3/e5/4debd04edf65dbd72a099a1fbcd98f246da6bd0b26993ff02f68fa7d6eec/typer_cli-0.24.0.tar.gz", hash = "sha256:ce902c5ae858d9a8df684c950cbb2e351ae6d686f397fee8c8e77cf5d99041a1", size = 4616, upload-time = "2026-02-16T22:08:49.072Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/ed/db873e6fe6154f5de6dc6096870b4aca2ad7f8e8f34e0b4e4e18d44f354c/typer_cli-0.24.0-py3-none-any.whl", hash = "sha256:e3bd84cf7c03fe92c5c53e68233abeaa81fb4d3dd9eff49bf2b19b788e85302d", size = 3220, upload-time = "2026-02-16T22:08:47.882Z" }, -] - [[package]] name = "typing-extensions" version = "4.15.0" @@ -1267,7 +1255,6 @@ docs = [ { name = "mkdocs" }, { name = "mkdocs-material" }, { name = "pymdown-extensions" }, - { name = "typer-cli" }, ] [package.metadata] @@ -1294,5 +1281,4 @@ docs = [ { name = "mkdocs", specifier = ">=1.6" }, { name = "mkdocs-material", specifier = ">=9.5" }, { name = "pymdown-extensions", specifier = ">=10.7" }, - { name = "typer-cli", specifier = ">=0.0.13" }, ] From 76aedcf15374046beca9afc559a8510a6ac351fc Mon Sep 17 00:00:00 2001 From: Jelmer de Wit <1598297+jdwit@users.noreply.github.com> Date: Thu, 28 May 2026 17:25:46 +0200 Subject: [PATCH 3/8] docs: align mkdocs site with current CLI surface and YouTube branding - Cover videos upload (sidecars, dry-run, --max, resume semantics, quota) on the videos page and surface it in the home highlights and nav label. - Document videos list --scheduled filter. - Replace nonexistent commands in docs: drop ytstudio logout, swap analytics top/by-country/by-day for the real overview/video/query/metrics/dimensions, use comments --status/--sort instead of --moderation-status/--order. - Add videos.insert and thumbnails.set to the API quota cheatsheet. - YouTube-inspired Material palette: black/white primary, red accent, plus an extra.css that pins #FF0033 / #212121 / #FFFFFF on both schemes. - Drop pymdownx.smartsymbols so -- and --- stay as plain hyphens. - check-yaml: pass --unsafe so mkdocs.yml python tags don't trip pre-commit. --- .pre-commit-config.yaml | 1 + docs/analytics.md | 52 +++++++++++++++++++------ docs/api-quota.md | 6 +++ docs/comments.md | 8 ++-- docs/index.md | 5 +++ docs/setup.md | 7 +++- docs/stylesheets/extra.css | 40 +++++++++++++++++++ docs/videos.md | 79 +++++++++++++++++++++++++++++++++++++- mkdocs.yml | 24 ++++++------ 9 files changed, 191 insertions(+), 31 deletions(-) create mode 100644 docs/stylesheets/extra.css diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9a1174c..6db8904 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,6 +5,7 @@ repos: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml + args: ["--unsafe"] - id: check-added-large-files - id: check-merge-conflict diff --git a/docs/analytics.md b/docs/analytics.md index a21a862..a645a78 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -1,17 +1,44 @@ # Analytics -`ytstudio analytics` queries the YouTube Analytics API: totals, daily -breakdowns, per-video and per-country views, watch time, and the rest. -Output defaults to a Rich table; pass `-o json` for scripting. +`ytstudio analytics` queries the YouTube Analytics API: channel-wide totals, +per-video performance, and ad-hoc queries against the full metric and +dimension catalogue. Output defaults to a Rich table; pass `-o json` for +scripting. ## Common queries ```bash -ytstudio analytics overview # channel summary for the last period -ytstudio analytics top --by views # top videos by views -ytstudio analytics video # one video's recent performance -ytstudio analytics by-country --metric views -ytstudio analytics by-day --metric views # last 7 days +ytstudio analytics overview # channel summary for the last 28 days +ytstudio analytics overview --days 7 # rolling 7-day window +ytstudio analytics video # one video's recent performance +ytstudio analytics video --days 90 # longer window for one video +``` + +## Custom queries + +`analytics query` is a thin wrapper over the YouTube Analytics +`reports.query` endpoint, so anything you can express against that API is +reachable from the CLI. + +```bash +ytstudio analytics query \ + -m views,estimatedMinutesWatched,averageViewDuration \ + -d day \ + --start 2026-04-01 --end 2026-04-30 + +ytstudio analytics query \ + -m views -d country \ + --sort -views -n 10 +``` + +Sort fields take a `-` prefix for descending. Use `-f key==value` (repeatable) +to filter, for example `-f video==dMH0bHeiRNg`. + +Discover what is available: + +```bash +ytstudio analytics metrics # list every metric the API exposes +ytstudio analytics dimensions # list every dimension the API exposes ``` Run `ytstudio analytics --help` (or open the @@ -22,16 +49,17 @@ Run `ytstudio analytics --help` (or open the === "Table" ```bash - ytstudio analytics top --by views + ytstudio analytics overview ``` === "JSON" ```bash - ytstudio analytics top --by views -o json | jq '.rows[0]' + ytstudio analytics overview -o json | jq '.rows[0]' ``` ## Quota -Analytics reads are cheap (1 unit per call); you can hit them often without -worrying. See [API quota](api-quota.md) for the full picture. +Analytics reads are cheap (1 unit per call against the Data API quota); you +can hit them often without worrying. See [API quota](api-quota.md) for the +full picture. diff --git a/docs/api-quota.md b/docs/api-quota.md index a1334aa..654a9a6 100644 --- a/docs/api-quota.md +++ b/docs/api-quota.md @@ -27,6 +27,12 @@ not eat into the table below. | `liveBroadcasts.insert` | 50 units | | `liveBroadcasts.update` | 50 units | | `liveBroadcasts.transition` | 50 units | +| `videos.insert` (upload) | ~1600 units | +| `thumbnails.set` | 50 units | + +At ~1600 units per upload, the default 10 000-unit daily budget covers about +six new videos per day. Use `ytstudio videos upload --max N` to cap a run +and avoid burning the budget you need for the rest of the day. ## Increasing the quota diff --git a/docs/comments.md b/docs/comments.md index 9212d9a..6facf09 100644 --- a/docs/comments.md +++ b/docs/comments.md @@ -12,12 +12,12 @@ ytstudio comments list --video ytstudio comments list --video -o json | jq # Held queue, ordered chronologically -ytstudio comments list --video --moderation-status held --order time +ytstudio comments list --video --status held --sort time ``` -`--moderation-status` accepts `published`, `held`, or `spam` (mapped -internally to YouTube's `published`, `heldForReview`, and `likelySpam` -values). +`--status` accepts `published`, `held`, or `spam` (mapped internally to +YouTube's `published`, `heldForReview`, and `likelySpam` values). `--sort` +accepts `time` or `relevance`. Omit `--video` to list across the channel. ## Approve held comments diff --git a/docs/index.md b/docs/index.md index a2cede1..5f5cfa9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -30,11 +30,16 @@ broadcasts you can schedule from a one-liner. per-profile credential storage. See [Multi-channel profiles](profiles.md). - :material-video-outline: **Bulk video updates** with search-and-replace on titles, descriptions, and tags. See [Bulk video updates](videos.md). +- :material-cloud-upload-outline: **Batch upload** from a directory of YAML + sidecars with resumable, quota-aware behaviour. See + [Upload a batch from sidecars](videos.md#upload-a-batch-from-sidecars). - :material-chart-line: **Scripted analytics** with table or JSON output. See [Analytics](analytics.md). - :material-broadcast: **Live broadcast control** for the full lifecycle: schedule, start (testing or live), update, stop, with stream-key handling. See [Live broadcasts](livestreams.md). +- :material-comment-check-outline: **Comment moderation** at the CLI: + approve, reject, or ban in bulk. See [Comments](comments.md). - :material-shield-lock-outline: **OAuth credentials live owner-only (`0600`)** under `~/.config/ytstudio-cli/`. The `init` step is one-shot. diff --git a/docs/setup.md b/docs/setup.md index a1bc390..9ac7a02 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -61,12 +61,15 @@ The command prints a Google OAuth URL. Open it in any browser, approve access, and copy the failed `127.0.0.1` redirect URL back into the terminal. ytstudio finishes the exchange locally. -## Status and logout +## Status ```bash ytstudio status # show the authenticated channel for the active profile -ytstudio logout # clear credentials for the active profile ``` +To remove credentials for a channel, use `ytstudio profile remove ` (see +[Multi-channel profiles](profiles.md)). On a fresh single-channel install the +active profile is `default`. + If you run more than one channel, head to [Multi-channel profiles](profiles.md) next. diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css new file mode 100644 index 0000000..0a4eaa6 --- /dev/null +++ b/docs/stylesheets/extra.css @@ -0,0 +1,40 @@ +/* YouTube-inspired palette: red accent on near-black dark / near-white light. */ + +:root { + --yt-red: #FF0033; + --yt-red-hover: #CC0029; + --yt-black: #212121; + --yt-white: #FFFFFF; +} + +/* Light scheme: white surface, near-black text, YouTube red accents. */ +[data-md-color-scheme="default"] { + --md-primary-fg-color: var(--yt-red); + --md-primary-fg-color--light: var(--yt-red); + --md-primary-fg-color--dark: var(--yt-red-hover); + --md-primary-bg-color: var(--yt-white); + --md-primary-bg-color--light: var(--yt-white); + --md-accent-fg-color: var(--yt-red); + --md-accent-fg-color--transparent: rgba(255, 0, 51, 0.1); + --md-typeset-a-color: var(--yt-red); +} + +/* Dark scheme: near-black surface, near-white text, YouTube red accents. */ +[data-md-color-scheme="slate"] { + --md-hue: 0; + --md-default-bg-color: var(--yt-black); + --md-default-bg-color--light: #2A2A2A; + --md-default-bg-color--lighter: #333333; + --md-default-bg-color--lightest: #404040; + --md-default-fg-color: var(--yt-white); + --md-default-fg-color--light: rgba(255, 255, 255, 0.85); + --md-default-fg-color--lighter: rgba(255, 255, 255, 0.55); + --md-default-fg-color--lightest: rgba(255, 255, 255, 0.12); + --md-primary-fg-color: var(--yt-black); + --md-primary-bg-color: var(--yt-white); + --md-accent-fg-color: var(--yt-red); + --md-accent-fg-color--transparent: rgba(255, 0, 51, 0.15); + --md-typeset-a-color: var(--yt-red); + --md-code-bg-color: #2A2A2A; + --md-code-fg-color: var(--yt-white); +} diff --git a/docs/videos.md b/docs/videos.md index 45e717b..f6f5f16 100644 --- a/docs/videos.md +++ b/docs/videos.md @@ -2,16 +2,22 @@ The whole reason ytstudio exists. Use this when you need to change titles, descriptions, or tags across many videos and YouTube Studio makes you -click each one. +click each one. Also covers uploading whole batches of videos from a +directory of YAML sidecars. ## List and inspect ```bash ytstudio videos list # most recent uploads ytstudio videos list -n 100 -o json # 100 items, JSON +ytstudio videos list --scheduled # only videos scheduled for future publish ytstudio videos show # full metadata for one video ``` +`--scheduled` filters to private videos with a future `publishAt`, which is +the YouTube convention for "scheduled for release". Useful for sanity-checking +a queue you just uploaded. + ## Update one video ```bash @@ -52,6 +58,74 @@ Flags: touches 200 videos eats the default daily quota (10 000 units). See [API quota](api-quota.md) before kicking off large jobs. +## Upload a batch from sidecars + +`videos upload` takes a video file or a directory, pairs each `.mp4`/`.mov` +with a sibling YAML sidecar describing its metadata, validates the lot, and +then uploads them in order. It is **dry-run by default**; pass `--execute` +to actually push to YouTube. + +```bash +ytstudio videos upload ./outbox # dry-run preview +ytstudio videos upload ./outbox --execute # actually upload +ytstudio videos upload ./outbox --execute --max 3 # cap the run (quota budget) +ytstudio videos upload ./outbox/single.mp4 --execute # one specific video +``` + +### Directory layout + +``` +outbox/ + holiday.mp4 + holiday.yaml # required + holiday.jpg # optional thumbnail (.jpg or .png), max 2 MB + travel-vlog.mp4 + travel-vlog.yaml +``` + +The sidecar must share the video's basename. A thumbnail with the same +basename is picked up automatically; near-misses (different extension, wrong +case) fail fast with a clear error rather than silently uploading without a +thumbnail. + +### Sidecar (`holiday.yaml`) + +```yaml +title: Holiday recap 2026 +description: | + Multi-line description. +privacy: private # private | unlisted | public +publish_at: 2026-06-01T10:00:00+02:00 # optional; forces privacy=private +tags: [travel, vlog] +category_id: "22" # YouTube category id; default 22 (People & Blogs) +default_language: nl +default_audio_language: nl +made_for_kids: false +``` + +`publish_at` must be timezone-aware and in the future. If you set it, the +upload pipeline forces `privacy=private` because that is the only privacy +state YouTube accepts for scheduled releases. + +### Resumability + +After each successful upload the sidecar is patched with the resulting +`video_id` and an `uploaded_at` timestamp. Re-running `videos upload` on the +same directory only retries sidecars without a `video_id`, so partial runs +(quota exhaustion, network drop) are safe to resume. + +If the write-back itself fails, the command prints the `video_id` to stdout +before exiting so you can paste it into the sidecar manually and avoid a +duplicate upload on the next run. + +### Quota and rate limits + +`videos.insert` costs roughly 1600 quota units, so on the default 10k/day +budget you can upload ~6 videos per day. Use `--max` to cap a run +explicitly. If the API returns `quotaExceeded` mid-run, the pipeline stops +cleanly, prints how many videos succeeded, and exits non-zero. See +[API quota](api-quota.md). + ## Demo mode Run any video command without authenticating by setting `YTSTUDIO_DEMO=1`: @@ -60,4 +134,5 @@ Run any video command without authenticating by setting `YTSTUDIO_DEMO=1`: YTSTUDIO_DEMO=1 ytstudio videos list ``` -Demo mode uses bundled fixtures and never reaches the real API. +Demo mode uses bundled fixtures and never reaches the real API. The upload +command is the one exception: it always talks to YouTube. diff --git a/mkdocs.yml b/mkdocs.yml index 2daaba6..215dca1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -28,20 +28,23 @@ theme: - content.tabs.link - toc.follow palette: - - media: "(prefers-color-scheme: light)" - scheme: default - primary: indigo - accent: indigo - toggle: - icon: material/weather-sunny - name: Switch to dark mode - media: "(prefers-color-scheme: dark)" scheme: slate - primary: indigo - accent: indigo + primary: black + accent: red toggle: icon: material/weather-night name: Switch to light mode + - media: "(prefers-color-scheme: light)" + scheme: default + primary: white + accent: red + toggle: + icon: material/weather-sunny + name: Switch to dark mode + +extra_css: + - stylesheets/extra.css plugins: - search @@ -69,7 +72,6 @@ markdown_extensions: - pymdownx.tasklist: custom_checkbox: true - pymdownx.keys - - pymdownx.smartsymbols - pymdownx.emoji: emoji_index: !!python/name:material.extensions.emoji.twemoji emoji_generator: !!python/name:material.extensions.emoji.to_svg @@ -85,7 +87,7 @@ nav: - Guides: - Multi-channel profiles: profiles.md - Live broadcasts: livestreams.md - - Bulk video updates: videos.md + - Videos (bulk update, upload): videos.md - Analytics: analytics.md - Comments: comments.md - Reference: From 83909d42a358502930865653b3d47326ef8c94a8 Mon Sep 17 00:00:00 2001 From: Jelmer de Wit <1598297+jdwit@users.noreply.github.com> Date: Thu, 28 May 2026 17:34:03 +0200 Subject: [PATCH 4/8] docs: review fixes for PR #14 - index.md: drop misleading "tags" from the search-replace highlight; the videos search-replace --field flag only accepts title or description, the per-video update command is what handles tags. - comments.md: document that --sort relevance requires --video (YouTube API only honours relevance ordering on a single video). - docs.yml: include src/ytstudio/** in the PR paths filter so CLI surface changes also trigger the strict docs build (the build hook regenerates the command reference from the live typer app). --- .github/workflows/docs.yml | 1 + docs/comments.md | 4 +++- docs/index.md | 3 ++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index d9116ff..4e26beb 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -9,6 +9,7 @@ on: - docs/** - mkdocs.yml - scripts/build_docs_reference.py + - src/ytstudio/** # CLI surface feeds the auto-generated reference - pyproject.toml - uv.lock - .github/workflows/docs.yml diff --git a/docs/comments.md b/docs/comments.md index 6facf09..185d933 100644 --- a/docs/comments.md +++ b/docs/comments.md @@ -17,7 +17,9 @@ ytstudio comments list --video --status held --sort time `--status` accepts `published`, `held`, or `spam` (mapped internally to YouTube's `published`, `heldForReview`, and `likelySpam` values). `--sort` -accepts `time` or `relevance`. Omit `--video` to list across the channel. +accepts `time` or `relevance`; the YouTube API only honours `relevance` for +a single video, so `--sort relevance` requires `--video `. Omit +`--video` to list across the channel (time-sorted only). ## Approve held comments diff --git a/docs/index.md b/docs/index.md index 5f5cfa9..3f78e61 100644 --- a/docs/index.md +++ b/docs/index.md @@ -29,7 +29,8 @@ broadcasts you can schedule from a one-liner. - :material-account-multiple: **Multiple channels** in a single install, with per-profile credential storage. See [Multi-channel profiles](profiles.md). - :material-video-outline: **Bulk video updates** with search-and-replace on - titles, descriptions, and tags. See [Bulk video updates](videos.md). + titles or descriptions, plus per-video title/description/tag edits. See + [Bulk video updates](videos.md). - :material-cloud-upload-outline: **Batch upload** from a directory of YAML sidecars with resumable, quota-aware behaviour. See [Upload a batch from sidecars](videos.md#upload-a-batch-from-sidecars). From ad778a25612cc92d5f6fe32b88d124820ac18d2f Mon Sep 17 00:00:00 2001 From: Jelmer de Wit <1598297+jdwit@users.noreply.github.com> Date: Thu, 28 May 2026 18:09:14 +0200 Subject: [PATCH 5/8] docs: trim readme, restyle site, rework landing copy - center readme top section (ascii.png header, badges, tagline) and remove sections now covered in mkdocs site; add features list and link to docs - site_name -> ytstudio-cli to match package/repo - force youtube-red header/tabs in both schemes (slate's more-specific rule was overriding the var-based default); footer fixed to yt-black - rephrase "what it is" and "why it exists" on landing page; drop multi-channel mention (plumbing, not a headline feature) --- README.md | 185 +++++++------------------------------ ascii.png | Bin 0 -> 18747 bytes docs/index.md | 20 ++-- docs/stylesheets/extra.css | 29 +++++- mkdocs.yml | 2 +- 5 files changed, 67 insertions(+), 169 deletions(-) create mode 100644 ascii.png diff --git a/README.md b/README.md index 0ffc569..2898f8d 100644 --- a/README.md +++ b/README.md @@ -1,171 +1,41 @@ -# YT Studio CLI +

YT Studio CLI

-[![CI](https://github.com/jdwit/ytstudio/actions/workflows/ci.yml/badge.svg)](https://github.com/jdwit/ytstudio/actions/workflows/ci.yml) -[![PyPI](https://img.shields.io/pypi/v/ytstudio-cli)](https://pypi.org/project/ytstudio-cli/) -[![Python](https://img.shields.io/pypi/pyversions/ytstudio-cli)](https://pypi.org/project/ytstudio-cli/) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +

+ ascii +

-Manage and analyze your YouTube channel from the terminal. Ideal for automation and AI workflows. +

+ CI + PyPI + Python +

-![demo](demo.gif) +

+ Manage and analyze your YouTube channel from the terminal. Ideal for automation and AI workflows. +

## Motivation I built this because I needed to bulk update video titles for a YouTube channel I manage with 300+ videos. YouTube Studio does not support bulk search-replace operations, which made it a tedious manual process. This tool uses the YouTube Data API to perform bulk operations on video metadata. -Furthermore, it provides features for analytics and comment moderation, all accesible from the +Furthermore, it provides features for analytics and comment moderation, all accessible from the command line. -## Installation +## Features -I recommend the excellent [uv](https://uv.io/) tool for installation: +- Bulk update video metadata across hundreds of videos in one pass (search-replace titles, + descriptions, tags). +- Upload videos from a directory using YAML sidecars for metadata and thumbnails. +- Schedule, start, stop, and update YouTube livestream broadcasts, including RTMP ingest details. +- Multi-channel profiles: manage several channels from one machine and switch per command. +- Comments moderation: list, reply, and moderate from the CLI. +- Channel analytics queries via the YouTube Analytics API. -```bash -uv tool install ytstudio-cli -``` - -The CLI is installed as `ytstudio`; `yts` is a short alias for the same entry point. - -Full documentation: [https://jdwit.github.io/ytstudio-cli/](https://jdwit.github.io/ytstudio-cli/). - -## Setup - -1. Create a [Google Cloud project](https://console.cloud.google.com/) -1. Enable **YouTube Data API v3** and **YouTube Analytics API** -1. Configure OAuth consent screen: - - Go to **APIs & Services** → **OAuth consent screen** - - Select **External** and create - - Fill in app name and your email - - Skip scopes, then add yourself as a test user - - Leave the app in "Testing" mode (no verification needed) -1. Create OAuth credentials: - - Go to **APIs & Services** → **Credentials** - - Click **Create Credentials** → **OAuth client ID**how - - Select **Desktop app** as application type - - Download the JSON file -1. Configure ytstudio: - -```bash -ytstudio init --client-secrets path/to/client_secret_.json -ytstudio login -``` - -Credentials stored in `~/.config/ytstudio-cli/`. - -### Headless Linux login - -If you are logging in from a server without a browser, run: - -```bash -ytstudio login --headless -``` - -The command prints a Google OAuth URL. Open that URL in a browser on any machine and approve -access. The browser will then fail to load a `127.0.0.1` page; this is expected. Copy the full URL -from the browser address bar, paste it back into the terminal, and ytstudio will finish the login. - -## Managing multiple channels - -If you run more than one YouTube channel, ytstudio stores each login under its own named profile. -Every command (`videos`, `analytics`, `comments`, ...) operates on the active channel. - -```bash -ytstudio profile add work # authenticate a new channel and make it active -ytstudio profile add personal # add another -ytstudio profile list # show profiles; the active one is marked with * -ytstudio profile use work # switch the active profile -ytstudio profile status work # auth status for one profile (defaults to active) -ytstudio profile remove personal -``` - -For scripting you can override the active channel per command without switching: - -```bash -YTSTUDIO_PROFILE=work ytstudio videos list -``` - -Credentials live in `~/.config/ytstudio-cli/profiles//` with owner-only (`600`) permissions. -The shared OAuth client secrets stay at the top level, so `ytstudio init` is only needed once. -A plain `ytstudio login` (no `profile add`) authenticates the active profile, which is `default` -on a fresh setup. Existing single-channel installs are migrated to the `default` profile -automatically on first run. - -## Live broadcasts +## Documentation -Manage your YouTube livestream broadcasts from the CLI. This drives YouTube's -`liveBroadcasts` API (scheduling, starting, stopping, updating); the actual -video push to YouTube still goes through your encoder of choice (OBS, etc.). - -```bash -ytstudio livestreams list --status upcoming # what is scheduled -ytstudio livestreams show # details for one broadcast -ytstudio livestreams show --ingest # also fetch the bound stream's RTMP URL -ytstudio livestreams show --show-key # reveal the stream key (treat as secret) -ytstudio livestreams schedule \ - --title "Title" --scheduled-start 2026-06-01T19:00:00+02:00 # dry-run -ytstudio livestreams schedule ... --execute # actually create -ytstudio livestreams start # transition to live -ytstudio livestreams start --to testing # transition to the monitor stream first -ytstudio livestreams stop # transition to complete -ytstudio livestreams update --privacy unlisted --no-dvr # dry-run by default -``` - -Notes: - -- `schedule` and `update` default to a dry-run preview; pass `--execute` to apply. -- `--show-key` on `show` reveals the bound stream key; without it the key is - redacted. Stream keys are credentials. -- `liveBroadcasts.insert`/`update` are write operations (~50 quota units each); - see [API quota](#api-quota) below. - -## Uploading videos - -Bulk-upload videos from a directory. Each video file needs a YAML sidecar with -the same basename describing its metadata. - -```bash -yts videos upload ./outbox # dry-run preview -yts videos upload ./outbox --execute # actually upload -yts videos upload ./outbox --execute --max 3 # cap to N uploads (quota budget) -``` - -Directory layout: - -``` -outbox/ - holiday.mp4 - holiday.yaml # required - holiday.jpg # optional thumbnail (or .png), max 2 MB -``` - -Sidecar (`holiday.yaml`): - -```yaml -title: Holiday recap 2026 -description: | - Multi-line description. -privacy: private # private | unlisted | public -publish_at: 2026-06-01T10:00:00+02:00 # optional; forces privacy=private -tags: [travel, vlog] -category_id: "22" # YouTube category id; default 22 (People & Blogs) -default_language: nl -default_audio_language: nl -made_for_kids: false -``` - -After a successful upload the tool patches the sidecar with the resulting -`video_id` and `uploaded_at`, so re-running on the same directory is safe; only -sidecars without a `video_id` get uploaded. - -Quota: `videos.insert` costs ~1600 quota units, so on the default 10k/day -quota you can upload ~6 videos per day. Use `--max` to cap a run explicitly. - -Show what's scheduled for the future: - -```bash -yts videos list --scheduled -``` +Full documentation, including installation, OAuth setup, and per-command reference, lives at +[jdwit.github.io/ytstudio-cli](https://jdwit.github.io/ytstudio-cli/). ## Development @@ -179,6 +49,13 @@ uv run pre-commit install Run the suite manually with `uv run pytest` and `uv run pre-commit run --all-files`. +To preview the docs site locally, install the `docs` group and serve with mkdocs: + +```bash +uv sync --group docs +uv run mkdocs serve # or: uv run mkdocs build +``` + ## API quota The YouTube Data API enforces a default quota of 10_000 units per project per day. Most read diff --git a/ascii.png b/ascii.png new file mode 100644 index 0000000000000000000000000000000000000000..27b3ecee80fe7d50ece835ee95a70adb0779928b GIT binary patch literal 18747 zcmb@uXIN9)w>^xC5=GDe3es{Q(wl`|6_C(-iAWI;>AjbLB}ymK6$!mpBOL?;DI(IF z5Q=~hLI}MEA^*+s-21!jy&vxLe&CUfJ8Q4KGS@6)jJYCowP7@um@iRKP|&EUD(h2F zoM}7#0bKz863o>70Ql#kyXr$P3JPla)1NaGscB5WAJ2H{!xSmX`&jY71*P46t@{)d z7!>tytMkA$t@i`Cw}IPJZ$B$fTM88iI~S{TOtK9Hg=&qO@_j@9Geor92iDR0a;h6o zVx{Qb24U9uG|`F@cX;MhBst?F!k@LrBIy|BKrmgOlD`xqq~a1O;ExA3Quf?1X4C_6zNoOX&WE zy+}cEFK?Wm<8eWdLNU|!)=ln5jR_{?t)@P>x@yYFPQMo>9kKH?p}oQSc<;FgkI3=Q zz9G1d!rnNy9?arX0QnbV=yAcr(%IkJj0q;}hgizY>#9gyuKw9W*Mz?k8D>Z)xL$i+ zE%Etkaw_-xTF7tft?oJA^_=f_>f!Z+Gd-~Ge1S0?g~p2Uz%mvAtrxv^vYLg8JyX(~ zF5=fe%PVmriw79y48pqS4!?ktbgkV#Mhw61XN{!{{k`b-bof>66>@$%eL}o7-^A}7 zHy%do2MikP6FkPQP2y|E#yt~k_TbqDlsakxmUPg=rRA@{HS!B-IN-*`85M|)MzyuDqkCuO#On{A6j^FdYyza6x$nitsx_p}}WG<>U zKSPg@%{ikJTrZjJleNx7NceuVXdOpsmGTB;RiLVHb3H>^DTnqEG@`MkUxsqD)$yH9 zD(C1nZ7Yl=r_J0XBP0ap9Ry5aT3)d$aAo^w2{6PUvVF|rxhoxkJkev=8WU#f$5`Em z7ks~UC@9<_e1Lj5VtPkYw1r|PIN-&o4?@W*!ESOphbq=*oLT5NRue~#3MhGdb^FjM z(v%PSzKnNqgrh!Wpl-$NlE??E?v(qB7;AIVaM9BXnL^h;*H}3+_HTC0g=1VdwV!rh2ze12`u?{x6NmE3Rl}~e$rN*pC++=Uq`?NbrYqiaB_@o~ z27gy2NKJT5u^%EgCLg%PiuKUXjhRFd1ECwvCea*tp#-ywa;5bO5~Ad@61 z!=hiI+*=UH$lZ~-=g;Y;#jVfW61(K}U21a;Pgo($U1hxkm`WeqW*;m*oHlo;)phpg z|7jo4$hU#BhD;kbPUfKPusRV8=>T+8Rmow~x|Y>G338`_u~3l$$*;XZ^QZs6F5gy# zc7a|`JQLwgSvdS5RTXX5cf=F)yeX9bqu{^pQgktJTbIWeEOOeBDx3aXMG&DrD}7Fz`~I2xMx^Km+;Lc94t>{uzv$ zTI8;l>uev}k35X`oA{$>-Rp+Qd~aQX$Q$5!C`0ujNUUwx8}|`DPTtsPyMxC+tAS6h zwWw(e-IIC)qVDXg76_G{7F`lH$xyLO?R>O!OrWQRaLBjSz>k)pW<1HXZ6SvW>w+=6 zLvvEjI{mfg_s%zl6%D?asNcT=nR^U;QON}8)RR)I5GGTw4)tGy*qrr^)0n05zs0YY zs9RrZ5KsQLTPCMRw5|HBFnr@0 z%a-xng_XNyP_FO&O9ArSNH*_z!?ks@rv$kd|Lc841y3noL&!+2-qDp z;ZYzBqPTSwj=d|R>1GXd(^>PCs|GlS^^6ZFL0?d85^2}EbcACW7rSu$1y+ZEWQ9ZPaT%e_YL8pU z$LsIoEGo;x)YfLn$FuK$?Qivn=&&Pp2DvZh=g11>jPt*yoAv6KlL!i_(YgzT31xHw zW5|w`Ban@;qhnp8my&F`kJ+r14!=6OgT{VB8K|hJv|i|Qdd8E8ZlN*(_`H-Q3%|v0 zN!){c0S*B%7l!fLB01w0pZqZo$8uD*C!H$9)^nZ|Axp-2wasWDR)_L!`ZlANBHTYN zuQ+*yKUkl=1DW8*ymkk+$y5wVH$oSBhf9cu=Qxd93eEE=_|S8^GJZ**;?o^9Jvwh| zgYT9nTPc-si_T573Cl<8EwjImS4K;E4?9JR=AnYKFiG#yEK;7Sm818^<@CnsIq0LM z!bd#D*JxYicEPC1-zOlmebZ{U@fOZ)gr8kB`;aPk=-%d`4lGrph&Xgi1ad5D!ngUehTjNKvRWIl>G1~>G1 z=QoR}t-olIH!-REPHdXRpEz0FU62tXJ;Bhxri9HQrg!+X!cc8&dRS6*~t8TZ`*gQ z>X{93mfaRTs`y`YZsxnXC~En(4!F$@n07)W=G7o~n|WQs2DrT00=olt6wRZ|?6bUx zo2vIppn?Q{P~*jaM7Bm7*s6C1EHl6~PD_tC*j7rJJoK|p1;_^w62 z_XrDHIO!pQfBS)E`vGVh)42p?Ek?1hCv#nR-+EbA?JIwJZecSN4wDz$p&qHQHCt}u_E4e>UD%lJ{nr6QZ4YiEs zGMU}E8(KXGuf)IM!hFxSDY_ep*$EX+9u&=t5AQp(GMG%6Mmf5P%fBqfza`aJ^JFrN zno(F3H|I&Kn3J1ryJOGNRQ>|y%gzy`oT>!1lCE^g3hhuemc(gt&LUW^}O5W8u+E=-b+JsTQ zP0?D&qsT!tk$K)g_T!+ch@Y_b6yU$^x(s;;b8n6~n%vpCa}ofmT2n`=n=((-WJUQQ z3lgydh4sYDvk)u!u^++6>IRy6)YZM%X0@K{Ag6dwRrE+QuLg{9Q5pMyWzwSgPL06B zD?YY-_Z;4Uvebi*kTDxB+6r3l@^RliKOHW;?=>u8Z*KEWut(zdhm~^17`P-5owgS> zhtbSp{0~>WC`LM$-7+X7X&W-ukN0OY%y~bq71~{c{Jd(AA#Z0l*7T-}u)T+M)9Fi> zlab+NBJeUj2E6@QXC^L2E;zcvt4*uls^MGI07yrHke$dOoOb4!E0B{rU*(eI5+C;U zh;`m=m>ji7s?bsXpas%D@o0^7zKbd_;Cg{u)un4;2|kLOo??o_^JAPoyEkr#3bXTNBUeO6D|hpkmV zE{!H}F^ykzR_{x6UY`q9ad7u!ZyZUGoQ5#gj`zUgC(7Z)s~Mf74;|=Q?NZ50yS`d# z70$#2P*Zfd|ESZ#`g=_z5Urz|P*|}C+-(RypHbkELM$rw?1PKaDt`IQ#Juvxy6BU& zM__%kNzMJAh>cb%EgHS)_XUAj^@I7E)XW#fGsGfzQ07w>wK~CGH?N&Y2QPi17tu2F z-QA5?bZLo!t3-&zrj2WvO}iP{6`vz@S>6mq2xhvrupUv zM=YQr{vI_A$RQt=mO(zj5FFt%7uM-?r8()kHxwS%N|^1tG&V4n6fF`v&BhLxB*aM&Va8m-5XlN2`?)gd7nYeX@<4V! zpRZqBG^r?7A60X9jJef;GLFUNSN=j8t5|h1lzU`nH!9<{0(!VBK@xF7DC<}ujKA-Y zlzqH$|BJ`Bh7O0N8oYKdsPJn5E72iURCwVWE1HA>*TV@#V$9U@5D zad7!@Ar`tiJ}jl6mGoeKxqlt^IcxsM`*i4PPU_0b`0*lxf{&rY_7*uh zEg2$#k?qIIN3?lMk6D0P6uQi7p{5d{2Bq)`kq*t(n{Ggt)1~Eq^(;I$YWP0ia5scq zjMnFbn%fJv@2$Hd}1S&q_AP~ z^O(ncrF7Z-jKxaWUoqPamqzw)I9@VRd!sk&zWllE3aZBBer>~Y_|!8S+<&(Qe3!p_o=r;Rn`WpY@L~IoBl+$>$|$Zz}$f z83|ZPSJY{8Ff10ha)!bacpZ!suTk-{FxasaUEp9=L4HWiEvx?X}(a$j|B zJ2G)BbvXq2ft|h&h`4Z%m4Al)06Ret$gT(b&+um%rkNlj_w-&}H7|2WL9fvpNR`!@ z_30H*n|k^@z{>g-?XS08S=TJ*-la1L%XAd`hOZSg?&GNImsi}L?>%~t7{C~&VSR4J z?A>?pi=`I`q=weSgJkG(ob*k|E6LRB8Z&nKVY&OgS~6ER!L$&k@t@GkBR`?C!YlTP z1BUl+ax2Oe#0ne7Y&Hcq_}php2}SitTB!G3jteB`OQSp1Z%b1ys(p{?^Q3J}%eT)! zAS?S1Qi?jVdPaHHGpr{@dG4w)YY-ATNjC-Qn_hvrgn1MZIN^q}6*1EEtItM-kCrJk zJQDBOa0ai{<5Hj0sZnvtw>AERPTd0B(B(zwPMp8&x0*#cyQkwHUDzebrHb8=Svh__ zPw+I`kwvj;u!BGdilnbruBK7Zq1dx>t4hl&z5NHrSGoP=x$VKxf{ajHEt0IEu`fc*QTG;U`Mo80-*I-sQ~izB?;Q2J zlP%64RTnO|@XEv*Z?F&JSh zN<&ZoAOcf0%o_ZA0H`JnCr;53-0@wH$uhT6r}1(zwpUf+>f!3X4S2H%K5oTPydhm$ zP|FwX9(0&A^=R78PJ*-`TakuCuB|l{&eq(^utM)fRk$=S+a~TrJj(0GSF!Vzt9SE< z6S%eKsyoyR&ULYBpdWg-R(D9(Z4P`NWsE4eK53md?2526%@Bx0iU$Vy*}FkM_;l)nvi!2Z||ark;#-C-Tj=0E|d zABMV>?iNAr%CTW-9c|^cYN+%Fs<-uK%jIZ6t6g%@OKps~lcD!D>{t*sBIVm7;>z|& zgSU>n}PKYCM4!@%w=Z!h=TMsHnNZVnq zXlt^S);GK<*o@he8_zdt8*Q#255J=eMjqSDL0Kux%n#vR9w5gRJ^M)gVO8U)+^6mw zZkaMr#vb2Zm`KI8sCH`lo~Dd|kFtk%5p99c4VCq@f@V{Ng@|S>!GER&u@BDgcab)} z$^L9{C0yn1t344Ol^jF|W0!r)u<9*zI!aqpVSOT^(ElS=zmhIQSOO7=azBRW9f9gm zALvFtlyQ5`$B&E5({^W%_~)jl&Z1e-hR4CmIDce9MulK+mZbEhacIH*^W(NzAIEgY zW_81e?(8MQcIVl=^nLk4L{C3#J|qoG{(Ul7_#B@ttctugM|~yuVvu9>{w(puuu?7~ zO(B5STjhL`NtG01Hd0kbHfMHrgEMJ#>>OJLJ4_XitTnaemxsJ&6q{<`u?199D97N8v`IR*z12+vg=bzaD=+i;tOt1*w`psx_Xp-OdS8@d+z%q= zM1u%`cYKpqahLY-W&bhjiQ`v@l$XaZ{&>0(-v1GKZ-q%_!?F1&>@Cu6EJ4jo48UnT zjsVYB((bDQsSVly`?gQ!mGtygr`^teNX|Lh8d~)10h#!09zPV;$vdK4(E18|T9@Wt z#vp%6b9#H4(yf@$aR5Mcmz8fThfB3sFqf1a6Yi?ew@J^mq_OkY(9{alRxTS?y}T=r z^G$9R21>9SfPYK;wW1I8+|qw_SxnW)c95n8GZcjgakLMdnNf)2R_xI-hZxcrDilQM zqFbh4`!$7POG2cya@;Ui7wRiGu@Euc{9x=uTkLDc36tPX?MbUG+ zzz!MPy-5rH1OJKRq0U%~dbpi}Oucbw)wWwy5_@60<6VACa`D&b$>hl#YVBO2rqSDx z3rW8X+52oS3=aZR`pA0+tU`V#{^9jlFZcnIb_rOSC%O4v*5W*^uC*ZX>OZxaqbzeb zel9gV-BWGQ%ki0dGJnuhxnBNU!me^c=Jj#Hp$rdBHUj6S9cs)YnsDX{lLodxr_?LV zz`>)TclKrcN?FdsJkjLjaiyMi=x8%W)LKv|S-MCblR9|c9VBg>rmbNk=T#R<$ci)4 zcbj+A)Lq&n$UcwdE)X0q>e`yP95FjZqVZiRYHKS}UG`7;wzi12$uz-{uasFg-|{C|v(9-j7igm$Px=qB z6pm14y(yJ!W5H~%(7-Bm7>aQaq!XuD{|2-lZekITC-!i%KA-xGux0bqlst!Ltv1C7 z@~K(5S^b@$s)+no@v{{nyCvsS3L=>K%q4Tq4Wz-DzP(U}Ure<&S}=s}BElRCm!D?( z2DfC=Cr`e1MZl|Fl8sszrpy~?ljeeUWrS-)%A#Q|?X>2`NhQIH;X zob#ry3_(S*`It>wn5sk!_@dJI3$D~F7i!;50d4*nUAIo8yTemv*pHBlJ0?unWuQxe zUFCWDy${1AgNRm2GJb1Qzgnpv&diTZHAWuP`*;_3Iv(qrW@2Yr{0-~XfMq}+nFpIT zJ6RGmQiOZs6Ew{8U<6a+(BUs9oviAmljVrmJ)@bvYQACr(}Xu^395+&ihf$>n-ejM zLsew^S=AnfNyGIP!ZU$s9st^X5GOHWN&fPMx*rI+(%-_h1#=wU{)k9s(vazG?Ybf) zn4q0h)LLYN%6m7mEZP=9qruj~&i`)d!dvY*^WWR&06z}(iIsWPOsFQp)ss_SJiM-9 ze`(nEW7V{$!Mi|v8Gg0qy-BCamM(BriiX;Pc}s}g(eEi~uHpJhxRoP@csJi53SnJz zH%hp!22Lfxp1KWA^>@q?GL|NaFTq`0#ACR+%T=DcwH*6ay5aq*JNz?qYxYl$*88g; z5*eArI>jG3LB@SaPsSPEfvUki$Q(9vG`h)aQ&kbh7-$9%GD}~TwFPyGIvm70hThY2c2b-`_{kqq;%@P9I6-Ll4LXD=OgV)#uTXd0cFq8L$*&Uym8)5iT1hM z-DTrZwQFvD{Z(ZtHUTgIEz-gquqp+57i}Z?`Gq~PtrxXn2h!^~+TWncJc=$~PBSG1 z!mc#cuf8%Nu*y)MUPeFe-Yc0GuLfIweF`%$VO~7An~%-X5p)~2#q5O0_vegbuZ>^R z75t$4P*6Z~c2i*A*;7wk5gspVi&bjv8tB5t{wHh-O zg=5vawK6PWgux4M<^_%?f5+rk+`h-i@8F$D$Hq~$Tm z;eYb`uv*SREEBJ#73GLsR-Mj~V~Zm=wip3U3**g>Sx?HLaI8#~_j!L!+T@2=AaeTU zYX{1oYFM^r5%A+{8zHgOf4Fpmod1zszW|vFw?1{g4W9u%0&vwIZHSkGk!%3xv6g}w zFn(62Y1W7BS*x$pdLU?pgBNw(idkIG78uJ1?q>`}9>Q&w7i!MPw^7Kq@g1!~zjOU1 zbIE6+Qd*SDa2%qQkV2P1pB0LV7{HjVK0@bpv^WTs;kfAm@`VrJB3__w0g3oU`8MyS zy;0cm`}gK#J=o!GDFE1q^^yboB6M%d(rwHlYh9Ro@_wvxNB2046bE8LG7gUYl}q*q z{p?fBNr)ij5^(+=byj@PZQunKfvL|!qk*lEW2eaiS0XHhb6zeO1!dD~SY{4*dQkD+ zPWBBq6J#^$z;}(n3i}??2$DcK0d1j-c-4i|==mx1@91f>AN#I+oL=jNsab_f*U%oG zo&X^9T3tEBrG^~5uWp;}kRDH-i$HA}?VV3T3*@#7L6F_9`R+*D)(V>&UEvqiBQ$oc z*?PM5c_;Eulz0~2UY5I7ZrfzZo3BC+(ecsOpJj?^-Wh0pAj|BgLe)BoI~Lb2$it*d zO||R}eC6j(VA7a9ertUSf+nwwJ2Lq1txinT_-Q>#Ac1z-mH+^7&%>cqpLeTR4;{1- zaNfuF6#C4GDZ(KV1$dG}bi$?s`wY(aSWnljzb|Xwsu%5wFVSeh9@`pLnb4>k_^vE_ z|0>d!9lgK@E{-em+TrCDqUL6Vn~4OF^ReqCuXa=Y~E>g>Dw{b;K^o!Zq!yH zHVtgQ8~b@w@{-6);P*6piV^C;zc;%o7v<r?K#K-r>o`k|FtsD*P`z?KcXH2bL_6IxBL{5fh+$=BmmLzicuB@o{ z3oZUOb-{eEv6&c6>il*HTG+1Kmu0?2;;rh1`P;a@4{6#9B+8CO1PjJ(gH>cN4U6wQ ze?WhC1gTkYS1vNTf{~rgbc4mFSAKS|A0@Zkt2niaV>ba!yT{QKM&ca#9oFqW3UY2h=+1Ul>L#0tO zLjvSaF}uN2Gn+WkWb*ZXXy*@yQt^bQW+zLJw-(W7Y=P1+Ez^qm%mHX0!sR(lF8Wm# zo3I`<1t4aO4@{Du^NHp+{5)b;2o77wJ}}Lp5X;<-OywVaB^6D1riS{DM==vT_wQ<+ zzl#K==cub=bKjB#dJ*r&%gx^h16f8{&m7c@hEzBG67>OkU0yL5iQNFNtQ(8D@Ec?8 zR-$SuN(i~5UYx)?pZk;L1BvAel>3pqBAMOBK`zmGx2Y3u+K1UW+%Y|F7EHD%;iMqk zRBTe|U^QG4F;cn_j4XMM`oIcAgbi>$0iezsflPf*Yq`msYLvi6?(g`G=G1@ zDztMAYG&8u3Dp79m)0l+`V^-`)ILAB1e0Vx0Cin_Pz$4wSNy@yvnS-UY@w25OF3K! zJWx_N@)P3hQr7L(e0YtUgD8WiMN-dJjYLm!CjyGvTZE+o>OVjJLC-Z9;uP$cFV%Ds9w8};bQpKt}xt|kyGVPn#}Pg*G*--s_=uy)rrz`~Xl zF-Pg$dZAtYXz8A+MTD6tQ5UA&(bNo=t=&j znTxk;EpE5s#(FlzJlBU_%|X9c0JhNEia?$R_P7|ZE0#jmw!odX4b}l{h*w2X?zMcm zpU8{W#B;z5=;{7;R5XAE0@@FRuwKt{fSjy#57!n`Pxsx-x^}a1jKIH=Mv%?_Hc+P)FwEVKGGFM5sdtx z!ByRMbt z*1ufobl6^e46n=2*TcSP;|W0lP(y@#Tix+#nlL2^m`SwTo5$@N* zGMiyRDh51Af2O72B&7CO zlrS;O@wTRt4uKTcszX2YO1hi2ygr#s3M%gfk}F$a3%vlIX@!t)V<0L7@&c4s06z5u zTchM|BCqJ;MZ6;XEW=g6WnaLg{5#u-edFKCn>zre`o}H$L#&i9P7ar(5A}K+lXfSd z*Y7ZHo!}dt#%!r+Tg?wZ03**iaz@LQJai_qCT6lU7+Ee8dHR;$ey*efH60g_C)pf< zpu)5+FI8ByDFX$6NT;qZ5Pt1X%K*wFE>ZqfN4pdGDPy@vZ7e`hJp3n?0Va}ApV0_x z4sj3gs&_8qVB#l8_1K`dwUd+GrmS!@)qgT2XaoN!l#S^0LB3|pNoIxN^}ZVL_DVo* z3jl(EEjOTU30F`fg0lwYFL~9zs>zuw6EWP93OT)LkO+Q;y877cG#35A-SbucXO$?R zU+Q26memJCy$)pbCj(u?bj~POmSE(mw+VsFypQ1c0mor4RQTuD@JGh^Tr~RMEZ&Zly(EfW`aI zL1qFcODcc$ZP|e>`=WfTy$h?y7HYQE95G|DM?k#wJ{ja-YN_=fSb*ywLT1fM)0Er+ zm_1eR1L>QOUG;ReTi$5sZ@I(udh4YFP!ABqDNlt-(?=i!6gs(5{aeI<+SDv2zEXvZ z|9Zz~*I_3wj8=Gl(BFGE;yvQY*G5WnZE)Qfc56*gaZwl#P8bIvFCBK5-F=D9=kO7tEH_l zChR*cdN68v+8DwAg-WCA;i}M)|$rb=OMAiok<5p??|AjYgENfor<&w#yB^$t&|htPKy8 z6bKZbp~gRiNESPC4X68T@$XUyT1usp7x1L;$yyMdmF#~=Z32G=&0MjsTmXPi-M^ak zB`tK%QM`X%q<5<2`lDON-r3> zDfNi03SFueDV^7R@EWfg-13#Wwgt=Ze9z8M2sYXl{fqU*ByEutXH1@H>>n-g0f+~U zi6uZaj}t=7tHLkWTbKjG?omOkC?Io*h&}Km1wpd*?sKWiRU9c8G07R9b$kRYdgJ+jg3QqhR8;uY(?(_N zvBlcRPGHPdRjC$)Z)GRAM2Y0rNcu!{%Q#qCe)f}4-(MGbD)|BjBfIv&mwL4eHAezM=J{UAw9sKIs4`dhM;=c^3=rf-i|REJK}Vwm&S? zT1{7ri7`hdfn;UBUzoH^%p_58ZLeAj&1_ta(ui4ra&)q*8ZAEoFye|X%4~D?FcUkwbu-!cfx%bo zDJR~=Qg`C$dkh>sOtmoHPdFn09ihpIe}jW4Ha2ftW@{@CbMwIarM{BB{?57wvkuKV zkaX>Nl?$3QSg4B*E&UoJbUfOZz}ZcX^+-J>L@}7xygn1?P?xk6tt{jGT9<|5$A{*>C72I?ae*_?G>( zXz-~cCm=WQ&nYM`H!B^f7`@&DAU)>hd{U6Cbf;}_Am#89G|h6qV%(wv-L>-8qWci! zWniH0DxEzpuBCOwgL3U3l;o~mT}xy0c6l=^Lf$3=;M`729^d2U9(YL&*`=KhgI)|q zcCM7e-yYWys!<3tzW-45EC?JAd6n}aem_(E8ybPFrGZze5D4UCi}#yF-;1_NWrHvD zatvVQ+jJ%s79FrUp z;sLz#&Sw}CQ%;EnyW*(4p_1VjBYo!*sbYSc(Yfq^h1^;! zOrZublRlD0Xcb;x%@;O%;7j#5b{9dApE-hbh#n?lvP(o_t{umi&$Nt0)Y6>NB6Gn= z=8K&#VbvnlF;mVFlgTIf%5Rfv^MY44gk)p@vDYph!VS+Xk@pvM8wzNBMIQ%xIdn<$tE*OxIk8q$o`eIGgc`jeS@ zvGx}j{QBv{8yd_^wXW2Ie5&~P3SHO4wULhPHNz#CVwIwKyQWiMFIuQFdUe! zsBz!XxWw31+ExyLS2Pn!w(V1Y;ez{){uxYaozbaosaj2^b`fY@@sV-^O7TClUh#Ag zdw6Z6F7dN9U34L)NUzw#bUd-sQ=gbU9*E{0hSf2hQjDh~3jTokdxun@JT+VZAt$kn z_$f0!JKr>ka<}rS`(26(aSp-!CPtxl-O8%xl23Oa?U=u{P_uY ze++J+F+N+=KClX4IEsBs#7pA-vOWA32GsVhx^1v`sTX?9??FYY>2_GvZhUOr zW&jmak8HU0^jhLyV%dfZzJeXCnKacozL{P)=_?h=O9F~wJs=|6GKJ@&r}oBu8TO;y4XpF(YbVmY`JBTdG;XD#o_kFK-)#HIt%fM0gi$*+!|7+JTe`(1C2oR2WL5!OyNvK2r1-#36B($@ zhGq-5(C(20H>?_JA}xd;M2Hi=1BxRBV^@X-AxPH#a|W*Rw;({nvp64;W7eIc9zxuDuVzPN6l|077*aH=Z2$lRwa4pq=`I@SP4(sjrzL(&E1aGuX z%}%#|oi7_N8P@;4FoP`J4d_vT$;2@x0;EHqgjWPh!=810x zosV;zg)`qyzOI2b%z*bU|5Zm~75EqO7%sy7ok`>9iLh}lvGMvOW5ZtFfdrQjv1bs< zL>9gluM(khR2uJ8Rc9X{RM1j1k{U90KYd3%4zZr{IvUVg=V8>dzLu!?thM5$16Z&lQrT}4eFM$nNze6r09roPa%h+yv`R~Wn>T$cN#IRd@*zWB?i-PTCt zNk5|VRH5a!2C%<4%%pX|Q5n4kHu#|+!VepnhbYq#L}z#luEX&403K#7iWR|sC$&c& zG>+M@QAh48tp{CS%)e-ORJV`II9|2u^Xa9bF}ege ztn)`8s-2UIZ0CC#F@2&Ppdm0+}MD z#1LY2GZr*H$na3UjI!`po65f)A0T%sW$(XM){jpR6{;AfnkjH`dUrF!;cG~Qd^zg} z9YbZ-3BLLwrEv5cX=*eI1Jml(9@gd~HA-L3@LE+zu6ZY4BaI!tlWaPFq~++0`n8uN z`f#RMjt$|MF)F_-5P$q>pt%5?zHfJ2QAB2)2^0EI17cZu#*@VU% zr^qReEx9|8Xz*T1iw!AFeOwVHQ!Xi1+`fe6&8^)G%0j#lPW!Yo5Rwz7YIia1CgRpo zhRxR2{kJFMCuwWzDn}I+_Q}PXJp{xN6TwR1FR<|onKN1_esG{8-^_U4uf@T15uJXm zM}LbKe04n@9+&Rx%Qm5;Su&!|*ee~yR49&x z3MLiA*j5ZKkj(p)*fOnNoEPLa$4&K$&);XeTs`amr0awVXk<7o58pDM{~y~50hmM3 z7y_~Ko_7&2W~pa6s(rO)G)#p43!wz`MY<9REk|pOy@*^FiL@)vn{hDqfZW5wD3RBr zFL$RccDQW2Ca*oP6pKXNg7oS6xmT>)(P=qqUpE+wNAdx3UmmS;^qrWO)M z1+HI=bT9jniUn>&-GTv-;L4H*W4^rC()TAu*>(f_M~BT#%NItx0+;Kvia|v#%ctUp znx@bLfG%~t3ZFmJH=#HS%J7kI*elhI?d zYJM?h$D9k0EZVb%tQ^v3OgLsflz+QVN}peUpZM!rLg7t&oosoVl>LgH`sL=_)7CVg z@&(W^J|z-}O@Q5~rUTG07@LcrCRZbd`?pw%17WVrh8OWbZQ0weuIb{Vam}PB|F*M` zNmq#^$+AagP%3@Ar>^FSIv}T%;csH|ybdy9+G40t@d{i6XcFp(;k?6SEiWep-OC-C z3;y2t!4TJ~8$f1D9PYEk5?1YoZL@i9vlr$PTZ8~K;_z=VmZ#&+f37i#w5-F-0P%w; z6-1%xgQm2R?`Hp%?}s3*G+zO+Q%uS+2%UL(tyWfzKdi3TV| z%h2Q9B`HRF#ufL`)|Ek9#=~p znQ|HEY2yGoN0Ov`9Hr&n64FL*lxzpDH|?9kl0Q81ru{KHcPIG6@FGU}6(#nWGU&m^ zN=b#dTHi%szw-onu)z_m|8WFUAdu%QQe!9IkMC z*na)@;dXDoG^Dy)W6i4epwT!!;YjC7&bmUn&R8or-~WfG{o72YnmM77paX>L{Bpy) z(2T(VV>WlydnH~KN;AvNmo1%#eLufcx>|D)=-cINLR#j~n3tKdY}Qa_A#;eIRaqU8 z${?dG%FGgWv3d9I8q>j%oG5yG(XY$YjzH_*f022n&LfbB)+4bzLjV`Dt_C*8Cc$MW z&P2MT;V+Y23HI@Xx(K*G*_Xb=TP0LU4v;-2u0S@CVj9U^f-B?Iso zGciWls9THzCTTJ;Ni6uhnHGGYP^af@yXm^7wWK%N$kQ&Bil)8E)%_v*toX*rWWtPU z+0kj+RLLMS{T%=D-JaP(JzvL_TO@f48E2)onHHTpyIhM`NA~U4GveWsMHO^_mDMAr z;7l&3ST@QCP}k|!&RK`>40fyJ>D>g?mhAPr_gmIZB_3nx+)&_I0LZvp5=wkPJ8++=KJ2(cxnbx+dS=6 zv@Pl5PU?~>&*kk}HVqk*zcZ?xh0}~GfXu2vGa~bpnGnHUB#81kS@O4bINFKyNtD&> zdt9NM?PZQ|gs<$XJI;n2xfuT}`ITjx_%^XU@k7<8^cMHIHfJdwX0E3(-&uLaelVnm zj5bK|1xgz)SK{xk$R%aUN>^sw4lyo?XMk-s@Z-#yg9G*|XQ0=aHX2}_V~78_+ogHW za`yf4$#<(qgGxD7Z{OCi3Aj|cVANMN)MLfCRf0x&0T91~8Nl0~2sV;fdqSE-ljBi+ zem+SThnH0%?z07W&o6#p(d*_w)9&!PcSB9)LxJYEr}5MN!n(`VdlJ5ASmJ(vdb{}g z?3>e;*=>wE;dF&zK*SJ;1&ofmyNHH|)=bG;%sbCJtRu|AKC7Mj1F&nkfV zE}Hq8dMDy{jOV$)n`QfjgY9R0zJy;bR*cYs%Er9$?pu>Yhp@ilJ3M@msuf%KJ3a79 zQvocHz-`&2-TjE_f+-njzJU)(bb)dP`)ya%|7DI)xnk}j zK$?1X0kasH?KyUVZ;;T7eMe;iS_HU|{iot+GV}n)C1wIrBZ8FhsDZ zOR-lbGq{uU48;-Up92v7BL@OB%drBe!b12cplh=6F=}GEGbaA9qrUEM?0d7T_+lUU z0>EMMjO8PcA1%yehosBsG^+0yOdA3oiP5O#qezW}vJ6sWMeX=m$zNgFZd(doOPann zd|rF*cJ%)k3I9}!JsKb908B0z`K3!b7WF~A4#5dU4E zSYUW;ZnkBQy*iKY*MGW$y#cQ=dFIL=%yN==icWg%zLR`qq6GAHYvBZ=lC*tNC0~tS z7eKcW=rxvqr^Uyik%|!l)tZI;Q3A8}hh{#>gz*ZIPZ_R{O(rUViP+yO4~&)WTuyR2 ziaynE)sr~w*vUOZaC{Z^txa|Hgy@(qvE>)MT9GqN6LodBZmVlpD<{tK59)|+RFO&* zHsEoFSgbOQM{t>Bd*YRf2i?-egjeYb>bXnu(22sqn0g)?2 zEC+{|f+B>2aGuzaQ&`cVdZE2xr@)~1@;wv5827|w^_c3?V;N(UBgmaG-D7=V9i;&# zr2)=E>ilLYChqm4ucd~ox>mn^H2>a%z&G{_A`U9>0 zpDDbPyeg+#UGE*QIgEHqJ;ZgqqGsd;Q+?n%?F4%ZX!yj)8e2{YO~TQG3`J3)PDtFM z>>Y*%c)r%<%M@QehhKgCa#>xU+D*)dIc8Iu(cZhSe;7@&tQdxB7oGk5^r!WWXW*>C z-1wDxFB~(*eeIGF1)K(GV^tkcy`5|@T(wf!FW*zI{LDs?ladKMSkKgd`|M}ZKOJRo z;OLcq4kQ7{2W232Lm8!_-03myptTm%Ro^QTBd6S@AJD-3gJk!$G0g8bL6+G2hZvlSs`pr}5 z6W)x{#Uuxi8M<)yY5~d<=t5THrOsCYMzhi=eG!aA*xMN0l2$=fA#-<7Rv@$Q2+o`A=F_%_kgEEfTJ!1|0oOADo|JdJ}KcR^m`ZZJjq6dzQ8vmJnp?!u|UBU>D}h`720KqY~|`Yh~$&z>G&05ENuz~!Pe<(dC$ zC;ipPGmts!(n+=#6xe5UPP&wT6Qh(r0EoL2#GR;OAB;DW#Bv-YXV z<5tZ14=$?655O@HK)1{)kk-0<8R0#>@kKiH*php|9iS2wdeeSi}BbkJEZKR6Djzj>$(;faFE zN7re-H<{ij12;dd0tVZxet`vSK!vivm2&&;8?`Gq@B%~dYGc2_|0p4aD_rD7Ev=5( hf#fJkO+WjeT~+atjcp&tQs9X}44$rjF6*2UngG?&Z8iV^ literal 0 HcmV?d00001 diff --git a/docs/index.md b/docs/index.md index 3f78e61..d7a1654 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,28 +1,22 @@ # ytstudio -Manage and analyze your YouTube channel from the terminal. Ideal for +Manage and analyze your YouTube channel from the terminal. Designed for automation and AI workflows. -[![CI](https://github.com/jdwit/ytstudio-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/jdwit/ytstudio-cli/actions/workflows/ci.yml) -[![PyPI](https://img.shields.io/pypi/v/ytstudio-cli)](https://pypi.org/project/ytstudio-cli/) -[![Python](https://img.shields.io/pypi/pyversions/ytstudio-cli)](https://pypi.org/project/ytstudio-cli/) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) - ## What it is A small CLI on top of the official [YouTube Data API](https://developers.google.com/youtube/v3) and [YouTube Analytics API](https://developers.google.com/youtube/analytics) that -covers what YouTube Studio cannot, or only at painful speed: bulk video -updates, scripted analytics queries, comment moderation, live broadcast -control, and multiple channels from one install. +allows for bulk video updates and uploads, scripted analytics queries, +comment moderation, and live broadcast control. ## Why it exists -YouTube Studio does not let you bulk-rename 300 videos. ytstudio does. It -also gives you a scriptable surface for the rest of the channel: analytics -you can pipe through `jq`, comment moderation you can run in CI, live -broadcasts you can schedule from a one-liner. +Managing a YouTube channel at scale feels like click-ops: the web UI has no +advanced features for bulk updates, and things become tedious very fast. +ytstudio gives you a programmable surface to automate administration tasks +and plug your channel into AI agent workflows. ## Highlights diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 0a4eaa6..46b79aa 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -30,7 +30,9 @@ --md-default-fg-color--light: rgba(255, 255, 255, 0.85); --md-default-fg-color--lighter: rgba(255, 255, 255, 0.55); --md-default-fg-color--lightest: rgba(255, 255, 255, 0.12); - --md-primary-fg-color: var(--yt-black); + --md-primary-fg-color: var(--yt-red); + --md-primary-fg-color--light: var(--yt-red); + --md-primary-fg-color--dark: var(--yt-red-hover); --md-primary-bg-color: var(--yt-white); --md-accent-fg-color: var(--yt-red); --md-accent-fg-color--transparent: rgba(255, 0, 51, 0.15); @@ -38,3 +40,28 @@ --md-code-bg-color: #2A2A2A; --md-code-fg-color: var(--yt-white); } + +/* Force YouTube-red header/tabs in dark mode (Material's slate scheme overrides + the variable-driven default with a more specific rule). */ +[data-md-color-scheme="slate"] .md-header, +[data-md-color-scheme="slate"] .md-tabs { + background-color: var(--yt-red); + color: var(--yt-white); +} + +/* Footer: YouTube near-black in both schemes. */ +.md-footer, +.md-footer-meta { + background-color: var(--yt-black); + color: var(--yt-white); +} +.md-footer a, +.md-footer-meta a { + color: var(--yt-white); +} +.md-footer a:focus, +.md-footer a:hover, +.md-footer-meta a:focus, +.md-footer-meta a:hover { + color: rgba(255, 255, 255, 0.7); +} diff --git a/mkdocs.yml b/mkdocs.yml index 215dca1..0a1fb52 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,4 +1,4 @@ -site_name: ytstudio +site_name: ytstudio-cli site_description: Manage and analyze your YouTube channel from the terminal. site_url: https://jdwit.github.io/ytstudio-cli/ site_author: Jelmer de Wit From cf7da4d0fdd16c77ab244e350e1a03bcfbf74a78 Mon Sep 17 00:00:00 2001 From: Jelmer de Wit <1598297+jdwit@users.noreply.github.com> Date: Thu, 28 May 2026 18:12:43 +0200 Subject: [PATCH 6/8] ci: deploy docs via github pages actions switch from mkdocs gh-deploy (gh-pages branch push) to the modern actions/upload-pages-artifact + actions/deploy-pages flow. requires the repo's pages source to be set to "github actions". --- .github/workflows/docs.yml | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 4e26beb..06440b5 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -16,13 +16,14 @@ on: workflow_dispatch: permissions: - contents: write + contents: read + pages: write + id-token: write -# Serialize deploys against the gh-pages branch; PR builds run in parallel -# because they only build and never push. +# Only one Pages deploy at a time; never cancel a running deploy. concurrency: - group: docs-${{ github.event_name == 'pull_request' && github.head_ref || 'deploy' }} - cancel-in-progress: true + group: pages + cancel-in-progress: false jobs: build: @@ -34,7 +35,7 @@ jobs: fetch-depth: 0 - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@v8 - name: Set up Python run: uv python install 3.12 @@ -45,8 +46,20 @@ jobs: - name: Build (strict) run: uv run mkdocs build --strict - - name: Deploy to gh-pages - if: | - (github.event_name == 'push' || github.event_name == 'workflow_dispatch') - && github.ref == 'refs/heads/main' - run: uv run mkdocs gh-deploy --force --no-history + - name: Upload Pages artifact + if: github.event_name != 'pull_request' + uses: actions/upload-pages-artifact@v5 + with: + path: site + + deploy: + needs: build + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 From 4ab55e6d8823df740119f9517045cbea4b909143 Mon Sep 17 00:00:00 2001 From: Jelmer de Wit <1598297+jdwit@users.noreply.github.com> Date: Thu, 28 May 2026 18:15:27 +0200 Subject: [PATCH 7/8] ci: pin setup-uv to v8.1.0 (no floating v8 tag yet) --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 06440b5..3864e7a 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -35,7 +35,7 @@ jobs: fetch-depth: 0 - name: Install uv - uses: astral-sh/setup-uv@v8 + uses: astral-sh/setup-uv@v8.1.0 - name: Set up Python run: uv python install 3.12 From cb90a421feb0c621c228761fc397323016121746 Mon Sep 17 00:00:00 2001 From: Jelmer de Wit <1598297+jdwit@users.noreply.github.com> Date: Thu, 28 May 2026 18:16:04 +0200 Subject: [PATCH 8/8] ci: use setup-uv floating @v7 (no @v8 floating tag yet) --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 3864e7a..418204c 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -35,7 +35,7 @@ jobs: fetch-depth: 0 - name: Install uv - uses: astral-sh/setup-uv@v8.1.0 + uses: astral-sh/setup-uv@v7 - name: Set up Python run: uv python install 3.12