build: make formatting enforcement actually work, and reformat the repo once - #96
Open
ayeshurun wants to merge 12 commits into
Open
build: make formatting enforcement actually work, and reformat the repo once#96ayeshurun wants to merge 12 commits into
ayeshurun wants to merge 12 commits into
Conversation
Formatting was never enforced in this repository. Four independent defects each broke it on their own: 1. `tox -e lint` ran `black .` in mutating mode instead of `--check`. Black exits 0 after reformatting, so the "Lint Code" job rewrote files in the ephemeral runner, threw them away and reported success. It could never fail. 127 of 347 files are currently unformatted as a result. 2. Black was unpinned (`deps = ["black"]`). Black's stable style changes between releases, so contributors on different versions reformat each other's code indefinitely. Measured on this tree: black 24.10.0 flags 114 files, black 26.5.1 flags 122 -- an 8 file delta from version drift alone. 3. `[tool.black]` lived in tox.toml, which Black never reads. Black only auto-discovers pyproject.toml, so line-length, target-version and exclude were all silently ignored. This went unnoticed because the configured line-length of 88 happens to equal Black's default. 4. isort was configured only in .vscode/settings.json. It was absent from tox.toml, CI and requirements-dev.txt, so contributors not using VS Code (including AI agents) produced a different import order with nothing to catch it. Changes: - Move [tool.black] to pyproject.toml and add [tool.isort] alongside it, so editor, tox, pre-commit and CI all read one source of truth. `known_first_party` and `src_paths` are pinned so isort classifies fabric_cli identically whether or not it is installed in the environment running it. - Drop the `exclude` regex. It replaced rather than extended Black's defaults, and those defaults are already a strict superset of it. - Split tox into `lint` (read-only, `--check`/`--check-only`, fails on drift) and `format` (mutating, for local use). `format` is deliberately kept out of env_list so it never runs in CI. - Pin black==26.5.1 and isort==8.0.1 in tox.toml, requirements-dev.txt and .pre-commit-config.yaml. - Add .pre-commit-config.yaml so drift is caught before it reaches CI. - Remove isort.args from .vscode/settings.json so the extension reads pyproject.toml, and scope editor.defaultFormatter to [python] -- it was set globally, applying the Black formatter to JSON, YAML and Markdown too. - Run the lint job on Python 3.13. Black verifies its output by re-parsing the AST, and that safety check is skipped with a warning when the interpreter is older than the highest target-version. - Extend the workflow `paths:` filter to cover .ai-assets/** and .pre-commit-config.yaml, which `tox -e lint` checks but the filter missed. The repository-wide reformat is deliberately kept in a separate follow-up commit so it can be added to .git-blame-ignore-revs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b5aa84f7-d808-438f-8b8b-1f17186eaabf
Mechanical, one-time reformat produced by the now-working lint
configuration from the previous commit. Run with the pinned
versions (isort 8.0.1, then black 26.5.1) and iterated to a fixed
point; a second pass produces no further changes.
This commit contains no behavioural change. Every one of the 144
files was audited by parsing both the before and after source into
an AST and comparing them after normalising for import ordering and
docstring whitespace. The only differences found fall into three
classes, all verified no-ops:
1. isort splitting, merging, and reordering import aliases.
2. black stripping trailing whitespace inside docstrings and
re-indenting them.
3. isort removing a duplicate import of
set_item_metadata_success_params in tests/test_commands/test_set.py,
which was listed twice in the same from-import.
Test results are identical before and after this commit:
tests/test_core, tests/test_utils, tests/test_parsers give
556 passed / 1 failed, and tests/test_commands gives 1066 passed /
5 failed / 2 skipped, on both revisions. Those six failures are
pre-existing and unrelated to formatting.
The next commit adds this SHA to .git-blame-ignore-revs so that
git blame skips it.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b5aa84f7-d808-438f-8b8b-1f17186eaabf
Records the repo-wide isort + black commit so that `git blame` skips
it and keeps attributing lines to their original authors.
GitHub honours this file automatically in its web blame view. Locally
it must be opted into once per clone:
git config blame.ignoreRevsFile .git-blame-ignore-revs
Verified: blaming a line touched only by the reformat attributes to
the reformat commit without the file, and to the original authoring
commit with it.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b5aa84f7-d808-438f-8b8b-1f17186eaabf
`--playback` is not a real pytest flag; the only option registered by tests/test_commands/conftest.py is `--record`. Passing `--playback` makes pytest exit immediately with "unrecognized arguments", so anyone following AGENTS.md could not run the integration suite at all. VCR playback is already the default (record_mode is "none" unless --record is passed), so the flag is simply dropped. The same stale flag also appears in .github/instructions/test.instructions.md, alongside a paragraph describing it as "a convention". That file is left alone here because correcting it means rewriting stated design intent rather than fixing a typo. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b5aa84f7-d808-438f-8b8b-1f17186eaabf
`.gitignore` excludes `tests/test_commands/data/*`. Black honours .gitignore while walking directories, so `black --check .` silently skipped three tracked Python files -- two of which were unformatted. `black --check .` reported "347 files would be left unchanged" and exited 0, while naming those files explicitly exited 1. isort does not honour .gitignore, and pre-commit passes tracked filenames explicitly rather than walking directories. So the three enforcement paths disagreed about which files exist. The previous commit's claim of a single source of truth was true for configuration but not for file selection. Changes: - .gitignore: re-include `*.py` under tests/test_commands/data/. The ignore rule is there for test artefacts, not for tracked source. Because a file cannot be re-included once a parent directory is excluded, this exposes models.py and static_test_data.py while leaving sample_code/ hidden -- which is what we want. - pyproject.toml: exclude tests/test_commands/data/sample_code/ from both formatters. Those fixtures are uploaded verbatim by tests and their bytes are recorded in VCR cassettes, so reformatting them would change recorded request bodies. Black needs `force-exclude` rather than `extend-exclude` because only the former applies to explicitly-passed paths, which is how pre-commit invokes it. - Reformat models.py and static_test_data.py. Verified AST-identical. - fab-build.yml: run `pre-commit run --all-files` alongside `tox -e lint`. The two resolve their file lists differently, so running both makes any future divergence a CI failure instead of a silent gap. It also validates .pre-commit-config.yaml, which nothing else in CI exercised. - .vscode/settings.json: set black/isort `importStrategy` to `fromEnvironment`. The extensions default to their bundled copies, which update on the extensions' own schedule and drift from the pins in requirements-dev.txt. - .pre-commit-config.yaml: pin revs to full commit SHAs, matching how upstream pins GitHub Actions. Tags can be moved by the upstream maintainer. - pyproject.toml: correct a comment that claimed black's DEFAULT_EXCLUDES cover __pycache__. They do not; it is skipped via .gitignore. That inaccuracy is what masked this bug. Verified: with a deliberately malformatted line appended to models.py, both `black --check .` and `pre-commit run --all-files` now fail. Before this commit, both passed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b5aa84f7-d808-438f-8b8b-1f17186eaabf
The previous `!tests/test_commands/data/*.py` negation was too broad. That directory doubles as a scratch area that tests write generated .py payloads into, so the wildcard made those artefacts show up as untracked in `git status` after a test run and exposed them to the formatters -- churn that the original blanket ignore existed to prevent. List the two tracked modules explicitly instead. Adding a real module here now requires a matching line, and CI's pre-commit/tox cross-check fails loudly if that is forgotten, so the failure mode is visible rather than silent. Verified: `black --check .` still reports 349 files (both modules remain visible); a deliberately mis-formatted models.py still fails both `black --check .` and `pre-commit run --all-files`; and a stray generated .py in that directory is ignored again. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b5aa84f7-d808-438f-8b8b-1f17186eaabf
Second review iteration surfaced three real defects and one finding that did not survive verification. 1. isort ignored its own skip globs for explicitly-passed files. `extend_skip_glob` is only consulted while walking directories. Passing a path directly bypassed it, so `tox -e format -- <sample_code file>` rewrote the VCR payload fixtures whose exact bytes are compared against recorded cassettes, breaking test_run_spark_job. Verified on a clean tree before and after the fix. `--filter-files` is now passed in both the lint and format environments; pre-commit's official isort hook already injects it. The comment in pyproject.toml claiming no "force" variant was needed was simply wrong and has been corrected. 2. isort and black disagreed about .gitignore. black honours .gitignore while walking directories; isort did not. The test suite writes generated .py files into tests/test_commands/data/, so running the tests and then `tox -e lint` produced an isort failure that black never reported. `skip_gitignore` aligns the two. Verified that the tracked, negated files in that directory are still checked by isort, black and pre-commit. 3. .git-blame-ignore-revs listed a SHA that can never exist. This repository allows squash merges only, so no commit SHA from inside a PR survives onto main. Git silently ignores unresolvable entries, so the listing failed open: blame would keep attributing reformatted lines to the formatting commit with no error. The dead SHA is replaced with the procedure a maintainer must follow after the squash merge, documented in both the file and CONTRIBUTING.md. The review also flagged the workflow `paths:` filter as a branch-protection deadlock. That did not hold up: PR microsoft#274 upstream changed only docs/release-notes.md, never triggered fab:build, and merged. The required checks are unconditional. The filter is also pre-existing upstream. The real gap was narrower -- .gitignore controls which files black sees, so a .gitignore-only change could alter lint scope without running lint. It is now in `paths:`. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b5aa84f7-d808-438f-8b8b-1f17186eaabf
Third review iteration. Four fixes plus a documentation trim. Pin the lint job's actions to full-length SHAs. Upstream microsoft/fabric-cli pinned every action in microsoft#271; this branch is based on a commit that predates that, so it still referenced actions/checkout@v3, actions/setup-python@v4 and actions/cache@v3 by tag. That made two claims false and introduced one real regression: - .pre-commit-config.yaml claimed its SHA pinning matched "how this repo pins GitHub Actions". True of upstream, not of this branch. Reworded to justify the practice on its own merits and cite microsoft#271 as the upstream precedent. - The pre-commit cache step this branch adds was a *new* actions/cache@v3 reference, i.e. a supply-chain regression against upstream. The SHAs used are copied verbatim from upstream/main, so these lines merge cleanly when this branch is rebased. Jobs this PR does not otherwise touch are left alone rather than pinned wholesale, to keep the diff scoped. Pinning setup-python also resolves a latent problem: the lint job asks for Python 3.13 from setup-python@v4, which predates 3.13 and runs on node16. Add scripts/check_formatter_pins.py and run it in CI. The black and isort versions are declared in three files, and nothing verified they agreed. The existing tox and pre-commit runs only catch drift once two versions format this specific codebase differently, which can lag a release by months. The script compares the declared pins directly. It is deliberately built so it cannot silently pass: a renamed pin, a stripped `# frozen:` annotation, a missing file or two tox environments disagreeing with each other all fail loudly, since a check that quietly finds nothing is worse than no check. Stdlib-only and regex-based rather than tomllib, so it runs on Python 3.10. Bound the pre-commit CI step with timeout-minutes. On a cache miss it clones hook repositories over the network; without a bound a stall holds a runner for the full six-hour default. Document that isort's skip_gitignore shells out to git, unlike black which parses .gitignore directly. In a tree with no .git it prints "fatal: not a git repository" per file and stops honouring .gitignore. Verified: in a git-archive extract, an untracked artefact makes isort exit 1 while black exits 0. That fails in the safe direction and does not affect a real checkout, so it is kept, but the caveat is now recorded. The obvious alternative, extend_skip_glob over the data directory, does not work: it would also skip models.py and static_test_data.py, which are tracked and must stay checked. Drop the "is a required check" claim from CONTRIBUTING.md. Branch protection is repository administration state, not something this tree can assert, and the lint job is in fact skipped for PRs that touch no path in its filter. Add scripts/** to the workflow path filter, since it now holds tracked Python that tox -e lint checks. Trim the 21-line banner in .git-blame-ignore-revs, which duplicated CONTRIBUTING.md, down to the parts a maintainer needs at the point of use. Verified: tox -e lint OK at 350 files, pre-commit clean, both YAML files parse, and no tracked .py falls outside the workflow path filter. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b5aa84f7-d808-438f-8b8b-1f17186eaabf
Corrects a claim made in d997a21. That commit asserted the pin check "cannot silently pass". That was false. The script counted occurrences of a pin and only rejected two cases: zero matches anywhere, and disagreement between matches. It could not detect *reduced coverage* -- a pin vanishing from one of two places it is required to appear. Four ways to bypass it were reproduced on disk, each exiting 0: - loosen black to `>=` in [env.lint] while [env.format] keeps `==`; the surviving `==` match was self-consistent, so the check passed - replace the pre-commit `rev:` SHA but leave `# frozen: 26.5.1` stale; the version was only ever read from the annotation - set `rev:` to a mutable tag rather than a SHA; nothing checked shape - add a second psf/black repo block at a different rev; `re.search` returned only the first The earlier tests missed all four because they mutated *every* occurrence of a pin, which is a different failure mode from a partial edit that leaves one self-consistent match behind. The script now validates structure rather than counting. It parses requirement specifiers (extras, whitespace, environment markers), normalises distribution names per PEP 503, requires both [env.lint] and [env.format] to declare an exact pin, rejects operators other than `==`, uses findall with an exactly-one assertion for pre-commit repo blocks, requires a 40-hex SHA, normalises CRLF, and reports undecodable or unreadable files as a pin error instead of a traceback. Array parsing uses a quote-aware bracket-depth scan, so an entry such as `black[jupyter]==26.5.1` no longer truncates at the first `]`. One limitation is now documented in the module rather than left implied: whether a `rev` SHA really is the tagged release cannot be checked offline, so a hand-edited SHA with a matching annotation still passes. `pre-commit autoupdate --freeze` is the mitigation. Verified against a harness that asserts each mutation actually changed the file bytes before running the check -- a guard added because an earlier harness silently applied no mutation at all and reported false passes. Baseline exits 0; 13 failure modes are caught; `black[jupyter]==26.5.1` with a matching version is still accepted, since the contract is version agreement, not extras equality. The lint job's path filter had the same shape of hole. isort's config search order is ('.isort.cfg', 'pyproject.toml', 'setup.cfg', 'tox.ini', '.editorconfig') and stops at the first hit, so a PR adding a root .isort.cfg would redefine import sorting while skipping the lint job that enforces it. None of those files were listed. Rather than enumerate directories a fourth time -- .ai-assets/, .gitignore and scripts/ were all added retroactively after being missed -- the filter now matches Python by extension and lists the alternate config files explicitly. Finally, .git-blame-ignore-revs contained a contradiction. It requires listed commits to be purely mechanical, but this repository squash-merges, which collapses a PR's commits into one. A PR mixing the reformat with configuration changes therefore produces a squash commit that is not purely mechanical and must not be listed, leaving the file permanently empty. The file and CONTRIBUTING.md now state that the reformat has to land as its own pull request for the entry to be valid. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b5aa84f7-d808-438f-8b8b-1f17186eaabf
Review iteration 5 found that scripts/check_formatter_pins.py was both too
strict and too permissive, and all six claims reproduced on disk.
It rejected valid configuration: a trailing comment on a requirements line
("black==26.5.1 # formatter"), an indented `deps = [` key, single-quoted TOML
strings, and a comment or blank line between `- repo:` and `rev:` are all legal
and all failed the check.
It also accepted broken configuration. A loose duplicate ("black>=24.0.0")
or a direct URL reference sitting alongside a good pin was skipped rather than
rejected, and -- worst -- renaming `id: black` in .pre-commit-config.yaml
disabled the formatter entirely while the check still exited 0. That is exactly
the failure mode this PR exists to prevent.
Both prior attempts to harden the regexes failed the same way, so the parsing
is now done with real parsers: tomllib for tox.toml and yaml.safe_load for
.pre-commit-config.yaml. The one thing YAML cannot supply is the version, which
lives in the `# frozen:` comment; that is read from the line carrying the rev
the parser returned, so it stays anchored to the real value. pyyaml is already
a hard project dependency, and tomli is added for Python 3.10, which has no
tomllib. The workflow installs pyyaml explicitly rather than relying on it
arriving as a pre-commit dependency.
The check now also requires each tool to be declared exactly once per file and
requires the pre-commit entry to actually enable the hook.
Verified with a 25-case suite on fresh git-archive extracts, each mutation
guarded by an assert that the file bytes changed: 8 valid variations exit 0,
17 broken ones exit 1.
Also adds MANIFEST.in to the workflow paths filter -- it controls what the
sdist ships, so a MANIFEST.in-only change should not skip the build.
Four other iteration-5 claims were checked and are false: the blame file does
not hardcode a SHA, commit 5fdb8ac contains no "squash merging exclusively"
text, tests/** is present in paths, and --filter-files is present in both tox
environments.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b5aa84f7-d808-438f-8b8b-1f17186eaabf
CI `tox -e lint` is the gate that actually blocks a merge; pre-commit was a
shift-left convenience on top of it. That convenience carried a real cost:
a third place to declare black/isort versions, a `# frozen:` SHA comment that
could silently drift from the tag beside it, an extra CI dependency, two cache
and run steps, and a second, independent definition of "the set of files we
format". Every defect found in the last review pass was in that layer.
Removing it leaves one gap. black honours .gitignore while walking directories,
so a file that is tracked *and* gitignored is never checked -- `black --check .`
reports success without having looked at it. That is how two unformatted files
shipped in this repo. `pre-commit run --all-files` happened to cover it, because
it passes tracked filenames explicitly rather than walking the tree.
Rather than keep a whole tool for that side effect, ask git the question
directly:
git ls-files -c -i --exclude-standard -- '*.py' '*.pyi'
This names the failure instead of inferring it from a formatting diff, needs no
black and no pins, and fails closed if git errors. sample_code/ is excluded by
prefix -- those fixtures are uploaded byte-for-byte by tests and are already
force-excluded in pyproject.toml -- so adding a legitimate new fixture will not
break CI while a genuinely hidden module still will.
Verified: the extracted step passes on the current tree, fails and names both
files when the .gitignore negations are removed, catches a newly hidden module
in an unrelated directory, and exits non-zero outside a git repository.
Also:
- check_formatter_pins.py drops to two files (317 -> 242 lines) and no longer
needs pyyaml. Re-validated against 21 adversarial cases.
- pyproject.toml keeps force-exclude, but for the remaining reason: it is what
makes exclusions apply to explicitly-passed paths, which is how editor
format-on-save invokes black.
- CONTRIBUTING.md now points contributors at format-on-save and says plainly
that there is no committed git hook.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b5aa84f7-d808-438f-8b8b-1f17186eaabf
The base branch moved (v1.7.0, plus the action SHA pinning from microsoft#271/microsoft#278), which left this PR CONFLICTING. GitHub cannot build a merge ref for a conflicting PR, so every `pull_request`-triggered workflow silently stopped running on it -- no run was queued, skipped, or failed, it simply never existed. Only `pull_request_target` (Semantic PR Validation) kept firing, because that one runs against the base branch instead of the merge ref. Three files conflicted: - src/.../fab_fs_deploy_config_file.py and tests/test_commands/test_deploy.py conflicted only because main changed the logic while this branch had reformatted them. Both were touched here solely by the pure isort+black commit (8e8472f), so main's version was taken verbatim and `tox -e format` re-applied. Both needed reformatting, which is this PR's premise in miniature: code landed on main unformatted because nothing was checking. - .github/workflows/fab-build.yml converged: main pinned actions/checkout to the same SHA this branch had picked. The comment claiming the other jobs still use floating tags is now false -- main pinned all of them -- so it is dropped. Python 3.13 is kept, as black's AST safety check is skipped when the interpreter is older than the py313 target. Verified after the merge: `tox -e lint` clean over 350 files, the formatter pin check passes, the gitignore guard passes, and the byte-sensitive VCR fixture is untouched at 55 lines. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b5aa84f7-d808-438f-8b8b-1f17186eaabf
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
PR microsoft#265 arrived with large amounts of unexplained formatting churn.
tests/test_utils/test_fab_ui.pyalone shows87+/58−in that PR, of which83+/58−is reproduced by runningblackon the file at the merge base. Roughly 95% of that file's diff is formatting, not the Azure CLI auth feature it claims to add.That is not the contributor's fault. Formatting enforcement in this repository has never worked. I found four independent defects, each verified experimentally.
1. The CI lint job was a no-op and could never fail
tox.tomlranblack .— the mutating form, notblack --check. Black reformats and exits0. In CI it rewrote files inside the ephemeral runner, threw them away, and reported green. Thelintjob has never once failed on formatting.2. Black was unpinned
deps = ["black"]floated to whatever was latest. Measured on the same tree:An 8-file swing from version drift alone. Two contributors on different black versions produce different output and each blames the other.
3.
[tool.black]was intox.toml, which black does not readBlack auto-discovers configuration from
pyproject.tomlonly. Verified directly: settingline-length = 40intox.tomlchanges nothing; the same setting inpyproject.tomlreformats immediately. The block was dead. It went unnoticed because the value it declared (88) happens to equal black's default — so it looked like it worked.4. isort was VS Code-only
isort was configured solely through
.vscode/settings.jsonisort.args. It was absent fromtox.toml, from CI, and fromrequirements-dev.txt. Contributors not using VS Code — or using it without the extension — had no import sorting at all.What this changes
Eleven commits, deliberately ordered so the repository ends green and
git blamesurvives.ae4be9d— fix the configurationpyproject.toml— adds[tool.black]and[tool.isort]. This is the single source of truth for formatter settings; isort usesprofile = "black"so the two cannot disagree. (Settings were only half the problem — the tools also disagreed about which files exist. Seefabfaf1.)known_first_party = ["fabric_cli"]andsrc_pathsare load-bearing: tox usesskip_install = trueand pre-commit runs in an isolated venv, so without them isort can classifyfabric_clias third-party and produce output that differs from CI.excluderegex is dropped rather than ported. Settingexcludereplaces black's defaults; black'sDEFAULT_EXCLUDESis a strict superset of what was there, so dropping it is strictly safer.extend-excludeis the correct knob if exclusions are ever needed.tox.toml—[env.lint]becomes read-only (isort --check-only --diff, thenblack --check --diff) and pins both tools. A new[env.format]does the mutating pass, and is deliberately not inenv_listso it never runs in CI. The dead[tool.black]block is deleted..pre-commit-config.yaml(new) — catches drift before it reaches CI.requirements-dev.txt— pinsblack,isort,pre-commitat the same versions..vscode/settings.json— removesisort.args(the second source of truth). Also scopeseditor.defaultFormatterunder"[python]": it was global, so VS Code was applying black to JSON, YAML and Markdown too..github/workflows/fab-build.yml— lint job moves from Python 3.12 to 3.13, andpaths:gains.ai-assets/**and.pre-commit-config.yaml.CONTRIBUTING.md/AGENTS.md— document the commands and the blame-ignore setup.8e8472f— the one-time reformat144 files,
+1459/−1235. Run with the pinned versions and iterated to a fixed point; a second pass produces no changes.67c9675—.git-blame-ignore-revsSo the reformat does not destroy blame.
Reworked in
5cab38e. This repository is squash-merge only, so no commit SHA from inside a PR ever reachesmain— the entry would have been dead on arrival. Worse, git silently ignores entries that do not resolve to a real commit, so it would have failed open: blame keeps pointing at the formatting commit and nothing warns you. The file now carries the mechanism and the procedure instead of a SHA, andCONTRIBUTING.mdtells maintainers to append the squashed SHA after merge.fc7b387— unrelated one-line doc fixAGENTS.mdtold contributors to runpytest tests/test_commands --playback. That flag does not exist —conftest.pyonly registers--record— so pytest exits immediately withunrecognized arguments. Anyone following the file could not run the integration suite. Playback is already the default. Happy to split this out if you would rather keep the PR pure.fabfaf1— close the file-selection gap (found in review)The first three commits fixed configuration. Review found they did not fix file selection, which was a second, independent hole:
.gitignoreexcludestests/test_commands/data/*. Black honours.gitignorewhen walking directories; isort does not; pre-commit sidesteps the question entirely by passing tracked filenames explicitly. The result was thatblack --check .reported347 files would be left unchangedand exited 0 while silently skipping three tracked Python files, two of which were unformatted. Naming them explicitly exited 1.So
tox -e lintwould have stayed green whilepre-commitwent red — the exact class of divergence this PR exists to eliminate..gitignorere-includes*.pyunder that directory. Git cannot re-include a file whose parent directory is excluded, which conveniently leavessample_code/hidden.sample_code/is excluded from both formatters on purpose: those fixtures are uploaded verbatim by tests and their bytes are recorded in VCR cassettes, so reformatting them would change recorded request bodies. Black needsforce-exclude, notextend-exclude— only the former applies to explicitly-passed paths, which is how pre-commit invokes it.models.pyandstatic_test_data.pyare now formatted. Verified AST-identical.pre-commit run --all-filesalongsidetox -e lint. They resolve file lists differently, so running both turns any future divergence into a CI failure. It also validates.pre-commit-config.yaml, which nothing in CI previously exercised. This is ~10s of deliberate redundancy; push back if you disagree..vscode/settings.jsonsetsimportStrategy: fromEnvironmentfor both extensions. They default to their bundled black/isort, which update on the extensions' own schedule and drift from the pins inrequirements-dev.txt..pre-commit-config.yamlrevs are now full commit SHAs, matching how upstream pins GitHub Actions. Tags are mutable.DEFAULT_EXCLUDEScovers__pycache__. It does not — that is.gitignoredoing the work. That inaccuracy is precisely what masked this bug.Regression test: append a malformatted function to
models.py; bothblack --check .andpre-commit run --all-filesnow fail. Before this commit, both passed.Evidence that the reformat is safe
AST equivalence, all 144 files. Every file was parsed before and after and the trees compared after normalising for import order and docstring whitespace. Three classes of difference were found, all confirmed no-ops:
set_item_metadata_success_paramsintests/test_commands/test_set.py— it was listed twice in the samefrom-import. This is the only non-whitespace change in the entire commit.Tests, identical before and after. Same invocation, same environment, run from a clean state on each of three revisions:
ae4be9d(pre-reformat)fc7b387(post-reformat)fabfaf1(HEAD, incl. review fixes)Caveat, and it matters. These absolute numbers are not stable across invocations. This suite leaks global state, so the failure count depends on what ran before it in the same environment:
test_cp.pyandtest_mv.pyindividually first, then the full suite: 19 failures on the exact same tree.test_cp.pyalone: 4 failures.test_mv.pyalone: 4 failures and 1 error.So do not read "6 failures" as a property of the code. The load-bearing claim is the comparison: under an identical invocation from an identical starting state, the three revisions produce identical results. The reformat and the review fixes change nothing.
The underlying cross-test pollution is a real pre-existing problem, but it is out of scope here.
5cab38e— second review iterationextend_skip_globis consulted only while walking directories.tox -e format -- tests/test_commands/data/sample_code/spark_job_simple.pytherefore rewrote a VCR payload fixture whose exact bytes are compared against a recorded cassette. Confirmed on a clean tree before and after. Fixed by passing--filter-filesin both tox environments — pre-commit's official isort hook already injects it, which is why pre-commit was never affected. The comment inpyproject.tomlasserting that isort needed no "force" variant was wrong and is corrected..gitignore. black honours it during traversal; isort did not. The test suite drops generated.pyfiles intotests/test_commands/data/, so running the tests and thentox -e lintproduced an isort failure black never reported.skip_gitignore = truealigns them. Verified the tracked, negated files in that directory are still checked by isort, black and pre-commit.d997a21— third review iterationcheckout@v3,setup-python@v4andcache@v3. Two consequences: the comment in.pre-commit-config.yamljustifying its SHA pins as "matching how this repo pins GitHub Actions" was false for this branch, and the pre-commit cache step I added was a newactions/cache@v3reference — a supply-chain regression against upstream. The lint job's actions are now pinned to the SHAs copied verbatim fromupstream/main, so those lines merge cleanly on rebase. Jobs this PR does not otherwise touch are deliberately left alone rather than pinned wholesale. This also removes a latent problem: the job askssetup-python@v4for Python 3.13, which postdates v4.tox.toml,requirements-dev.txtand.pre-commit-config.yaml. Running both tools in CI only catches drift once two versions format this codebase differently, which can lag a release by months.scripts/check_formatter_pins.pycompares the declared pins directly. Correction: this bullet originally claimed the script "cannot silently pass". That was false. The version committed here counted occurrences of a pin and rejected only two cases — zero matches anywhere, and disagreement between the matches it did find.5fdb8acbelow documents the four ways it could be bypassed, and rewrites it.CONTRIBUTING.mdclaimed the lint job "is a required check". Branch protection is repository administration state, not something this tree can assert — and I had already disproved it myself while investigating thepaths:filter (see below). Reworded to describe what CI does, not what the repo is configured to require.skip_gitignorehas an undocumented dependency on thegitbinary. isort shells out to git; black parses.gitignoredirectly. In a tree with no.git(an sdist, or a container built without the checkout) isort printsfatal: not a git repositoryper file and stops honouring.gitignore. Verified: in agit archiveextract with a stray untracked artefact, isort exits 1 while black exits 0. This fails in the safe direction — a spurious failure, never missed drift — and does not affect a real checkout, so it is kept, but the caveat is now recorded. The obvious alternative, anextend_skip_globover the data directory, does not work: it would also skipmodels.pyandstatic_test_data.py, which are tracked and must stay checked.timeout-minutes, addedscripts/**to the workflow path filter, and trimmed the 21-line banner in.git-blame-ignore-revsthat duplicatedCONTRIBUTING.md.5fdb8ac— fourth review iteration0: loosening black to>=in[env.lint]while[env.format]kept==(the surviving==was self-consistent, so the check was satisfied); replacing the pre-commitrev:SHA but leaving# frozen: 26.5.1stale (the version was only ever read from the annotation); settingrev:to a mutable tag rather than a SHA; and adding a secondpsf/blackblock at a different rev, whichre.searchnever saw. The root cause was that the script counted occurrences instead of validating structure, so it could not detect a pin disappearing from one of two places it is required to appear. My own earlier tests missed all four because they mutated every occurrence of a pin — a different failure mode from a partial edit that leaves one consistent match behind. The script now parses requirement specifiers (extras, whitespace, environment markers), normalises names per PEP 503, requires both tox environments to declare an exact pin, rejects any operator other than==, asserts exactly one matching pre-commit repo block, and requires a 40-hex SHA. Re-verified with a harness that asserts each mutation actually changed the file bytes before running the check — a guard added because an earlier harness silently applied no mutation at all and reported false passes. Baseline exits0; 13 failure modes are caught; a legitimateblack[jupyter]==26.5.1with a matching version is still accepted, since the contract is version agreement, not extras equality. One limitation is now documented rather than left implied: whether arevSHA really is the tagged release cannot be checked offline.paths:filter had the same shape of hole. isort's config search order is('.isort.cfg', 'pyproject.toml', 'setup.cfg', 'tox.ini', '.editorconfig')and stops at the first hit, so a PR adding a root.isort.cfgwould redefine import sorting while skipping the job that enforces it. None of those files were listed. Rather than enumerate directories a fourth time —.ai-assets/,.gitignoreandscripts/**were each added retroactively after being missed — the filter now matches Python by extension and lists the alternate config files explicitly..git-blame-ignore-revscontained a contradiction. It requires listed commits to be purely mechanical, but this repository squash-merges, which collapses a PR's commits into one. A PR mixing the reformat with configuration changes therefore produces a squash commit that is not purely mechanical and must not be listed — leaving the file permanently empty and the stated benefit unrealised. Three prior review iterations missed this. The file andCONTRIBUTING.mdnow state that the reformat has to land as its own pull request for the entry to be valid. This is a merge-strategy decision for whoever lands the work, not something the branch can fix by itself — see below.3.26svs pre-commit1.34scached. It does not.Things I did not resolve
paths:filter. A reviewer called this a branch-protection deadlock: a PR touching only unlisted paths would leave a requiredlintcheck pending forever. I could not reproduce that. Upstream PR chore(release): revert changes to previous release notes microsoft/fabric-cli#274 changed onlydocs/release-notes.md, never triggeredfab:build, and merged — the required checks (license/cla, changelog, PR title) all run unconditionally. The filter is also pre-existing upstream, not something I introduced. The genuine gap was narrower:.gitignoredecides which files black even sees, so a.gitignore-only change could alter lint scope without lint running. That path is now listed. Dropping the filter entirely remains a repo-policy call.ayeshurun/fabric-cli@main, which is 7 commits behind and 2 ahead ofmicrosoft/fabric-cli@main. I deliberately did not rebase ontoupstream/main: this PR targets the fork'smain, which carries two fork-only commits, and rebasing would make the diff show those as deletions. The cost is that the branch does not inherit upstream's SHA-pinning commit (chore: Pin GitHub Actions to full-length commit SHAs microsoft/fabric-cli#271), which is why the pinning above had to be applied by hand to the job this PR touches. A 144-file reformat on a stale base will conflict badly. If any of this is wanted upstream, commits 1, 3 and 4 should be cherry-picked and the reformat re-run fresh on currentmain— do not port the reformat diff itself.tox -e formatrather than resolved by hand..git-blame-ignore-revsstays empty andgit blamepermanently attributes 144 files' worth of lines to the reformat. Landing it as two PRs — configuration first, then the reformat alone — costs one extra round trip and is the only way to get the blame benefit. Documented rather than decided unilaterally..github/instructions/test.instructions.mdcarries the same bogus--playbackflag plus a paragraph describing it as an intentional convention. Left alone — correcting it means rewriting stated design intent.For reviewers
Review iteration 5 (final) —
0c5390eThe pin check was both too strict and too permissive. All six of the reviewer's
script findings reproduced on disk.
It rejected valid configuration: a trailing comment on a requirements line
(
black==26.5.1 # formatter), an indenteddeps = [key, single-quoted TOMLstrings, and a comment or blank line between
- repo:andrev:are all legaland all failed the check.
It accepted broken configuration: a loose duplicate (
black>=24.0.0) or adirect URL reference alongside a good pin was skipped rather than rejected, and
renaming
id: blackdisabled the formatter entirely while the check still exited0 — precisely the failure mode this PR exists to prevent.
Two prior attempts to harden the regexes failed the same way, so parsing now uses
real parsers:
tomllibfortox.toml,yaml.safe_loadfor.pre-commit-config.yaml. The version lives in the# frozen:comment, which YAMLdiscards, so it is read from the line carrying the rev the parser returned. PyYAML
is already a hard project dependency;
tomliis added for Python 3.10. Theworkflow now installs
pyyamlexplicitly instead of relying on it arriving viapre-commit.
Also adds
MANIFEST.intopaths:— it controls sdist contents, so aMANIFEST.in-only change should not skip the build.Verified with a 25-case suite on fresh
git archiveextracts, every mutationguarded by an assert that the file bytes actually changed: 8 valid variations
exit 0, 17 broken ones exit 1.
Four iteration-5 claims were checked and are false, and no change was made for
them:
.git-blame-ignore-revshardcodes8e8472f8…5cab38e5fdb8acsays "squash merging exclusively…"tests/**missing frompaths:[env.format]lacks--filter-filesUseful negative results from this iteration:
**/*.pydoes not match.ai-assets/foo.py(GitHub path filters use minimatch without{dot: true}), sothe explicit
.ai-assets/**entry is load-bearing; and tox and pre-commit resolveto the identical 350-file set.
The review loop is complete at 5 of 5 iterations.
Final change — removing pre-commit
e7e58d2, in response to the question "can we do the enforcement without addingthe pre-commit?" The answer is yes, and on the evidence from five review
iterations that is the better trade.
Why.
tox -e lintin CI is what actually blocks a merge. pre-commit was ashift-left convenience layered on top of it, and its cost was concentrated
rather than spread out: a third place to declare black/isort versions, a
# frozen:comment that could silently drift from the tag beside it, apyyamldependency in CI, two extra workflow steps, and 55 of the pin checker's 317
lines. Every defect iteration 5 found — the renamed hook
id, the stale# frozen:version, the mutable tag, the duplicate repo block — lived in thatlayer. None of them existed in the tox path.
It also created two independent definitions of "the set of files we format":
black's directory walk, which honours
.gitignore, and pre-commit's explicitlist of tracked files, which does not. They agree at 350 files today. Keeping
them in agreement needed its own CI step.
The one thing pre-commit was really protecting. Removing it would have
silently re-opened the original bug. black skips gitignored paths while walking
directories, so a file that is tracked and gitignored is never checked and
tox -e lintstill reports success — that is exactly howmodels.pyandstatic_test_data.pyshipped unformatted.pre-commit run --all-filescoveredthat hole incidentally, by passing tracked filenames explicitly.
Rather than keep a tool for its side effect, the workflow now asks git directly:
This is stronger than the cross-check it replaces: it names the failing files
instead of inferring the problem from a formatting diff, needs no black, no pins
and no second config file, and fails closed if git errors.
sample_code/isexcluded by prefix — those fixtures are byte-compared against VCR cassettes and
are already
force-excluded inpyproject.toml— so adding a legitimate fixturewill not break CI while a genuinely hidden module still will. That one path is
now duplicated between
pyproject.tomland the workflow; the comment says so.Verified, running the step's shell extracted verbatim from the YAML:
.gitignorenegations removedmodels.pyandstatic_test_data.py.pyhidden in an unrelated directoryKnock-on changes.
check_formatter_pins.pydrops to two files, 317 → 242lines, and no longer imports
yaml, so the explicitpip install pyyamlisgone. Re-validated on fresh extracts against 21 adversarial cases — 7 valid
variations exit 0, 14 broken ones exit 1 — including a lint-vs-format
divergence inside
tox.toml, which the error message now names precisely.force-excludestays inpyproject.toml, but the comment now gives the reasonthat survives: it is what makes exclusions apply to explicitly-passed paths,
which is how editor format-on-save invokes black.
CONTRIBUTING.mdpointscontributors at format-on-save and states plainly that there is no committed git
hook.
What is genuinely lost. Unformatted code can now reach a push and fail in
CI rather than being caught locally at commit time. That is a real regression in
feedback latency, and it is the deliberate trade:
.vscode/settings.jsonalready gives format-on-save with
importStrategy: fromEnvironmentfor bothtools, so anyone using the repo's recommended setup still gets local
correction — and
tox -e formatis one command away for anyone who is not.If you would rather keep the hook, say so and I will restore it — but restoring
it unpinned and unchecked is worse than either extreme, because a stale local
black then fights CI in a loop.