Skip to content

fix(AI-3750): stop duplicate _ui_dist wheel entry on VCS-less source builds - #623

Open
Matovidlo wants to merge 3 commits into
mainfrom
martinvasko-ai-3750-ai-kit-docs-misroute-kbagent-install-points-at-old-kbc-cli
Open

fix(AI-3750): stop duplicate _ui_dist wheel entry on VCS-less source builds#623
Matovidlo wants to merge 3 commits into
mainfrom
martinvasko-ai-3750-ai-kit-docs-misroute-kbagent-install-points-at-old-kbc-cli

Conversation

@Matovidlo

Copy link
Copy Markdown
Contributor

Summary

  • Fixes a wheel-build crash ("A second file is being added to the wheel archive at the same path") hit when installing kbagent via uv tool install git+https://github.com/keboola/cli (the install.sh fallback path) on a source tree with no .git directory.
  • Root cause: force-include's de-dup with hatchling's default package globbing for _ui_dist/ relies on .gitignore-based exclusion, which needs a .git dir to run git check-ignore against. Without one, the (gitignored) _ui_dist/ the build hook just populated gets picked up by both the default globbing and force-include → duplicate archive entry → build aborts.
  • Fix: add an explicit exclude = ["src/keboola_agent_cli/_ui_dist"] under [tool.hatch.build.targets.wheel] — a plain glob evaluated unconditionally, independent of VCS state, so force-include is the only path that adds it.
  • Adds an end-to-end regression test (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.
  • Documents the previously-undocumented Python >=3.12 install floor in README's Install section, with the UV_PYTHON=3.12 workaround for when uv doesn'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 separate keboola/ai-kit repo — nothing in this repo references it, so there's nothing to fix here for that part.

Test plan

  • Reproduced the exact reported error locally: cloned the repo, stripped .git, populated a fake SPA dist, ran uv build --wheel → same ValueError: A second file is being added to the wheel archive at the same path traceback.
  • Applied the exclude fix, reran the same reproduction → build succeeds; confirmed via zipfile inspection that _ui_dist/index.html and py.typed are each present exactly once.
  • Sanity-built the wheel in the real repo (with .git present, both with and without a prebuilt SPA dist) → no regression.
  • New test TestForceIncludeNoDuplicate::test_wheel_builds_without_git_directory added; confirmed it fails without the pyproject.toml fix and passes with it (git-stash round-trip).
  • ruff check / ruff format --check clean on changed files.
  • Full test suite (uv run pytest tests/ -m "not e2e", 5.6k+ tests) passes.

Related issues

AI-3750

🤖 Generated with Claude Code

…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>
@linear-code

linear-code Bot commented Aug 20, 2026

Copy link
Copy Markdown

AI-3750

@Matovidlo

Copy link
Copy Markdown
Contributor Author

@claude review

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 excluding src/keboola_agent_cli/_ui_dist from default wheel collection while keeping force-include as 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.html appears exactly once.
  • Document the Python >= 3.12 requirement (and UV_PYTHON=3.12 workaround) 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.

Comment thread tests/test_build_hook.py
@Matovidlo

Copy link
Copy Markdown
Contributor Author

Closing -- scope was wrong for this issue; redoing the actual fix (ai-kit docs cross-references) in keboola/ai-kit instead.

@Matovidlo Matovidlo closed this Aug 20, 2026
@Matovidlo Matovidlo reopened this Aug 20, 2026
@Matovidlo
Matovidlo requested a review from MiroCillik August 20, 2026 09:44
@Matovidlo
Matovidlo marked this pull request as ready for review August 20, 2026 09:44
@Matovidlo
Matovidlo requested a review from soustruh August 20, 2026 09:44
Comment thread tests/test_build_hook.py Outdated
Comment thread tests/test_build_hook.py Outdated
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 MiroCillik left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread README.md Outdated

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}'
unset

So 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 | sh

Worth noting the neighbouring KBAGENT_NO_SERVER sentence says "set", not "prefix", and is fine as written.

Comment thread pyproject.toml Outdated
# ``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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. A git+ VCS install is not the failing shape. uv clones tracked files, and .gitignore is tracked — so the exclusion works fine there. Verified empirically with the exclude line removed: a tree with .gitignore and no .git builds successfully; the duplicate only appears when no .gitignore is reachable at all. The path that actually blew up is the sdist: [tool.hatch.build.targets.sdist].include omits .gitignore, so pip install <sdist> / uv build (sdist→wheel) is where the exclusion genuinely can't run.
  2. .git is 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.

Comment thread tests/test_build_hook.py Outdated
dist.mkdir(parents=True)
(dist / "index.html").write_text("<html>app</html>", encoding="utf-8")

assert not (project / ".git").exists()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread tests/test_build_hook.py
"""

def test_wheel_builds_without_git_directory(self, tmp_path: Path) -> None:
if shutil.which("uv") is None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 without uv — a hard failure, or at least a -W/marker, would be louder.
  • timeout=120 is tight for a cold-cache build environment on a Windows runner, and the full Windows suite step does run this test.

@Matovidlo
Matovidlo requested a review from MiroCillik August 21, 2026 13:50
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants