diff --git a/README.md b/README.md index 60e144fb..416c6158 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 45dff823..57856c43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_build_hook.py b/tests/test_build_hook.py index 2cdcb889..18f71376 100644 --- a/tests/test_build_hook.py +++ b/tests/test_build_hook.py @@ -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 @@ -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") + + dist = project / "web" / "frontend" / "dist" + dist.mkdir(parents=True) + (dist / "index.html").write_text("app", 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"]