fix(AI-3750): stop duplicate _ui_dist wheel entry on VCS-less source builds - #623
Conversation
…builds hatchling's force-include for _ui_dist/ relies on .gitignore-based exclusion to avoid double-adding the path, but that exclusion needs a .git directory to run `git check-ignore` against. A git+ install that hands hatchling a plain exported tree (no .git) skips it, so _ui_dist/index.html gets added twice and the build aborts. Add an explicit wheel-target exclude so it works regardless of VCS state, and document the undocumented Python >=3.12 floor in the README install instructions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
@claude review |
There was a problem hiding this comment.
Pull request overview
This PR fixes a wheel-build failure that occurs when building from a VCS-less exported source tree (no .git directory), where src/keboola_agent_cli/_ui_dist/ could be collected twice (default package collection + force-include) and abort the build with a duplicate-archive-path error. It also adds a regression test to exercise that no-.git build path and documents the project’s Python version floor in the install instructions.
Changes:
- Prevent duplicate
_ui_dist/inclusion by explicitly excludingsrc/keboola_agent_cli/_ui_distfrom default wheel collection while keepingforce-includeas the single inclusion path. - Add an end-to-end test that builds a wheel in a
.git-less temporary project and asserts_ui_dist/index.htmlappears exactly once. - Document the Python >= 3.12 requirement (and
UV_PYTHON=3.12workaround) in the README install section.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
pyproject.toml |
Adds an explicit wheel exclude to avoid duplicate _ui_dist/ entries in VCS-less builds while retaining force-include. |
tests/test_build_hook.py |
Adds an end-to-end regression test that builds a wheel without a .git directory and checks _ui_dist isn’t duplicated. |
README.md |
Documents Python >= 3.12 install requirement and a uv interpreter selection workaround. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
Closing -- scope was wrong for this issue; redoing the actual fix (ai-kit docs cross-references) in keboola/ai-kit instead. |
Miro flagged that AI-3750 references in pyproject.toml/test docstrings aren't publicly accessible; the technical description stands on its own without them.
MiroCillik
left a comment
There was a problem hiding this comment.
Reviewed at 557ad04. The fix itself is sound and load-bearing — I reproduced the original failure by stripping the new exclude line and building the same fixture tree (ValueError: A second file is being added to the wheel archive at the same path: keboola_agent_cli/_ui_dist/index.html), and confirmed against hatchling 1.32 source that recurse_forced_files() applies no include/exclude filtering, so the new exclude cannot suppress the force-include. Also confirmed packages being set short-circuits default_file_selection_options, so exclude_spec's eager self.default_exclude() call has no side effect. The new test is a genuine regression test.
Four findings below — one is a docs bug that makes the documented workaround inert, two are about the root cause being misattributed (which also makes the regression test able to pass vacuously), one is test hygiene. Nothing blocking the one-line fix.
Thanks for dropping the AI-3750 refs in 557ad04 — that was on my list and it's already handled.
|
|
||
| This installs a **prebuilt wheel** from the latest GitHub release -- a few-seconds download, no source build. Building from `git+` instead recompiles the bundled React SPA via npm on every install, which takes minutes on WSL ([#353](https://github.com/keboola/cli/issues/353)). The script bundles the `[server]` extras by default (set `KBAGENT_NO_SERVER=1` for a CLI-only install) and needs only `curl` + [`uv`](https://docs.astral.sh/uv/). | ||
|
|
||
| Requires **Python >=3.12**. `uv` normally fetches a matching interpreter on its own even if your default Python is older, but if it doesn't (offline, or Python downloads disabled) the install fails with `does not satisfy Python>=3.12` -- prefix the command with `UV_PYTHON=3.12` (a standard `uv` env var, works with the `curl | sh` one-liner too) to pin one explicitly. |
There was a problem hiding this comment.
The documented workaround doesn't work with the one-liner it claims to work with.
UV_PYTHON=3.12 curl -LsSf … | sh puts the assignment on curl, not on sh, so neither the script nor the uv it invokes ever sees UV_PYTHON. Verified:
$ FOO=bar echo hi | sh -c 'echo ${FOO:-unset}'
unsetSo a user who hits does not satisfy Python>=3.12, follows this instruction literally, gets the exact same failure with no clue why. The parenthetical "(works with the curl | sh one-liner too)" is attached to the wrong half of the sentence — UV_PYTHON is indeed a standard uv env var, but prefixing the command is what doesn't carry through the pipe.
Either of these works:
curl -LsSf https://raw.githubusercontent.com/keboola/cli/main/install.sh | UV_PYTHON=3.12 sh
# or
export UV_PYTHON=3.12
curl -LsSf https://raw.githubusercontent.com/keboola/cli/main/install.sh | shWorth noting the neighbouring KBAGENT_NO_SERVER sentence says "set", not "prefix", and is fine as written.
| # ``force-include`` (below) is meant to be the ONLY way ``_ui_dist/`` enters | ||
| # the wheel. Hatchling normally also skips it via .gitignore-based exclusion, | ||
| # but that exclusion depends on a ``.git`` directory being present to run | ||
| # `git check-ignore` against -- absent one (e.g. a VCS-url install that hands |
There was a problem hiding this comment.
The root cause is misattributed — the stated trigger isn't the one that fails.
Hatchling never runs git check-ignore. BuilderConfig.load_vcs_exclusion_patterns() parses the .gitignore file located by locate_file(root, ".gitignore", boundary=".git") and feeds the patterns to pathspec. Two consequences:
- A
git+VCS install is not the failing shape. uv clones tracked files, and.gitignoreis tracked — so the exclusion works fine there. Verified empirically with theexcludeline removed: a tree with.gitignoreand no.gitbuilds successfully; the duplicate only appears when no.gitignoreis reachable at all. The path that actually blew up is the sdist:[tool.hatch.build.targets.sdist].includeomits.gitignore, sopip install <sdist>/uv build(sdist→wheel) is where the exclusion genuinely can't run. .gitis a boundary that stops the upward search, not a prerequisite for the exclusion — close to the inverse of what the comment says.
The fix is right either way; it's the explanation that will mislead whoever next touches the build config or the sdist include list. Same misattribution is repeated in the module docstring and class docstring of tests/test_build_hook.py, so all three want the same correction.
| dist.mkdir(parents=True) | ||
| (dist / "index.html").write_text("<html>app</html>", encoding="utf-8") | ||
|
|
||
| assert not (project / ".git").exists() |
There was a problem hiding this comment.
This guards the wrong invariant, which leaves the test able to pass vacuously.
Because locate_file walks upward until it finds a .gitignore or hits a .git boundary, and this fixture creates neither, hatchling searches every ancestor of tmp_path. Verified: with the exclude fix removed and a .gitignore placed one directory above the fixture project, the build succeeds — i.e. under a TMPDIR that sits anywhere beneath a .gitignore (a dev with TMPDIR inside a checkout), this regression test silently stops testing the regression.
Creating an empty .git directory in the fixture is what actually forces the no-exclusion state (it's the boundary that halts the search), and asserting the project has no .gitignore of its own pins the rest:
(project / ".git").mkdir() # boundary: stops the upward .gitignore search
assert not (project / ".gitignore").exists()That also makes the test match the real mechanism described in the docstring.
| """ | ||
|
|
||
| def test_wheel_builds_without_git_directory(self, tmp_path: Path) -> None: | ||
| if shutil.which("uv") is None: |
There was a problem hiding this comment.
Minor: the test inherits build-affecting env vars and fails confusingly under KBAGENT_SKIP_UI_BUILD=1.
That's a documented, CI-used knob (.github/workflows/ci.yml:243). With it exported, _bundle_ui creates an empty _ui_dist/, the wheel legitimately contains no index.html, and the closing assert ui_entries == [...] fails on an otherwise-green build with a message pointing at duplication rather than at the env var. The module already imports the constant's home, so:
monkeypatch.delenv(SKIP_UI_BUILD_ENV, raising=False)removes the trap.
Two smaller notes on the same test:
pytest.skip("uv not on PATH")means the only coverage of this bug can vanish silently in an image withoutuv— a hard failure, or at least a-W/marker, would be louder.timeout=120is tight for a cold-cache build environment on a Windows runner, and the full Windows suite step does run this test.
…d regression test Follow-up on review of #623. Four issues, none in the one-line `exclude` fix itself (which is load-bearing and verified). - README: `UV_PYTHON=3.12 curl ... | sh` assigns the var to `curl`, not `sh`, so neither the script nor the `uv` it invokes ever saw it -- the documented workaround for `does not satisfy Python>=3.12` was inert. Show the `| UV_PYTHON=3.12 sh` and `export` forms instead. - pyproject/test docstrings: the root cause was misattributed. Hatchling never runs `git check-ignore`; it parses the `.gitignore` *file* found by `locate_file(root, ".gitignore", boundary=".git")`, where `.git` is the boundary that STOPS the upward search rather than a prerequisite. A `git+` install ships a tracked `.gitignore` and excludes fine; the shape that actually failed is the sdist, whose `include` list omits it. - The regression test asserted "no `.git`", which guards the wrong invariant: with neither `.git` nor `.gitignore` in the fixture, hatchling searched every ancestor of `tmp_path`, so a `TMPDIR` under any checkout let an ancestor `.gitignore` supply the exclusion and the test passed with the fix reverted. Create an empty `.git` (the boundary) and assert no local `.gitignore`. Verified: reverting `exclude` now fails both with a normal TMPDIR and with TMPDIR beneath an ancestor `.gitignore`. - The module inherited `KBAGENT_SKIP_UI_BUILD` (exported in parts of CI), which makes `_bundle_ui` ship an empty `_ui_dist/`; 5 tests then failed on a green build with messages pointing elsewhere. Clear it in an autouse fixture. Also raise the wheel-build timeout to 300s for cold-cache Windows runners. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
uv tool install git+https://github.com/keboola/cli(theinstall.shfallback path) on a source tree with no.gitdirectory.force-include's de-dup with hatchling's default package globbing for_ui_dist/relies on.gitignore-based exclusion, which needs a.gitdir to rungit check-ignoreagainst. Without one, the (gitignored)_ui_dist/the build hook just populated gets picked up by both the default globbing andforce-include→ duplicate archive entry → build aborts.exclude = ["src/keboola_agent_cli/_ui_dist"]under[tool.hatch.build.targets.wheel]— a plain glob evaluated unconditionally, independent of VCS state, soforce-includeis the only path that adds it.tests/test_build_hook.py::TestForceIncludeNoDuplicate) that builds a real wheel in a.git-less tree; verified it fails on the pre-fix config and passes on the post-fix one.UV_PYTHON=3.12workaround for whenuvdoesn't auto-fetch a matching interpreter.Filed against AI-3750. Note: this PR covers only Defects 2 & 3 from that issue (the wheel-build crash and the undocumented Python floor). Defect 1 (ai-kit's docs pointing at the wrong CLI /
developers.keboola.com/cli) lives entirely in the separatekeboola/ai-kitrepo — nothing in this repo references it, so there's nothing to fix here for that part.Test plan
.git, populated a fake SPA dist, ranuv build --wheel→ sameValueError: A second file is being added to the wheel archive at the same pathtraceback.excludefix, reran the same reproduction → build succeeds; confirmed viazipfileinspection that_ui_dist/index.htmlandpy.typedare each present exactly once..gitpresent, both with and without a prebuilt SPA dist) → no regression.TestForceIncludeNoDuplicate::test_wheel_builds_without_git_directoryadded; confirmed it fails without thepyproject.tomlfix and passes with it (git-stash round-trip).ruff check/ruff format --checkclean on changed files.uv run pytest tests/ -m "not e2e", 5.6k+ tests) passes.Related issues
AI-3750
🤖 Generated with Claude Code