Step code style#

A Windmill step is a small program in one language. This project keeps that code as the source of truth in git, so each language it uses needs a named, enforced style: the standard it follows, the tool that checks it, and the Windmill rules that make step code different from ordinary scripts.

Python is the only language in the workspace today, so it is specified in full below. The structure is deliberately per-language: a new language gets its own section with the same four parts (baseline standard, tools and how to run them, formatting and line length, and any Windmill-specific contract). See Other languages for the set Windmill supports and how a new one is added here.

The Windmill main() contract#

Across every language, a step’s entry point is a function named main (a Windmill script entrypoint). Its parameters are not just arguments: their type annotations are the workspace form schema. Windmill parses the main signature statically (it does not run the module) and turns each annotated parameter into a property of a JSON Schema that Windmill infers from the signature, which renders as a UI form field. A parameter with a default value, or an optional type, becomes a non-required field.

This is semantic typing. A linter or type checker must not “simplify” these annotations, and main may legitimately take domain-shaped values and return a dict-shaped payload. The relaxations described under Type checking exist for exactly this reason.

Python#

Python is used in two places, both governed by one pyproject.toml at the repository root:

  • scripts/*.py: repository tooling (gen-bringup.py, reflow-descriptions.py), plain CPython plus PyYAML.

  • f/**/*.py: the hand-authored Windmill step scripts.

The target runtime is CPython 3.11. Ruff is pinned to it through target-version; Pyright is not version-pinned and infers 3.11 from the devshell interpreter. Either way the modern built-in generics and union syntax are available everywhere.

Standards baseline#

The baseline is PEP 8, but only the parts the enabled Ruff rule families actually enforce are gated; the rest is convention upheld in review. The enabled families are E, F, I, UP, and B (resolve any prefix in the Ruff rules index):

  • E (pycodestyle): the mechanical layout rules (whitespace, blank lines, indentation). The one exception is E501 (line length), which is disabled; the formatter owns wrapping instead (see Line length).

  • F (pyflakes): correctness defects such as undefined names, unused imports, and unused locals. This is logic level, not style.

  • I (isort): import ordering and grouping (see Imports).

  • UP (pyupgrade): rewrites legacy syntax to the modern py311 idiom, such as the built-in generics and the X | None union; the tree already follows it, so this locks in a rule rather than introducing one.

  • B (flake8-bugbear): likely-bug patterns, such as a mutable default argument or an except clause that re-raises without from.

Not enabled, and therefore not gated (they are upheld in review): D (docstrings), N (naming), and RUF. In particular the step naming rule (verb_object snake_case for steps, nouns for libraries) is a human convention, not a tool check.

Tools#

All Python configuration lives in pyproject.toml.

Ruff

The single linter and formatter. It lints E, F, I, UP, and B (with E501 ignored) and formats in the Black-compatible style at a line length of 88. target-version is py311 and vendor is excluded. This is the gate.

Pyright

Type checks in basic mode. It is advisory only and is not part of the gate. It includes scripts and f, resolves the repository root as an extra path, and relaxes several diagnostics, some only for f/ and some globally (see Type checking). The mode and every report* rule name are documented in the Pyright configuration.

How to run them:

$ nix flake check                           # lint and format check
$ nix run .#format                          # apply lint and format fixes
$ nix develop .#checks --command pyright     # advisory type check

Line length#

The line length is 88 columns, the Ruff line-length default, not PEP 8’s 79. The wider limit reduces wrapping while staying readable, which is Black’s rationale for choosing 88. Because Ruff’s formatter is the single authority on wrapping, the E501 lint is disabled: it disagrees with the formatter on the comments, strings, and URLs the formatter leaves unwrapped by design, so keeping it on would mean fighting the formatter.

Imports#

Import ordering is enforced by Ruff’s I (isort) rules and follows the PEP 8 grouping: standard library first, then third party, then first party, each group sorted and separated by a blank line. In step scripts the first-party group is the sibling imports, written as from f.x import y (there is no __init__.py; f resolves as a namespace package from the repository root).

from __future__ import annotations

import json
import os
from pathlib import Path

from f.common.devshell import Nix

The from __future__ import annotations line (PEP 563) keeps annotations as strings, so they are never evaluated at run time and forward references need no quoting. Windmill parses the signature statically either way, so this is ordinary runtime hygiene rather than a schema requirement.

Typing#

Typing follows PEP 484 with the modern syntax from PEP 585 (built-in generics such as list[str] and dict[str, int]) and PEP 604 (the X | None union). Because the target is 3.11, these need no from typing import and are preferred over the older List, Dict, and Optional spellings.

In a step, the main annotations are the form schema, so they carry extra meaning. Windmill maps Python types to form fields as follows:

  • str, int, float, bool: text, integer, number, and checkbox.

  • list[T]: an array field with typed items. dict: a JSON object field.

  • bytes: a base64 string. datetime and date: date-time and date pickers.

  • A default value, Optional[T], or T | None: a non-required field. Everything else is required.

  • Literal[...] and a string Enum: a select with those choices.

  • An unrecognised annotation name: a typed resource picker (its type is the name). S3Object is the s3_object resource.

A library module (a noun such as common.py or devshell.py) has no main and is imported with from f.x import y.

A step’s main therefore looks like this, with annotated scalars and a dict return:

def main(
    worktree: str,
    build_dir: str,
    targets: str = "",
    make_flags: str = "",
) -> dict:
    ...

Here targets and make_flags have defaults, so they render as optional fields; worktree and build_dir are required.

Type checking#

Pyright is advisory, not a gate, and two kinds of relaxation apply. The first is scoped to f/ through a Pyright execution environment: a step’s main annotations are a form schema, not ordinary typing, and it returns a dict-shaped payload whose keys are read dynamically, so reportArgumentType and reportAttributeAccessIssue are turned off under f. The second is global: both trees import f siblings as namespace packages over the repo root (there is no __init__.py; even scripts/gen-bringup.py does from f.qsu.binaries import ...), and the wmill client is injected at run time, so reportMissingImports and reportMissingModuleSource are set to none at the top level to avoid false unresolved-import noise. Apart from those two import diagnostics, scripts/ keeps the basic default.

Docstrings#

A step opens with a module docstring in the f/kernel and f/qemu style: a short prose summary followed by an Equivalent command (or Equivalent bash) block that shows the operation as a copy-pasteable shell command. There is no docstring rule in the gate, and none is wanted: requiring a docstring on every symbol would force boilerplate that restates the obvious.

Naming#

A step file is named for the action it performs, in the imperative mood, in verb_object snake_case when it takes an object (prepare_worktree, install_modules). A library or data module is a noun (common.py, identity.py). Field labels use the upstream spelling and a schema title: overrides Windmill’s auto-title-casing for acronyms (qemu_binary becomes QEMU Binary); the title key is one of Windmill’s advanced schema settings. These are conventions upheld in review, not tool checks.

Other languages#

Windmill runs step code in many languages. The current supported set is Python 3, TypeScript (in three flavors: Bun, Deno, and the native-worker variant nativets), Go, Bash, PowerShell, PHP, Rust, C#, Java, Ruby, Nushell, R, Ansible, GraphQL, and the SQL dialects PostgreSQL, MySQL, MS SQL, BigQuery, Snowflake, DuckDB, and Oracle. Windmill chooses the language from the file extension on sync.

When this workspace adds code in any of these, that language gets its own section here, structured like Python: its standard baseline, its tools and how to run them, its formatting and line length, and the Windmill main() contract restated in that language’s terms. The near-term candidate is TypeScript: wmill.yaml sets defaultTs: bun, so a bare .ts step resolves to Bun.