Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ curl -LsSf https://raw.githubusercontent.com/keboola/cli/main/install.sh | sh

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.

Prefer to build from source, or pin a specific ref?

```bash
Expand Down
14 changes: 11 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,17 @@ packages = ["src/keboola_agent_cli"]
# ``uv tool install git+...`` or ``pip install`` from PyPI without the
# user needing the ``web/`` source tree.
#
# ``force-include`` overrides the default ``.gitignore`` exclusion so the
# generated dist actually lands in the wheel. Key on the LHS is the path
# on disk; value on the RHS is the path inside the wheel.
# ``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
# hatchling a plain exported source tree, no `.git`), the default package
# globbing is not excluded and picks up `_ui_dist/` too, colliding with
# force-include and aborting the build with "A second file is being added to
# the wheel archive at the same path" (issue AI-3750). `exclude` here is a
# plain glob, evaluated unconditionally regardless of VCS state, so the
# duplicate can't happen either way.
exclude = ["src/keboola_agent_cli/_ui_dist"]

[tool.hatch.build.targets.wheel.force-include]
"src/keboola_agent_cli/_ui_dist" = "keboola_agent_cli/_ui_dist"
Expand Down
61 changes: 58 additions & 3 deletions tests/test_build_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,22 @@
any OS.
- **Bug 2** -- an early ``return`` left ``_ui_dist/`` missing, and hatchling's
``force-include`` then failed the whole build. We assert every code path
leaves ``_ui_dist/`` existing on disk. (The force-include interaction itself
is OS-independent and is additionally exercised end-to-end by the CI wheel
build.)
leaves ``_ui_dist/`` existing on disk.

Also covers issue AI-3750: the CI wheel build always runs inside a real `git`
checkout, so it never exercised the no-``.git`` case (a VCS-url install that
hands hatchling a plain exported source tree). Without a ``.git`` dir,
hatchling's gitignore-based default-exclusion can't run, so the (gitignored)
``_ui_dist/`` the hook just populated got picked up by BOTH the default
package globbing and ``force-include``, and the wheel build aborted with "A
second file is being added to the wheel archive at the same path". See
``TestForceIncludeNoDuplicate`` below for the end-to-end regression test.
"""

from __future__ import annotations

import importlib.util
import shutil
import subprocess
import sys
import zipfile
Expand Down Expand Up @@ -289,3 +297,50 @@ def test_no_ui_fails_on_ui_wheel(self, tmp_path: Path) -> None:

def test_missing_wheel_is_an_error(self, tmp_path: Path) -> None:
assert check_wheel_ui.main(["--expect-ui", "--dist", str(tmp_path)]) == 1


class TestForceIncludeNoDuplicate:
"""AI-3750: building without a ``.git`` dir must not duplicate ``_ui_dist/``.

Reproduces the real failure end-to-end (not mocked): a minimal project
laid out with the actual ``pyproject.toml`` / ``hatch_build.py``, a
prebuilt SPA dist on disk (so the hook populates ``_ui_dist/`` with a real
file), and deliberately NO ``.git`` directory -- the exact shape of a
VCS-url install where the build backend never sees repo history.
"""

def test_wheel_builds_without_git_directory(self, tmp_path: Path) -> None:
if shutil.which("uv") is None:
pytest.skip("uv not on PATH")

repo_root = Path(__file__).resolve().parents[1]
project = tmp_path / "project"
(project / "src" / "keboola_agent_cli").mkdir(parents=True)
(project / "src" / "keboola_agent_cli" / "__init__.py").write_text("", encoding="utf-8")
(project / "src" / "keboola_agent_cli" / "py.typed").write_text("", encoding="utf-8")
(project / "scripts").mkdir()
shutil.copy(
repo_root / "scripts" / "hatch_build.py", project / "scripts" / "hatch_build.py"
)
shutil.copy(repo_root / "pyproject.toml", project / "pyproject.toml")
(project / "README.md").write_text("test project", encoding="utf-8")
Comment thread
Matovidlo marked this conversation as resolved.

dist = project / "web" / "frontend" / "dist"
dist.mkdir(parents=True)
(dist / "index.html").write_text("<html>app</html>", encoding="utf-8")

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

result = subprocess.run(
["uv", "build", "--wheel", "-o", str(tmp_path / "out")],
cwd=project,
capture_output=True,
text=True,
timeout=120,
)
assert result.returncode == 0, result.stderr

(wheel,) = list((tmp_path / "out").glob("*.whl"))
with zipfile.ZipFile(wheel) as zf:
ui_entries = [n for n in zf.namelist() if n.endswith("_ui_dist/index.html")]
assert ui_entries == ["keboola_agent_cli/_ui_dist/index.html"]
Loading