litestar-org/litestar-skills/litestar-build

Claude Code and agent skill by litestar-org/litestar-skills

Trust tier 2
  • community
  • ext:[{"type":"hook","description":"Pre/post lifecycle hooks"}]
Source check pending

Checking that the SKILL.md file is still in the repository.

Use when working with uv build, hatch build, PyApp, PYAPP_*, wheel assets, GitHub release matrices, cargo-zigbuild, or python-build-standalone. Not for runtime deployment.

Install

npx vskill@latest install litestar-org/litestar-skills/litestar-build

Semi-extensibleMentions customization, but needs skill-specific knowledge. Read the extensible skills standard

SKILL.md

litestar-build

Build-side packaging patterns for Litestar applications: how to produce a self-contained wheel that embeds the Vite/Bun frontend, how to wrap that wheel in a PyApp onefile binary, and how to wire the whole pipeline into GitHub Actions CI and releases.

This skill is the counterpart to litestar-deployment — build is about producing artifacts, deployment is about running them.

The Core Idea: One Wheel, Self-Contained

A Litestar wheel is the single source of truth for a release. It contains:

  • Python code (src/py/app/ or app/)
  • SQL migrations, Jinja templates, INI configs
  • The built Vite/Bun frontend bundle (JS, CSS, HTML, images)
  • Email templates rendered from React/MJX to static HTML

Once produced, that wheel can be:

  1. pip installed into a container (litestar-deployment).
  2. Wrapped in a PyApp binary (dist/<app>, dist/app-x86_64-linux-gnu) for zero-dep distribution.
  3. Uploaded to PyPI or a private index.

All three paths assume the wheel is already complete — no bun run build happens at deploy/install time.

Why bundle assets into the wheel (and not serve from a CDN)
PropertyBundled wheelExternal CDN
Deploy artifacts1 (.whl or binary)2+ (wheel + CDN upload)
Version alignmentAtomic — API and UI lock-stepEasy to skew; rollback is painful
PyApp onefileRequired — the binary embeds the wheelNot possible — binary can't fetch CDN URLs at install time
Offline/air-gappedWorksDoesn't
Dev server startupInstant (files on disk next to package)Fine
Frontend-only deploysRebuild + redeploy wheelPush to CDN only

For most Litestar apps that ship as a product (CLIs, internal tools, enterprise installers), bundled-in-wheel is correct. Projects like litestar-fullstack-inertia and litestar-fullstack all bundle.

Why litestar-vite configs look the way they do in reference apps

This is the piece most developers miss. The Vite/litestar-vite configs in the reference apps are deliberately set up so the Vite output lands inside the Python package directory — because that's what makes the wheel pick them up automatically.

litestar-fullstack (src/js/web/vite.config.ts):

ts
export default defineConfig({
build: {
outDir: path.resolve(__dirname, "../../py/app/server/static/web"), // ← inside src/py/app/ (the Python package)
emptyOutDir: true,
},
plugins: [
litestar({
bundleDir: path.resolve(__dirname, "../../py/app/server/static/web"),
hotFile: path.resolve(__dirname, "../../py/app/server/static/web/hot"),
}),
],
})

litestar-fullstack-inertia — in litestar-vite v0.30+, Python ViteConfig(paths=PathConfig(bundle_dir=...)) is the single source of truth and writes .litestar.json on startup/CLI invocation so litestar({ input: [...] }) in vite.config.ts inherits bundleDir, hotFile, and assetUrl automatically (and litestar assets build exports OpenAPI/routes metadata, runs TypeGenConfig codegen once with LITESTAR_VITE_SKIP_BUILD_TYPEGEN=1, installs node_modules if missing, and runs the Vite build):

python
from pathlib import Path
from litestar_vite.config import PathConfig, ViteConfig
def get_vite_config(base_dir: Path) -> ViteConfig:
"""Configure litestar-vite to emit assets inside the app/ Python package."""
return ViteConfig(
paths=PathConfig(
root=base_dir.parent,
bundle_dir=Path("app/domain/web/public"),
resource_dir=Path("resources"),
),
)

Advanced reference pattern — same approach: Vite and the offline-report build write to src/py/<app>/server/public/ and src/py/<app>/domain/web/static/reports/offline/, both under the package root.

Contrast with a naïve vite build that writes to ./dist/ at the repo root: those files are outside the package directory listed in [tool.hatch.build.targets.wheel] packages = [...], so Hatchling silently drops them. The wheel ships without a frontend.

Rule: Vite's `outDir` and litestar-vite's `bundle_dir` must point inside one of the Python packages that Hatchling is told to include. Everything else flows from that.

Code Style Rules

  • Emit all compiled frontend bundles (PathConfig.bundle_dir / Vite build.outDir) inside a Python package directory included by Hatchling (app/... or src/py/<pkg>/...).
  • Choose exactly one Hatchling asset inclusion strategy ([tool.hatch.build.targets.wheel.force-include] OR ignore-vcs = true) — never combine both.
  • Guard custom Hatchling build hooks (BuildHookInterface.initialize) with if version == "editable": (warning to sys.stderr, creating the placeholder directory, and returning early when compiled assets do not exist yet) so uv sync succeeds before frontend assets are built, and exclude tools/build from mypy and pyright.
  • Run asset compilation, static template generation, and license collection before uv build --wheel --clear.
  • Treat PYAPP_* configuration variables (PYAPP_PROJECT_PATH, PYAPP_PYTHON_VERSION, PYAPP_DISTRIBUTION_EMBED, PYAPP_DISTRIBUTION_VARIANT_GIL, PYAPP_SKIP_INSTALL) as compile-time inputs to cargo build / cargo zigbuild; at runtime, use PYAPP_INSTALL_DIR_<PROJECT_NAME> (where <PROJECT_NAME> is the uppercased PYAPP_PROJECT_NAME with - replaced by _) to override the extraction directory and inspect os.getenv("PYAPP") ("1" or path when built with PYAPP_PASS_LOCATION=1) to detect PyApp execution.
  • Use cargo zigbuild --release --target <arch>-unknown-linux-gnu.2.17 with BZIP2_SYS_STATIC=1 and LZMA_API_STATIC=1 when building portable Linux PyApp binaries, and ensure both $(uv tool dir --bin) ($HOME/.local/bin) and $HOME/.cargo/bin are on PATH / $GITHUB_PATH so the build never silently falls back to cargo build --release and binds to the runner's host glibc.

Quick Reference

TopicReferenceKey Commands
Wheel build + asset bundlingreferences/wheel-assets.mduv build --wheel --clear, [tool.hatch.build.targets.wheel.force-include], ignore-vcs = true
PyApp — simple (hatch-binary)references/pyapp-simple.mduv run hatch build --target binary
PyApp — advanced (offline + custom install dir)references/pyapp-advanced.mdtools/bundler.py build, cargo zigbuild
GitHub Actions CI (test matrix)references/github-ci.mdastral-sh/setup-uv@v7, oven-sh/setup-bun@v2, composite actions
GitHub Actions releasereferences/github-release.mdmatrix onefiles, cargo-zigbuild, gh release create
Upgrading Python / PyAppreferences/upgrading.mdFiles to edit in sync

Canonical Makefile Build Graph

Every Litestar app with bundled assets has some variant of this:

makefile
.PHONY: install build-assets build-wheel build-onefile
install: ## Install Python + JS deps
@uv sync --all-groups
@cd src/js/web && bun install --frozen-lockfile
build-assets: ## Build frontend into the Python package
@uv run app assets install
@uv run app assets build
build-wheel: build-assets ## Self-contained Python wheel
@uv build --wheel --clear
build-onefile: build-wheel ## Single-file PyApp binary
@./tools/scripts/build-onefile-package.sh

The dependency chain is load-bearing: build-onefile depends on build-wheel, which depends on build-assets. Running them out of order produces an empty or broken artifact.

The two-variant story

Real projects have multiple JS build outputs that all need to land in the wheel:

makefile
js-build-all: js-build-web js-build-offline-report
build-wheel: generate-licenses build-templates js-build-all
@uv build --wheel --clear

Each js-build-* target emits into a distinct subdirectory of the Python package (src/py/<app>/server/public, src/py/<app>/domain/web/static/reports/offline, etc.). Because they're all inside the package, a single uv build --wheel --clear captures everything.

<workflow>

Workflow

Step 1: Point Vite output inside the Python package

Configure ViteConfig(paths=PathConfig(bundle_dir=Path("app/domain/web/public"))) in Python so .litestar.json bridges the target directory to litestar-vite-plugin. If vite.config.ts overrides build.outDir directly (monorepo or standalone Vite build), set build.outDir and litestar({ bundleDir, hotFile }) to an absolute path inside your Python package (src/py/<pkg>/... or <pkg>/...). Do not let Vite default to ./dist/.

Step 2: Choose a Hatchling bundling strategy
  • `force-include` (inertia): List the built-asset directory explicitly under [tool.hatch.build.targets.wheel.force-include]. Built assets stay .gitignored. Explicit, auditable.
  • `ignore-vcs = true` (SPA): Tell Hatchling to ignore .gitignore. All package files ship. Simpler; requires discipline to keep dev junk out of package dirs.
  • Optional custom Hatchling hook: If you add a BuildHookInterface subclass (e.g., tools/build/hatch_build.py) to validate or build frontend assets during uv build, guard initialize(version, build_data) with if version == "editable": (warning to sys.stderr, creating the placeholder directory, and returning early instead of raising ValueError or running a frontend build) so uv sync succeeds before frontend assets exist, and exclude tools/build from mypy and pyright.

See references/wheel-assets.md for full config.

Step 3: Wire Makefile targets

Create install, build-assets, build-wheel. Make the wheel target depend on the asset target. Pass --clear to uv build --wheel --clear so stale wheels in dist/ never get picked up by downstream packaging steps.

Step 4: Add PyApp (if shipping a binary)

Decide which flavor:

  • Simple: add [tool.hatch.build.targets.binary] to pyproject.toml and run uv run hatch build --target binary (runtime install location can still be overridden via PYAPP_INSTALL_DIR_<PROJECT_NAME>). Good when end-users have PyPI access. See pyapp-simple.md.
  • Advanced: write a tools/bundler.py that pre-installs deps into a python-build-standalone archive, patches PyApp's src/app.rs for a custom compiled-in default install dir, then runs cargo zigbuild. Good for air-gapped distribution or bespoke default install locations. See pyapp-advanced.md.
Step 5: Add GitHub Actions CI

Start with a reusable test.yml that accepts python-version + coverage inputs. Call it from ci.yml across a matrix. Use astral-sh/setup-uv@v7 and oven-sh/setup-bun@v2. See github-ci.md.

For larger projects, factor setup-python and setup-node into .github/actions/ composite actions.

Step 6: Add release workflow

Trigger on v* tags. Run the test matrix first. Then build the wheel once. Then build PyApp onefiles in a per-target matrix (x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu, Apple, Windows). Upload to gh release create. See github-release.md.

</workflow>

<guardrails>

Guardrails

This is the start of the file. Read the full SKILL.md on GitHub.

Security

  • Security scanPassed, 100 of 100Pattern checks on the skill's files.
  • Model reviewPassed

Quality evaluation

  • Task evaluationNot runNo evaluation yet. Run one in Skill Studio.

Source

Popularity

7-day trend score
0

Not refreshed yet.

Extension points

  • hookPre/post lifecycle hooks

Works with

Any agent vskill installs to: 56 targets in all. These 39 have a page on this site.

  • Claude Code
  • Cursor
  • GitHub Copilot
  • Windsurf
  • Codex
  • Gemini CLI
  • Cline
  • Amp
  • Roo Code
  • Goose
  • Continue
  • Kilo Code
  • Junie
  • Trae
  • OpenHands
  • Qwen Code
  • AI Agent
  • Tabnine Chat
  • Sourcegraph Cody
  • Amazon Q
  • Firebase Genkit
  • Sweep
  • Devin
  • Phind
  • Mentat
  • Claude Desktop
  • Aider
  • GPT Pilot
  • Plandex
  • bolt.new
  • v0
  • Replit Agent
  • Anse
  • TypingMind
  • BetterChatGPT
  • LibreChat
  • Warp AI
  • Pieces
  • Fig AI
Embed badge
vskill VERIFIED badge
[![vskill VERIFIED](https://verified-skill.com/api/v1/skills/litestar-org/litestar-skills/litestar-build/badge)](https://verified-skill.com/skills/litestar-org/litestar-skills/litestar-build)

Something wrong with this skill or its page? Tell us.