Skip to content

Latest commit

 

History

History
295 lines (224 loc) · 14.4 KB

File metadata and controls

295 lines (224 loc) · 14.4 KB

Contributing to skit

Thanks for helping out! This document explains the development workflow and the quality gates every change must pass.

Hard requirement: uv

skit development is driven entirely by uv. Manually assembled pip / venv environments are not supported. Every command goes through uv run, and uv owns the creation and syncing of the isolated environment.

Install uv (pick one):

# macOS / Linux
curl -LsSf https://astral.sh/uv/install.sh | sh

# Windows (PowerShell)
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"

# If you already have Homebrew / pipx / cargo
brew install uv        # or
pipx install uv        # or
cargo install --git https://github.com/astral-sh/uv uv

Verify:

uv --version

Getting started

git clone https://github.com/t41372/skit
cd skit

# Create and sync the environment with dev dependencies (.venv is managed by uv)
uv sync --dev

# Run skit locally
uv run skit --help

Quality gates

Every item below is a hard CI gate — all of them must be green before a merge. Please run the full set locally before opening a PR.

Purpose Command Notes
Lint uv run ruff check Rule set lives in pyproject.toml (bugbear, bandit, pylint, and more)
Formatting uv run ruff format Run before submitting; CI verifies with ruff format --check
Types uv run ty check ty in its strictest mode ([tool.ty.rules] all = "error")
Tests uv run pytest -q Runs across Linux/macOS/Windows × Python 3.12 / 3.13
Coverage uv run pytest --cov Floor is 100% (fail_under = 100); anything less fails
Mutation testing uv run mutmut run Surviving mutants fail CI
Workflow audit uv run zizmor .github/workflows Security scan for GitHub Actions
i18n in sync uv run python scripts/i18n.py compile Committed .mo must match the .po sources (CI checks git diff)
i18n coverage uv run python scripts/i18n_coverage.py Fresh .pot, 100% non-fuzzy translations, no unwrapped UI literals, no dynamic gettext() (mirrored by tests/test_i18n.py)

Run the whole suite in one go (recommended before every PR):

uv run ruff format --check && \
uv run ruff check && \
uv run ty check && \
uv run pytest --cov && \
uv run mutmut run

Pre-commit hooks (prek)

This project uses prek — a faster, Rust-based, pre-commit-compatible runner — for pre-commit checks. Configuration lives in .pre-commit-config.yaml.

# Install the git hook (runs automatically on every commit afterwards)
uvx prek install

# Run against all files manually
uvx prek run --all-files

The hooks cover ruff (lint + format), ty, a zizmor audit of .github/workflows, and recompiling the i18n catalogs (.po.mo) whenever a .po changes.

Testing rules (important)

  • The 100% coverage floor is real, and padding is not accepted. Never use # pragma: no cover to hide reachable branches, and never write hollow "import-only, assert-nothing" tests to game the number. Every test must make a meaningful assertion about observable behavior.
  • Coverage only counts when it survives mutation testing. Coverage proves "this line executed"; mutation testing proves "this line's logic is actually pinned down by an assertion". When adding code, make sure your assertions kill the mutants mutmut generates.
  • # pragma: no cover is allowed only for genuinely unreachable or defensive branches, with an inline comment explaining why.
  • Secret-related behavior must have a "never touches disk" test (see the existing argstate tests).

Translations (i18n)

User-facing strings use GNU gettext with source-string message ids: the English text passed to gettext("…") / ngettext("…", "…", n) in the source is the message id. The runtime uses only the stdlib gettext module (no third-party runtime dependency); Babel is a dev-only tool for extraction and compilation. Catalogs live in src/skit/locales/<locale>/LC_MESSAGES/skit.{po,mo}; both the .po sources and the compiled .mo are committed (the .mo is what ships in the wheel and what the tests load).

All workflows go through scripts/i18n.py:

# Changed or added a UI string? Refresh the template, sync it into every locale, then compile.
uv run python scripts/i18n.py extract     # source strings  -> locales/skit.pot
uv run python scripts/i18n.py update      # skit.pot         -> each locale's .po (new msgids appear untranslated)
# …translate the new/changed msgids in each src/skit/locales/*/LC_MESSAGES/skit.po…
uv run python scripts/i18n.py compile     # .po -> .mo  (also run by the pre-commit hook)

# Add a whole new language (e.g. Japanese, French):
uv run python scripts/i18n.py add ja      # scaffolds locales/ja/LC_MESSAGES/skit.po from the template

English needs no catalog — an untranslated msgid falls back to the source text. Because the id is the English, editing an English string changes its id, so update will (correctly) flag the translations as needing review. Keep the committed .mo in sync (compile) or CI will fail.

Demo assets (README videos & screenshots)

Regenerate before every release. The committed videos and screenshots are current as of 0.3.0. Re-render them with bash scripts/record_demo.sh (needs Docker) whenever UI copy changes or a locale is added — the filenames are stable, so the READMEs need no edit unless the grid itself changes. The hand-recorded docs/assets/demo-mouse.gif is outside the pipeline and goes stale silently; re-record it by hand when the screens it shows change.

The README's demo videos (docs/assets/demo-*.mp4) and its four-screen TUI screenshot grid (docs/assets/tui-*-{en,zh}.png) are never recorded by hand — a scripted, hermetic VHS pipeline renders them, so they can be regenerated identically whenever the UI changes:

bash scripts/record_demo.sh          # everything: 2 videos + 8 screenshots
bash scripts/record_demo.sh videos   # docs/assets/demo-en.mp4, docs/assets/demo-zh.mp4
bash scripts/record_demo.sh shots    # docs/assets/tui-{library,form,add,settings}-{en,zh}.png

The only host requirement is Docker (or OrbStack). vhs / ttyd / ffmpeg live inside the image and never touch your machine.

How the pieces fit:

  • docs/assets/demo/Dockerfile — the recording environment: the official VHS image, plus uv, skit installed from your working tree, bat, vim (the demo's "edit script" scene opens $EDITOR, set to vim, configured by docs/assets/demo/demo.vimrc — a mirror of the maintainer's ~/.vimrc), fonts-noto-cjk (real Han glyphs for the zh renders), and a colored prompt (docs/assets/demo/demo.bashrc).
  • docs/assets/demo/demo.tape (the video) and docs/assets/demo/shots.tape (the screenshots) — the VHS keystroke choreography. Each tape is written once and drives every locale.
  • docs/assets/demo/scripts/{en,zh}/ — the dummy scripts being demoed, one set per language (their docstrings and --help text are what skit's forms display, so they are localized too). scripts/record_demo.sh runs each tape once per locale, with SKIT_LANG set and that language's scripts mounted at /demo.
  • Tapes and demo scripts are mounted, not baked — edit them and re-run, no rebuild. Only a change to skit's own source triggers an image rebuild (skit is baked in with uv tool install), and the script rebuilds automatically anyway.

Tips when editing tapes:

  • Expect a short tune loop — render, eyeball, adjust Sleep values and Tab counts. That's normal VHS workflow.
  • Type only ASCII in the shell scenes: non-ASCII keystrokes garble on the way through ttyd. Typing into skit's own TUI inputs is fine.
  • VHS has no End/Home and no mouse. Clear a prefilled field with Right N + Backspace N; mouse interaction can't be recorded at all — a separately captured clip is the only option (see The mouse-operability GIF below).
  • Showing a new screen? Add a Screenshot "/out/shot-<name>.png" line to shots.tape and the matching rename in record_demo.sh, then reference it from both READMEs.

Inline video playback on GitHub

The <video> tags in the READMEs point at GitHub attachment URLs (github.com/user-attachments/assets/…), not the repo files. GitHub serves repo-hosted mp4s as application/octet-stream with nosniff, so a raw docs/assets/*.mp4 URL only downloads — it never plays inline. The mp4s themselves stay pipeline-generated in docs/assets/; the attachment URL is only how they get embedded. To refresh after regenerating a video: drag the new docs/assets/demo-{en,zh}.mp4 into any issue/PR comment box, copy the user-attachments/assets/… URL GitHub produces, and swap it into that locale's <video src> (en → README.md, zh → README.zh-TW.md + README.zh-CN.md). The ▶ Watch the demo link under each video still points at the repo file, as a fallback for renderers that strip <video> (e.g. PyPI).

The mouse-operability GIF (docs/assets/demo-mouse.gif)

One demo asset is not pipeline-generated: docs/assets/demo-mouse.gif, the short clip under the hero video that shows skit driven by mouse alone (design principle #2). VHS drives no mouse, so this is hand-captured — the one exception to the "never hand-recorded" rule. It's a single shared clip (English UI, reused verbatim in all three READMEs, not per-locale), and record_demo.sh never touches it, so it goes stale silently if the UI it shows changes.

To regenerate it: screen-record yourself driving skit with the mouse (any tool — the current clip is a macOS QuickTime recording), then trim, speed up, and optimize into a small autoplaying GIF. The current one is source 16→37s, sped 1.5× to ~14s, 15fps, 1000px wide, 1.3MB:

SRC="your-recording.mov"    # macOS recording filenames carry a U+202F (narrow no-break
                            # space) before AM/PM — glob the path, don't type it by hand
common="setpts=PTS/1.5,fps=15,scale=1000:-1:flags=lanczos"
ffmpeg -y -ss 16 -t 21 -i "$SRC" -vf "${common},palettegen=stats_mode=diff" pal.png
ffmpeg -y -ss 16 -t 21 -i "$SRC" -i pal.png \
  -lavfi "${common} [x]; [x][1:v] paletteuse=dither=bayer:bayer_scale=3:diff_mode=rectangle" \
  docs/assets/demo-mouse.gif

Keep it short and mouse-motion-focused (light on readable UI text) so it stays useful across locales and doesn't obviously stale when copy changes. A full-length GIF is a trap — 64s came out at ≈18MB; trim hard, and prefer speeding the clip up over shipping raw minutes.

Adding dependencies

uv add <package>            # runtime dependency
uv add --dev <package>      # dev dependency (goes into [dependency-groups] dev)

Never edit uv.lock by hand; let uv maintain it and commit it together with your change.

Commits and PRs

  • Write commit messages in the imperative mood, focused on a single change.
  • In your PR, explain the motivation and the approach, and confirm every gate above is green.
  • When touching .github/workflows/**, run zizmor locally, and always pin third-party actions to a commit SHA (with the version tag noted in a comment beside it).

Releasing (maintainers)

Five steps. release.yml automates only step 3 — steps 4 and 5 are manual and easy to forget, because by then the satisfying part is over. Both were missed on 0.3.0 and had to be repaired after the fact: PyPI carried 0.3.0 while the GitHub releases page still advertised 0.2.0 as latest, and main sat on the released version, so uv tool install git+… handed out dev builds stamped 0.3.0.

Releases go directly to main — the tag has to sit there — and every commit is signed, so have your signing key unlocked before you start.

Before you begin, the whole gate must be green, and two things go stale quietly:

uv run ruff format --check && uv run ruff check && uv run ty check && uv run pytest --cov && uv run mutmut run
uv run python scripts/i18n_coverage.py   # 100% per shipped locale
bash scripts/record_demo.sh              # if any UI copy changed — see "Demo assets" above

1 — Bump to the release version. uv version maintains uv.lock too; commit both.

uv version X.Y.Z
git commit -am "chore(release): X.Y.Z"     # exactly these two files

2 — Tag. Annotated (-a), which is what 0.1.0 and 0.3.0 used; 0.2.0 was lightweight, an inconsistency not worth repeating. The tag name is vX.Y.Z — the leading v is what release.yml triggers on.

git push origin main
git tag -a vX.Y.Z -m "skit X.Y.Z

<a short summary — this is not the release notes, those come in step 4>"
git push origin vX.Y.Z

3 — The workflow publishes to PyPI. This is the point of no return. Pushing the tag builds, verifies, and uploads; the pypi environment carries no protection rules, so nothing pauses for approval. A version can never be re-uploaded or overwritten on PyPI. The workflow's first real gate is tag == uv version --short — a mismatch fails the build before anything ships, which is the one mistake it can save you from.

gh run watch "$(gh run list --workflow=release.yml --limit 1 --json databaseId -q '.[0].databaseId')" --exit-status
curl -s https://pypi.org/pypi/skit-cli/json | python3 -c "import json,sys; print(json.load(sys.stdin)['info']['version'])"

4 — Write the GitHub Release. Manual: the workflow does not create one, and until you do, the releases page still shows the previous version as "Latest". Notes are hand-written, not auto-generated — see v0.2.0 for the shape: a one-line framing of the release, bulleted highlights that lead with the biggest change (check git log vPREV..vX.Y.Z — it is easy to frame a release around whatever you worked on last rather than what actually landed), install/upgrade commands, and the PyPI link.

gh release create vX.Y.Z --title "vX.Y.Z" --notes-file notes.md --latest

5 — Reopen main for development. Immediately, in the same sitting:

uv version X.Y.(Z+1).dev0
git commit -am "chore: bump version to X.Y.(Z+1).dev0" && git push origin main

Historical note: v0.0.1 is a lightweight tag that does not point at a commit on main, and predates the single-sourced version in pyproject.toml. It is an artifact of the first release, not a pattern to copy.

License

By submitting a contribution you agree to release it under the project's MIT License.