-
Notifications
You must be signed in to change notification settings - Fork 374
Add language-agnostic emitter-diff tool + python adapter #11122
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b741d3b
dadc841
244f134
b996a66
c7d896f
1b64aa7
ce0ab95
4fca715
943d24a
2ab0428
4d2044d
174b1cb
a233572
17c65ee
7e27c74
dcd4ca9
02a7cea
a1c0118
722e756
c2e2e99
abe62b0
4601b99
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| changeKind: internal | ||
| packages: | ||
| - "@typespec/http-client-python" | ||
| --- | ||
|
|
||
| Add `--httpSpecsDir`, `--azureSpecsDir`, and `--no-baseline` flags to `regenerate.ts` so the language-agnostic `eng/emitter-diff` tool can drive code generation against pinned specs without cloning the published baseline. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,127 @@ | ||
| name: "python / emitter diff" | ||
|
|
||
| # Diffs generated code between this PR's emitter and the merge-base baseline. | ||
| # Informational only: reports a sticky PR comment + HTML artifact, and fails the | ||
| # job only on a tool/build error (never on a diff). See eng/emitter-diff. | ||
|
|
||
| on: | ||
| pull_request: | ||
| branches: [main, release/*] | ||
| paths: | ||
| - "packages/http-client-python/**" | ||
| - "eng/emitter-diff/**" | ||
| - ".github/workflows/ci-emitter-diff-python.yml" | ||
| workflow_dispatch: | ||
| inputs: | ||
| baseline: | ||
| description: "Baseline emitter ref (npm version, local path, or github ref). Defaults to the PR merge-base." | ||
| required: false | ||
| default: "" | ||
|
|
||
| permissions: | ||
| contents: read | ||
| pull-requests: write | ||
|
|
||
| concurrency: | ||
| group: ${{ github.workflow }}-${{ github.ref }} | ||
| cancel-in-progress: true | ||
|
|
||
| jobs: | ||
| emitter-diff: | ||
| name: "Generate & Diff" | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@v6 | ||
| with: | ||
| fetch-depth: 0 | ||
| - uses: ./.github/actions/setup | ||
| - uses: actions/setup-python@v5 | ||
| with: | ||
| python-version: "3.12" | ||
| - name: Install repo dependencies (emitter-diff tool) | ||
| run: pnpm install | ||
| - name: Build + venv for head emitter | ||
| working-directory: packages/http-client-python | ||
| run: npm ci && npm run build && npm run install | ||
|
|
||
| - name: Determine baseline | ||
| id: baseline | ||
| # Dispatch input is untrusted: pass via env, reference only as "$VAR". | ||
| env: | ||
| BASELINE_INPUT: ${{ github.event.inputs.baseline || '' }} | ||
| BASE_REF: ${{ github.event.pull_request.base.ref || github.event.repository.default_branch }} | ||
| RUNNER_TEMP: ${{ runner.temp }} | ||
| run: | | ||
| input="$BASELINE_INPUT" | ||
| if [ -z "$input" ]; then | ||
| # merge-base = a real commit on the base branch; survives squash/rebase. | ||
| git fetch --no-tags origin "$BASE_REF" | ||
| base_sha="$(git merge-base FETCH_HEAD HEAD)" | ||
| [ -n "$base_sha" ] || { echo "::error::No merge-base with $BASE_REF."; exit 1; } | ||
| git worktree add "$RUNNER_TEMP/baseline" "$base_sha" | ||
| ( cd "$RUNNER_TEMP/baseline/packages/http-client-python" && npm ci && npm run build && npm run install ) | ||
| input="local:$RUNNER_TEMP/baseline/packages/http-client-python" | ||
| echo "sha=$base_sha" >> "$GITHUB_OUTPUT" | ||
| fi | ||
| echo "ref=$input" >> "$GITHUB_OUTPUT" | ||
| echo "Baseline: $input" | ||
|
|
||
| - name: Run emitter diff | ||
| id: diff | ||
| working-directory: eng/emitter-diff | ||
| env: | ||
| BASELINE_REF: ${{ steps.baseline.outputs.ref }} | ||
| RUNNER_TEMP: ${{ runner.temp }} | ||
| run: | | ||
| set +e | ||
| # Node 24 runs TypeScript natively; no --fail-on-diff (informational). | ||
| # A tool/build error still exits non-zero (checked in "Fail on tool error"). | ||
| node src/cli.ts \ | ||
| --emitter python --baseline "$BASELINE_REF" \ | ||
| --work-dir "$RUNNER_TEMP/emitter-diff" \ | ||
| --html "$RUNNER_TEMP/emitter-diff.html" \ | ||
| | tee "$RUNNER_TEMP/emitter-diff.log" | ||
| echo "status=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT" | ||
| # Reuse the tool's own summary line (strip ANSI) instead of re-parsing. | ||
| summary="$(sed -r 's/\x1b\[[0-9;]*m//g' "$RUNNER_TEMP/emitter-diff.log" \ | ||
| | grep -oE 'Diff summary: [0-9]+ file\(s\), \+[0-9]+ / -[0-9]+' | head -1)" | ||
| echo "summary=${summary:-No changes to generated output.}" >> "$GITHUB_OUTPUT" | ||
|
|
||
| - name: Upload HTML diff | ||
| if: always() | ||
| uses: actions/upload-artifact@v7 | ||
| with: | ||
| name: emitter-diff-html | ||
| path: ${{ runner.temp }}/emitter-diff.html | ||
| if-no-files-found: ignore | ||
| retention-days: 7 | ||
|
|
||
| - name: Comment on PR | ||
| if: always() && github.event_name == 'pull_request' | ||
| continue-on-error: true | ||
| uses: actions/github-script@v7 | ||
| env: | ||
| BASELINE: ${{ steps.baseline.outputs.sha || steps.baseline.outputs.ref }} | ||
| STATUS: ${{ steps.diff.outputs.status }} | ||
| SUMMARY: ${{ steps.diff.outputs.summary }} | ||
| with: | ||
| script: | | ||
| const marker = "<!-- emitter-diff-python -->"; | ||
| const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; | ||
| const { STATUS, SUMMARY, BASELINE } = process.env; | ||
| let body = `${marker}\n### Python emitter diff\nBaseline \`${BASELINE}\` vs this PR.\n\n`; | ||
| body += STATUS !== "0" | ||
| ? `⚠️ **emitter-diff failed** (exit \`${STATUS}\`) — tool/build error, not a diff. See the [run](${runUrl}).\n` | ||
| : `${SUMMARY}\n\nFull rendered diff: **emitter-diff-html** artifact on the [run](${runUrl}).\n`; | ||
| body += `\n_Informational check (eng/emitter-diff); does not block the PR._`; | ||
| const { owner, repo } = context.repo, issue_number = context.issue.number; | ||
| const { data } = await github.rest.issues.listComments({ owner, repo, issue_number }); | ||
| const hit = data.find((c) => c.body && c.body.includes(marker)); | ||
| if (hit) await github.rest.issues.updateComment({ owner, repo, comment_id: hit.id, body }); | ||
| else await github.rest.issues.createComment({ owner, repo, issue_number, body }); | ||
|
|
||
| - name: Fail on tool error | ||
| if: always() | ||
| env: | ||
| STATUS: ${{ steps.diff.outputs.status }} | ||
| run: '[ "$STATUS" = "0" ] || { echo "::error::emitter-diff failed (exit $STATUS)."; exit 1; }' |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -98,6 +98,7 @@ words: | |
| - getpgid | ||
| - ghapp | ||
| - giacamo | ||
| - gpgsign | ||
| - graalvm | ||
| - Gson | ||
| - Hdvcmxk | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| # emitter-diff | ||
|
|
||
| A language-agnostic tool for **diffing the generated code produced by two versions of a | ||
| TypeSpec emitter**. | ||
|
|
||
| It generates code from the test specs twice (a **baseline** emitter and a **head** emitter), | ||
| then shows the diff between the two outputs. Use it locally during development and in CI on PRs. | ||
|
|
||
| Each language emitter (python, and later java/rust/go/ts) plugs in via a small **adapter** that | ||
| wraps that emitter's own generation command. The core (ref resolution, diffing, orchestration) | ||
| contains no language-specific logic. | ||
|
|
||
| ## How it works | ||
|
|
||
| ``` | ||
| baseline emitter ─┐ | ||
| ├─ generate (adapter) ─► <work>/baseline ─┐ | ||
| specs ─────────────────────┤ ├─► git diff ─► terminal / HTML | ||
| ├─ generate (adapter) ─► <work>/head ─────┘ | ||
| head emitter ─────┘ | ||
| ``` | ||
|
|
||
| - The **adapter** wraps the emitter's existing commands. For python that is | ||
| `packages/http-client-python/eng/scripts/ci/regenerate.ts` (generation). | ||
| - The regenerate _driver_ always comes from the current checkout; only the emitter build it points | ||
| at (`--pluginDir`) changes between baseline and head, isolating the diff to emitter behavior. | ||
|
|
||
| ## Usage | ||
|
|
||
| ```bash | ||
| # Run directly with Node 24+ (native TypeScript, no build step, no dependencies): | ||
| node eng/emitter-diff/src/cli.ts --emitter python --baseline 0.34.0 | ||
| ``` | ||
|
|
||
| > This tool is a set of plain `.ts` scripts — not an installed package. Node 24 runs TypeScript | ||
| > natively, so there is nothing to build or install. Typecheck with `npx tsc -p eng/emitter-diff`. | ||
|
|
||
| ### Refs (`--baseline`, `--head`, `--specs`) | ||
|
|
||
| | Syntax | Meaning | | ||
| | --------------------------------- | -------------------------------------- | | ||
| | `npm:1.2.3` or `1.2.3` | a published package version (prebuilt) | | ||
| | `local:/path` or `./path` | a local source folder | | ||
| | `github:owner/repo@<sha\|branch>` | a GitHub source at a ref | | ||
| | `gh:<sha\|branch>` | the current repo at a ref | | ||
|
|
||
| `--head` defaults to the **current checkout**. `--specs` defaults to **all** repo specs. | ||
|
|
||
| > `--baseline`/`--head` accept all three ref kinds. `--specs` accepts **local** or **github** | ||
| > refs only (npm versions aren't a valid spec source) — an `npm:` spec ref is rejected. | ||
|
|
||
| ### Common options | ||
|
|
||
| By default the tool writes a **clickable HTML report** (`emitter-diff.html`) into the work dir and | ||
| prints a `file://` link to it. Use `--terminal` for the full patch in your shell, or | ||
| `--patch`/`--html` to write to a specific file. | ||
|
|
||
| | Option | Description | | ||
| | ------------------ | ------------------------------------------------------------------------------------- | | ||
| | `--name <pattern>` | Filter which specs/packages are generated | | ||
| | `--html <file>` | Write the rendered HTML report to this path (default: `<work-dir>/emitter-diff.html`) | | ||
| | `--terminal` | Print the full colored patch to the terminal instead | | ||
| | `--patch <file>` | Write the raw unified diff to a file | | ||
| | `--fail-on-diff` | Exit non-zero when output differs (CI gating) | | ||
| | `--opt key=value` | Repeatable adapter-specific option (e.g. `--opt flavor=azure`) | | ||
| | `--sequential` | Generate baseline then head one at a time (default: both in parallel) | | ||
| | `-- <args>` | Everything after `--` is forwarded to the adapter | | ||
|
Comment on lines
+58
to
+67
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. At first version, I think there is no need to add too many customized options. Just call the regenerate command of language emitters then show the diff. And we could add more command option in the future if needed. |
||
|
|
||
| ### Examples | ||
|
|
||
| ```bash | ||
| # Default: writes a clickable emitter-diff.html and prints a file:// link. | ||
| node eng/emitter-diff/src/cli.ts --emitter python --baseline 0.34.0 \ | ||
| --opt flavor=azure --name authentication-api-key | ||
|
|
||
| # Compare two source folders and write an HTML report to a specific path: | ||
| node eng/emitter-diff/src/cli.ts --emitter python \ | ||
| --baseline local:/path/to/old/http-client-python \ | ||
| --head local:/path/to/new/http-client-python \ | ||
| --html diff.html | ||
|
|
||
| # Diff against a GitHub sha: | ||
| node eng/emitter-diff/src/cli.ts --emitter python \ | ||
| --baseline github:microsoft/typespec@ flavor=azure < sha > --opt | ||
| ``` | ||
|
|
||
| ## CI integration | ||
|
|
||
| `.github/workflows/ci-emitter-diff-python.yml` runs on PRs that touch the python emitter or this | ||
| tool. The **baseline** is the base-branch commit the PR is based on (the `git merge-base` with the | ||
| target branch) — always a real commit on the target branch, so it survives squash-merge, rebase, | ||
| and force-push. CI builds the python emitter (and a venv) for both the PR's checkout and a worktree | ||
| of that merge-base commit, diffs the two, and then: | ||
|
|
||
| - uploads the rendered **`emitter-diff-html`** artifact (full side-by-side diff; downloadable from | ||
| the workflow run — GitHub artifacts are zip downloads, so they can't be rendered inline in a | ||
| comment), | ||
| - writes a job-summary with the diff totals, and | ||
| - posts a **sticky PR comment** (updated in place on each push) with the changed-file and `+`/`-` | ||
| counts and a link to download the artifact. | ||
|
|
||
| **Informational:** the check **always passes unless the tool hits a real tool/build error** — a | ||
| generated-output diff does not fail the PR. CI runs the tool without `--fail-on-diff`, so a diff | ||
| still exits `0`; only a non-zero exit (a build/venv/generate failure) fails the job. Reviewers use | ||
| the PR comment and the HTML artifact to eyeball the diff. | ||
|
|
||
| The comment step needs `pull-requests: write`. PRs **from forks** get a read-only token, so the | ||
| comment is best-effort there (`continue-on-error`) — the artifact and job-summary still work. | ||
|
|
||
| ## Adding a new language adapter | ||
|
|
||
| 1. Implement `EmitterAdapter` (`src/types.ts`) — `prepareEmitter` and `generate` — wrapping that | ||
| emitter's own commands (e.g. rust's `npm run tspcompile`, ts's `gen-spector`). | ||
| 2. Register it in `src/registry.ts`. | ||
|
|
||
| That's the only wiring needed — the orchestrator, ref resolver, and diff engine are | ||
| language-agnostic and require no changes. | ||
|
|
||
| ## Notes & limitations | ||
|
|
||
| - External `--specs` folders must mirror the `http-specs` / `azure-http-specs` layout. | ||
| - `--html` renders a self-contained, GitHub-style HTML report (inline CSS, no external requests). | ||
| The tool has **zero runtime dependencies** — the diff itself is `git diff --no-index`. | ||
| - **Python's native two-phase pipeline needs a venv per emitter version.** `regenerate.ts` | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe we could also use worktree for local diff and there are 2 pros:
|
||
| compiles TypeSpec to YAML in-process and then runs a batched Python subprocess | ||
| (`eng/scripts/setup/run_batch.py`) that writes the `.py` files using a venv co-located with the | ||
| emitter at `<emitter>/venv`. The adapter therefore builds **and** creates a venv (`npm run | ||
| install`) for each side before generating. Because each version's venv is built from that | ||
| version's generator, a baseline must be a buildable **source** ref (`local`/`github`) or an npm | ||
| version whose package can run `npm run install`; the CI workflow uses a worktree of the PR base | ||
| commit as the baseline. | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think
cli.tsshall have an option like--generated-code-pathso that language emitter could set specific folder.