From f8a33bac627ba97dfbf89bbe301138e50418dd95 Mon Sep 17 00:00:00 2001 From: Roman PASSLER Date: Thu, 24 Sep 2026 09:12:51 +0200 Subject: [PATCH 1/2] fix(encoding): decode git's file names as Python does, and write LF km decoded git's output with the system's preferred encoding. On Windows that is a code page (cp1252), while git prints file names as UTF-8, so a tracked file with a non-ASCII name came back mangled and named no file: - km validate over the whole repo stopped with FileNotFoundError before checking anything; - km gen-index, demote and serve, which skip names that are not files, silently left the doc out: gen-index said "up to date" while the doc was missing from its list. Every git call now goes through one helper, paths.git(), that decodes the output with the file-system encoding, the way Python turns file names into text: UTF-8 on Windows and macOS, the locale's encoding on Linux, so each listed name opens its file on all three. Bytes that do not decode are kept (surrogateescape) instead of crashing: git writes its messages in the locale, which crashed km outside a repo. validate lists files through common.tracked_md() instead of three copies of the git call, and git lists them once per run; that also stops a tracked doc deleted but not yet staged from crashing validate. promote reads its child validator as UTF-8, and km's console output escapes a character the console encoding lacks instead of crashing. The same platform wrote CRLF: every file km writes now goes through paths.write_text(), UTF-8 with LF, so a generated _index.md no longer shows as changed on Windows. Tests run km with EncodingWarning as an error, so any call that relies on the default encoding fails them. tests/encoding_smoke.sh builds a brain with an u-umlaut and an o with double acute in its file names and checks that gen-index lists them and writes LF, that validate reports them, and that the work tree stays as committed. A new Windows CI job runs it, and the release waits for that job; team_smoke runs it under every macOS locale that behaves like Windows. On Linux a non-UTF-8 locale changes the file-name encoding too, which Windows never does: the smoke job generates a Latin-1 locale, and a test checks that a non-ASCII name is still validated there. Decoding as UTF-8 regardless of platform would have failed it. A further test checks a git error in a Latin-1 locale. --- .github/workflows/test.yml | 27 +++++++++++++++++++++++++-- README.md | 3 ++- src/km/cli.py | 3 +++ src/km/common.py | 19 +++++++++++++------ src/km/demote.py | 3 ++- src/km/gen_index.py | 3 ++- src/km/init.py | 4 ++-- src/km/paths.py | 18 +++++++++++++++++- src/km/pins.py | 7 ++++--- src/km/promote.py | 9 ++++++--- src/km/serve.py | 7 +++---- src/km/upgrade.py | 8 ++++---- src/km/validate.py | 30 ++++++------------------------ tests/encoding_smoke.sh | 30 ++++++++++++++++++++++++++++++ tests/lib.sh | 19 ++++++++++++++++--- tests/team_smoke.sh | 29 +++++++++++++++++++++++++++++ 16 files changed, 164 insertions(+), 55 deletions(-) create mode 100644 tests/encoding_smoke.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b1a8516..537c2a9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,7 +25,8 @@ jobs: KM_REPO_URL="file://$GITHUB_WORKSPACE" /tmp/wheel/bin/km init /tmp/wheel-brain --team --name W --desc w --initials WW --folder d=D - run: bash tests/gate_smoke.sh - run: bash tests/index_smoke.sh - - run: bash tests/team_smoke.sh + - name: team_smoke.sh, with a Latin-1 locale for its file-name encoding test + run: sudo localedef -i en_US -f ISO-8859-1 en_US.ISO-8859-1 && bash tests/team_smoke.sh # The composite action a brain's CI uses: install km from this checkout, then build and check a # team brain with the installed `km` command. @@ -58,11 +59,33 @@ jobs: path: ${{ runner.temp }}/b/dist/served/ if-no-files-found: error + # Windows: its default encoding is a code page (cp1252), not UTF-8, and text files are written with + # CRLF. A file name with an umlaut must still be found, and km must write the LF the repo holds + # (tests/encoding_smoke.sh). + windows: + runs-on: windows-latest + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.11' + - run: python -m pip install --quiet --disable-pip-version-check pyyaml==6.0.3 + - name: non-ASCII file names and LF writes + env: + PYTHON: python + run: | + source tests/lib.sh + windows_like "" || { echo "::error::the runner's encoding is UTF-8, this job tests nothing"; exit 1; } + with_env "" bash tests/encoding_smoke.sh # as checked: UTF-8 mode off + # Merging a version bump is the release: once main is green, tag v<__version__> if that tag is new. # Brains pin these tags, and `km init` writes the tag of the km that runs it. release: if: github.event_name == 'push' - needs: [smoke, action] + needs: [smoke, action, windows] runs-on: ubuntu-latest permissions: contents: write diff --git a/README.md b/README.md index 799f323..73a6211 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,8 @@ tests/ smoke tests ## Development The smoke tests need Python 3.11+, PyYAML and git: `bash tests/gate_smoke.sh`, `index_smoke.sh`, -`team_smoke.sh`. CI runs all three plus the GitHub Action on every pull request. A release bumps +`team_smoke.sh`. CI runs all three plus the GitHub Action on every pull request, and +`encoding_smoke.sh` on Windows (team_smoke runs it on macOS under non-UTF-8 locales). A release bumps `__version__` in `src/km/__init__.py`, `version` in `.claude-plugin/plugin.json` and the version in this README together; merging that to main is the release, CI tags `vX.Y.Z` once main is green. diff --git a/src/km/cli.py b/src/km/cli.py index 2633ee6..4014c4e 100644 --- a/src/km/cli.py +++ b/src/km/cli.py @@ -87,6 +87,9 @@ def run_pinned(root: Path, cmd: str, rest: list[str]) -> int | None: def main(argv: list[str] | None = None) -> int: + for stream in (sys.stdout, sys.stderr): # a non-UTF-8 console or pipe (Windows) must not crash on a file name + if hasattr(stream, "reconfigure"): + stream.reconfigure(errors="backslashreplace") root, args = take_root(list(sys.argv[1:] if argv is None else argv)) if root is not None and not Path(root).is_dir(): print(f"km: --root {root}: no such directory", file=sys.stderr) diff --git a/src/km/common.py b/src/km/common.py index d0e7eef..b1f6bd3 100644 --- a/src/km/common.py +++ b/src/km/common.py @@ -5,14 +5,14 @@ """ from __future__ import annotations +import functools import re -import subprocess import sys from pathlib import Path import yaml -from km.paths import BASE_SCHEMA, repo_root +from km.paths import BASE_SCHEMA, git, repo_root ROOT = repo_root() @@ -80,11 +80,18 @@ def read(rel: str) -> str: return (ROOT / rel).read_text(encoding="utf-8-sig", errors="replace") -def tracked_md() -> list[str]: - res = subprocess.run(["git", "ls-files", "-z", "*.md"], cwd=ROOT, capture_output=True, text=True) +@functools.cache +def _ls_md() -> tuple[str, ...]: + res = git("ls-files", "-z", "*.md", cwd=ROOT) if res.returncode != 0: - sys.exit(f"git ls-files failed: {res.stderr.strip()}") - return [p for p in res.stdout.split("\x00") if p and (ROOT / p).is_file()] # a deletion not yet staged is gone + sys.exit(f"git ls-files failed (not a git repo?): {res.stderr.strip()}") + return tuple(p for p in res.stdout.split("\x00") if p) + + +def tracked_md(existing: bool = True) -> list[str]: + """The tracked .md files, listed by git once per run; with existing=False also those deleted but + not yet staged.""" + return [p for p in _ls_md() if not existing or (ROOT / p).is_file()] def is_article(rel: str) -> bool: diff --git a/src/km/demote.py b/src/km/demote.py index 6d7e3b5..3002fde 100644 --- a/src/km/demote.py +++ b/src/km/demote.py @@ -13,6 +13,7 @@ import sys from km.common import ROOT, SERVED_STATUS, is_article, read, split_frontmatter, tracked_md +from km.paths import write_text TODAY = datetime.date.today() @@ -49,7 +50,7 @@ def main() -> int: continue stale.append(rel) new_fm = re.sub(r"(?m)^approved_(?:by|at):.*\n", "", new_fm) - (ROOT / rel).write_text(new_fm + body, encoding="utf-8") + write_text(ROOT / rel, new_fm + body) if stale: print(f"km demote: {'demoted' if apply else 'would demote'} {len(stale)} doc(s) past review_by:") diff --git a/src/km/gen_index.py b/src/km/gen_index.py index 121fa07..939f2e5 100644 --- a/src/km/gen_index.py +++ b/src/km/gen_index.py @@ -19,6 +19,7 @@ from pathlib import Path from km.common import INDEX_SKIP, ROOT, SKIP, folder_of, frontmatter, is_article, read, tracked_md +from km.paths import write_text _MARK = re.compile(r"(?m)^## Documents[ \t]*$") _NEXT_H2 = re.compile(r"(?m)^## ") @@ -79,7 +80,7 @@ def main() -> int: if new != cur: changed.append(idx) if not check: - (ROOT / idx).write_text(new, encoding="utf-8") + write_text(ROOT / idx, new) if check and changed: print("km gen-index: out of date, run `km gen-index`:") diff --git a/src/km/init.py b/src/km/init.py index be879bc..476bdfd 100644 --- a/src/km/init.py +++ b/src/km/init.py @@ -23,7 +23,7 @@ from pathlib import Path from km import KM_REF -from km.paths import TEMPLATES +from km.paths import TEMPLATES, write_text from km.pins import resolve PERSONAL = { # template -> path in the brain (repo-owned after init) @@ -130,7 +130,7 @@ def main() -> int: sys.exit(f"placeholder(s) left after rendering: {', '.join(left)}") for dst, text in rendered.items(): (repo / dst).parent.mkdir(parents=True, exist_ok=True) - (repo / dst).write_text(text, encoding="utf-8") + write_text(repo / dst, text) print(f"km init: {'team' if a.team else 'personal'} brain in {repo}, pinned to km {a.pin}") return 0 diff --git a/src/km/paths.py b/src/km/paths.py index c5018aa..57ce1d2 100644 --- a/src/km/paths.py +++ b/src/km/paths.py @@ -3,6 +3,7 @@ import os import subprocess +import sys from pathlib import Path PACKAGE = Path(__file__).resolve().parent @@ -10,11 +11,26 @@ TEMPLATES = PACKAGE / "templates" +def git(*args: str, cwd: Path | None = None) -> subprocess.CompletedProcess[str]: + """git, with its output decoded the way Python decodes file names, so each name it lists opens + the file it names: UTF-8 on Windows and macOS, the locale's encoding on Linux. With the system's + code page instead (cp1252 on Windows), a name with an umlaut named no file. Bytes that do not + decode (a git message in another encoding, an odd name) are kept, not a crash.""" + return subprocess.run(["git", *args], cwd=cwd, capture_output=True, + encoding=sys.getfilesystemencoding(), errors="surrogateescape") + + +def write_text(path: Path, text: str) -> None: + """Every file km writes: UTF-8 with LF, the bytes the repo holds, also where the default is a code + page and CRLF (Windows).""" + path.write_text(text, encoding="utf-8", newline="\n") + + def repo_root() -> Path: """The brain km works on: KM_ROOT if set (the CLI sets it from --root), else the git work tree containing the current directory, else the current directory.""" env = os.environ.get("KM_ROOT") if env: return Path(env).resolve() - res = subprocess.run(["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True) + res = git("rev-parse", "--show-toplevel") return Path(res.stdout.strip()).resolve() if res.returncode == 0 else Path.cwd().resolve() diff --git a/src/km/pins.py b/src/km/pins.py index 4a1c993..ff5095d 100644 --- a/src/km/pins.py +++ b/src/km/pins.py @@ -11,10 +11,11 @@ import os import re -import subprocess from dataclasses import dataclass from pathlib import Path +from km.paths import git, write_text + KM_REPO = "https://github.com/pasrom/km" SHA = re.compile(r"[0-9a-f]{40}") VERSION = r"v\d[\w.\-]*" @@ -113,7 +114,7 @@ def set_pins(root: Path, sha: str, version: str) -> list[str]: else: new = PIN.sub(lambda m: m.group(1) + uses_ref(sha, version), text) if new != text: - p.write_text(new, encoding="utf-8") + write_text(p, new) changed.append(str(p.relative_to(root))) return changed @@ -132,7 +133,7 @@ def resolve(version: str) -> str | None: """The commit a km release tag points at; None when the tag does not exist or the repository cannot be reached.""" tag = f"refs/tags/{version}" - out = subprocess.run(["git", "ls-remote", repo_url(), tag, tag + "^{}"], capture_output=True, text=True).stdout + out = git("ls-remote", repo_url(), tag, tag + "^{}").stdout refs = {name: sha for sha, _, name in (line.partition("\t") for line in out.splitlines())} return refs.get(tag + "^{}") or refs.get(tag) # an annotated tag: the commit it points at diff --git a/src/km/promote.py b/src/km/promote.py index 5514ae6..97113bf 100644 --- a/src/km/promote.py +++ b/src/km/promote.py @@ -35,6 +35,7 @@ import argparse import datetime +import os import re import subprocess import sys @@ -43,6 +44,7 @@ import yaml from km.common import ROOT, SCHEMA +from km.paths import write_text def submodule_prefixes() -> set[str]: @@ -96,7 +98,8 @@ def type_enum() -> set[str] | None: def run_gate(target: Path): """`km validate` on one file, in a child process: the validator runs its checks at import.""" return subprocess.run([sys.executable, "-m", "km", "validate", "--root", str(ROOT), str(target)], - cwd=ROOT, capture_output=True, text=True) + cwd=ROOT, capture_output=True, encoding="utf-8", errors="replace", + env={**os.environ, "PYTHONIOENCODING": "utf-8"}) # the child writes UTF-8 too _PROVENANCE = ("status", "approved_by", "approved_at", "supersedes", "superseded_by", "contribution") @@ -137,7 +140,7 @@ def _stub_source(src: Path, target_rel: str, prefixes: set[str], src_fm: dict, i stub["superseded_by"] = target_rel tmp = src.parent / (src.stem + ".stub-tmp.md") try: - tmp.write_text(dump(stub, f"Moved to the served brain. See [{target_rel}]({target_rel})."), encoding="utf-8") + write_text(tmp, dump(stub, f"Moved to the served brain. See [{target_rel}]({target_rel}).")) r = run_gate(tmp) if r.returncode != 0: tmp.unlink(missing_ok=True) @@ -364,7 +367,7 @@ def main() -> int: content = dump(fm, body) target.parent.mkdir(parents=True, exist_ok=True) tmp = target.parent / (target.stem + ".promote-tmp.md") - tmp.write_text(content, encoding="utf-8") + write_text(tmp, content) r = run_gate(tmp) if r.returncode != 0: tmp.unlink(missing_ok=True) diff --git a/src/km/serve.py b/src/km/serve.py index 8b35459..9f8eda3 100644 --- a/src/km/serve.py +++ b/src/km/serve.py @@ -17,15 +17,14 @@ from pathlib import Path from km.common import CUSTOMER_AUDIENCE, ROOT, SERVED_STATUS, is_article, read, split_frontmatter, tracked_md +from km.paths import write_text OUT = ROOT / "dist" / "served" ALLOWED_AUDIENCE = {"internal", CUSTOMER_AUDIENCE} def write_jsonl(path: Path, records: list[dict]) -> None: - with path.open("w", encoding="utf-8") as fh: - for r in records: - fh.write(json.dumps(r, ensure_ascii=False, sort_keys=True) + "\n") + write_text(path, "".join(json.dumps(r, ensure_ascii=False, sort_keys=True) + "\n" for r in records)) def main() -> int: @@ -63,7 +62,7 @@ def main() -> int: "counts": {"internal": len(accepted), "customer": len(customer)}, "skipped_bad_audience": skipped_bad_audience, } - (OUT / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + write_text(OUT / "manifest.json", json.dumps(manifest, indent=2) + "\n") print(f"served: internal={len(accepted)} customer={len(customer)} " f"(bad-audience skipped {skipped_bad_audience}) -> {OUT.relative_to(ROOT)}") return 0 diff --git a/src/km/upgrade.py b/src/km/upgrade.py index b8463a7..6881eed 100644 --- a/src/km/upgrade.py +++ b/src/km/upgrade.py @@ -29,7 +29,7 @@ from km import KM_REF from km.init import pin_values, template -from km.paths import repo_root +from km.paths import repo_root, write_text from km.pins import hook_rev, movable_refs, read_pins, resolve, set_pins, version_key, workflow_files COPIED = { # a file km copied in -> text only km's copy of it carries @@ -92,7 +92,7 @@ def main() -> int: continue text = p.read_text(encoding="utf-8") if rel in rewrites: - p.write_text(template(tpl, values), encoding="utf-8") + write_text(p, template(tpl, values)) done.append(f"rewrote {rel} (it was km's own, unchanged) to install km") team |= rel.startswith(".github/") elif rel == ".pre-commit-config.yaml" and "scripts/validate.py" in text: @@ -125,12 +125,12 @@ def main() -> int: if local.is_file(): text = local.read_text(encoding="utf-8") if (new := MARKER.sub("", text)) != text: - local.write_text(new, encoding="utf-8") + write_text(local, new) done.append("dropped team_brain from schema.local.yaml (no longer used)") dependabot = root / ".github/dependabot.yml" if team and not dependabot.exists(): dependabot.parent.mkdir(parents=True, exist_ok=True) - dependabot.write_text(template("team/dependabot.yml", {}), encoding="utf-8") + write_text(dependabot, template("team/dependabot.yml", {})) done.append("added .github/dependabot.yml") if values and (not current or stray): diff --git a/src/km/validate.py b/src/km/validate.py index 2c0dd04..2cd6c9c 100644 --- a/src/km/validate.py +++ b/src/km/validate.py @@ -30,7 +30,6 @@ import datetime import re -import subprocess import sys import unicodedata import urllib.parse @@ -39,7 +38,8 @@ import yaml -from km.common import EXEMPT, OVERLAY, ROOT, SCHEMA +from km.common import EXEMPT, OVERLAY, ROOT, SCHEMA, tracked_md +from km.paths import git from km.pins import movable_refs from km.common import RESERVED as RESERVED_NF from km.common import SKIP as SKIP_PREFIXES @@ -171,12 +171,7 @@ def targets() -> list[str]: if rel.endswith(".md") and is_validatable(rel) and (ROOT / rel).is_file(): out.append(rel) return out - res = subprocess.run( - ["git", "ls-files", "-z", "*.md"], cwd=ROOT, capture_output=True, text=True - ) - if res.returncode != 0: - sys.exit(f"git ls-files failed (not a git repo?): {res.stderr.strip()}") - return [p for p in res.stdout.split("\x00") if is_validatable(p)] + return [p for p in tracked_md() if is_validatable(p)] def secret_targets() -> list[str]: @@ -193,12 +188,7 @@ def secret_targets() -> list[str]: if rel.endswith(".md") and not rel.startswith(SKIP_PREFIXES) and (ROOT / rel).is_file(): out.append(rel) return out - res = subprocess.run( - ["git", "ls-files", "-z", "*.md"], cwd=ROOT, capture_output=True, text=True - ) - if res.returncode != 0: - return [] - return [p for p in res.stdout.split("\x00") if p and not p.startswith(SKIP_PREFIXES)] + return [p for p in tracked_md() if not p.startswith(SKIP_PREFIXES)] def frontmatter(text: str): @@ -225,12 +215,7 @@ def resolve_ref(rel: str, target: str) -> bool: def blob_exists(pin: str) -> bool: """For a path.md@hash pin, verify the blob existed at that commit in git.""" path, _, h = pin.partition("@") - res = subprocess.run( - ["git", "cat-file", "-e", f"{h}:{path.lstrip('/')}"], - cwd=ROOT, - capture_output=True, - ) - return res.returncode == 0 + return git("cat-file", "-e", f"{h}:{path.lstrip('/')}", cwd=ROOT).returncode == 0 # --- gate helpers --- @@ -345,10 +330,7 @@ def check_index_tree() -> None: that have an index. `skip_prefixes` folders and `index_skip_prefixes` folders are not linted (the latter is the index-only opt-out, leaving article validation on). Whole-repo only: skipped on per-file (pre-commit) runs.""" - res = subprocess.run(["git", "ls-files", "-z", "*.md"], cwd=ROOT, capture_output=True, text=True) - if res.returncode != 0: - return - tracked = {p for p in res.stdout.split("\x00") if p} # existence oracle: ALL tracked .md + tracked = set(tracked_md(existing=False)) # existence oracle: ALL tracked .md walk = {p for p in tracked # only these folders are linted if not p.startswith(SKIP_PREFIXES) and not p.startswith(INDEX_SKIP_PREFIXES)} diff --git a/tests/encoding_smoke.sh b/tests/encoding_smoke.sh new file mode 100644 index 0000000..88f87b2 --- /dev/null +++ b/tests/encoding_smoke.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Non-ASCII file names where the encoding is not UTF-8 (Windows: cp1252): km must find them, report +# them without crashing and write LF. CI runs it on Windows; team_smoke.sh runs it under every +# Windows-like environment the machine has. +# Run: bash tests/encoding_smoke.sh +set -u +source "$(dirname "$0")/lib.sh" +U="$(mktemp -d)" +trap 'rm -rf "$U"' EXIT +G(){ git -C "$U" -c user.name=t -c user.email=t@t -c core.autocrlf=false "$@"; } +run(){ out="$(km "$1" --root "$U" "${@:2}" 2>&1 | tr -d '\r')"; } # Windows prints CRLF + +G init -q; mkdir -p "$U/notes"; echo "# conv" > "$U/CONVENTIONS.md" +printf 'check_index: true\n' > "$U/schema.local.yaml" +printf -- '---\ntype: reference\ntitle: "Notes"\ntimestamp: 2026-01-01\nauthor: X\nstatus: draft\ntags: [t]\n---\n\n## Documents\n' > "$U/notes/_index.md" +printf -- '---\ntype: note\ntitle: "Künstler"\ntimestamp: 2026-01-01\nauthor: X\nstatus: draft\ntags: [t]\n---\nx\n' > "$U/notes/künstler.md" +printf -- '---\ntitle: "no type"\n---\nx\n' > "$U/notes/őrült.md" # an error to report, with a character cp1252 lacks +G add -A; G commit -qm init + +run gen-index +{ ! out_has Traceback && grep -q "(künstler.md)" "$U/notes/_index.md" && ! grep -q $'\r' "$U/notes/_index.md"; } \ + && ok "gen-index lists a non-ASCII file name and writes LF" || no "gen-index with non-ASCII names ($out)" +G add -A; G commit -qm index +run gen-index --check +out_has "up to date" && ok "gen-index --check: up to date" || no "gen-index --check ($out)" +run validate +{ ! out_has Traceback && out_has "required field 'type' absent" && out_has "^ERRORS: [1-9]"; } \ + && ok "validate finds and reports non-ASCII file names" || no "validate with non-ASCII names ($out)" +[ -z "$(G status --porcelain)" ] && ok "km left the work tree as committed" || no "km changed the work tree: $(G status --porcelain)" +summary encoding-smoke diff --git a/tests/lib.sh b/tests/lib.sh index d0daa54..038dee8 100644 --- a/tests/lib.sh +++ b/tests/lib.sh @@ -1,11 +1,17 @@ # Sourced by the smoke tests: `km` from this checkout, and pass/fail bookkeeping. -REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -km(){ PYTHONPATH="$REPO/src${PYTHONPATH:+:$PYTHONPATH}" python3 -m km "$@"; } +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && { pwd -W 2>/dev/null || pwd; })" # -W: a path Python on Windows reads +SEP=":"; pwd -W >/dev/null 2>&1 && SEP=";" # PYTHONPATH's separator +PY="${PYTHON:-python3}" +# Any read, write or subprocess call in km that relies on the system's default encoding fails the +# tests: on Windows that default is cp1252, not UTF-8. +km(){ PYTHONWARNDEFAULTENCODING=1 PYTHONWARNINGS=error::EncodingWarning \ + PYTHONPATH="$REPO/src${PYTHONPATH:+$SEP$PYTHONPATH}" "$PY" -m km "$@"; } pass=0; fail=0 ok(){ echo " ok: $1"; pass=$((pass+1)); } no(){ echo " FAIL: $1"; fail=$((fail+1)); } summary(){ echo "$1: $pass passed, $fail failed"; [ "$fail" -eq 0 ]; } -kmpy(){ PYTHONPATH="$REPO/src${PYTHONPATH:+:$PYTHONPATH}" python3 "$@"; } +kmpy(){ PYTHONPATH="$REPO/src${PYTHONPATH:+$SEP$PYTHONPATH}" "$PY" "$@"; } +out_has(){ LC_ALL=C grep -aq "$1" <<< "$out"; } # $out may be in a non-UTF-8 encoding: match bytes # A stand-in for the km repository with release tags, for `km init`/`km upgrade`, which pin a # release by its commit: v0.9.0 (lightweight) and this km's version (annotated, as releases may be). fake_km_remote(){ @@ -15,3 +21,10 @@ fake_km_remote(){ } # repin OLD_SHA OLD_VERSION NEW_SHA NEW_VERSION FILE...: rewrite a km pin in both formats (see km.pins) repin(){ local f; for f in "${@:5}"; do sed -i.bak "s|$1 # $2|$3 # $4|; s|$1 # frozen: $2|$3 # frozen: $4|" "$f" && rm -f "$f.bak"; done; } +# windows_like "K=V ...": whether that environment behaves like Windows: file names in UTF-8, +# everything else in a code page (cp1252 there). macOS has such locales; on Linux a non-UTF-8 locale +# makes file names non-UTF-8 as well, which Windows never does. +windows_like(){ with_env "$1" "$PY" -c 'import locale, sys +sys.exit(sys.getfilesystemencoding() != "utf-8" or "utf" in locale.getpreferredencoding(False).lower())' 2>/dev/null; } +# with_env "K=V K=V" CMD...: run CMD (a shell function too) with those exported and UTF-8 mode off +with_env(){ ( export $1 PYTHONUTF8=0; "${@:2}" ) } diff --git a/tests/team_smoke.sh b/tests/team_smoke.sh index 3d88110..ad1f98a 100644 --- a/tests/team_smoke.sh +++ b/tests/team_smoke.sh @@ -117,6 +117,7 @@ out="$(kt gen-index)"; rc=$? { [ "$rc" = 0 ] && ! echo "$out" | grep -q Traceback && ! grep -q "gone.md" "$T/process/_index.md"; } \ && ok "a deleted tracked doc drops out of the list, no crash" || no "a deleted tracked doc crashes gen-index ($out)" out="$(kt serve)"; echo "$out" | grep -q Traceback && no "serve survives a deleted tracked doc" || ok "serve survives a deleted tracked doc" +out="$(kt validate)"; echo "$out" | grep -q Traceback && no "validate survives a deleted tracked doc" || ok "validate survives a deleted tracked doc" G add -A; G commit -q -m gone2 >/dev/null # --- serve --- @@ -342,6 +343,34 @@ tpl = pins(glob.glob(f"{repo}/src/km/templates/team/*.yml")) sys.exit(0 if all(name in own and shas == own[name] for name, shas in tpl.items()) else 1) PY +# --- non-ASCII file names on a system whose encoding is not UTF-8 (Windows, cp1252) --- +tested= +for e in "LC_ALL=C PYTHONCOERCECLOCALE=0" "LC_ALL=en_US.ISO8859-1"; do + windows_like "$e" || continue; tested=1 + out="$(with_env "$e" bash "$REPO/tests/encoding_smoke.sh" 2>&1)" && ok "[$e] encoding_smoke.sh" || no "[$e] encoding_smoke.sh: +$out" +done +[ -n "$tested" ] || echo " skip: no Windows-like encoding here (Linux); the windows CI job runs encoding_smoke.sh" + +# On Linux a non-UTF-8 locale changes the file-name encoding too: each name git lists must still +# open its file (the smoke CI job generates this locale) +L="$F/latin1"; mkdir -p "$L/notes"; git -C "$L" init -q; echo "# conv" > "$L/CONVENTIONS.md" +printf -- '---\ntitle: "no type"\n---\nx\n' > "$L/notes/künstler.md"; git -C "$L" add -A +e="LC_ALL=en_US.ISO-8859-1" +if with_env "$e" "$PY" -c 'import sys; sys.exit(sys.getfilesystemencoding() == "utf-8")' 2>/dev/null; then + out="$(with_env "$e" km validate --root "$L" 2>&1)" + { ! out_has Traceback && out_has "required field 'type' absent"; } \ + && ok "[$e] a non-ASCII name is validated where file names are not UTF-8" || no "[$e] non-UTF-8 file names ($out)" +else echo " skip: no locale here whose file names are not UTF-8 (the smoke CI job has one)"; fi + +# git's messages follow the locale: a Latin-1 one outside a repo must give km's error, not a traceback +N="$F/norepo"; mkdir -p "$N" +if [ -n "$(cd "$N" && LC_ALL=fr_FR.ISO8859-1 git rev-parse 2>&1 | LC_ALL=C tr -d '\0-\177')" ]; then + out="$(with_env LC_ALL=fr_FR.ISO8859-1 km validate --root "$N" 2>&1)" + { ! out_has Traceback && out_has "git ls-files failed"; } \ + && ok "a git error in a Latin-1 locale is reported, not a crash" || no "git error in a Latin-1 locale ($out)" +else echo " skip: git prints no non-ASCII error in a Latin-1 locale here"; fi + # --- km's dependencies are pinned to the same exact versions everywhere --- v="$(grep -oE 'pyyaml==[0-9.]+' "$REPO/pyproject.toml")" { [ -n "$v" ] && grep -q "$v" "$REPO/action.yml" && grep -q "$v" "$REPO/skills/km/bin/km" && grep -q '"setuptools==' "$REPO/pyproject.toml"; } \ From 92a75a9f59661539b2181dd71046651b9d9e2af6 Mon Sep 17 00:00:00 2001 From: Roman PASSLER Date: Thu, 24 Sep 2026 09:12:51 +0200 Subject: [PATCH 2/2] chore(release): km 1.0.2 --- .claude-plugin/plugin.json | 2 +- README.md | 10 +++++----- src/km/__init__.py | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 80357c3..308fb6f 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "km", - "version": "1.0.1", + "version": "1.0.2", "description": "Knowledge management for markdown knowledge bases: answer from it with sources, save notes, decisions and transcripts with validated frontmatter, lint, mount peer brains, contribute to a team brain by PR, and set up a shared team brain with CI (/km init --team).", "author": { "name": "Roman Passler" diff --git a/README.md b/README.md index 73a6211..79c9025 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,9 @@ km upgrade move a brain's km pins to this version ``` Every command takes `--root DIR`; the default is the git work tree around the current directory. -Install a release by its commit (`git ls-remote https://github.com/pasrom/km refs/tags/v1.0.1` shows -it), not by the tag: `pip install git+https://github.com/pasrom/km@ # v1.0.1`, or run it -without installing: `uvx --from git+https://github.com/pasrom/km@ km validate # v1.0.1`. +Install a release by its commit (`git ls-remote https://github.com/pasrom/km refs/tags/v1.0.2` shows +it), not by the tag: `pip install git+https://github.com/pasrom/km@ # v1.0.2`, or run it +without installing: `uvx --from git+https://github.com/pasrom/km@ km validate # v1.0.2`. ## What a brain gets @@ -62,13 +62,13 @@ Brains carry no km code. `km init` writes `CONVENTIONS.md`, `CLAUDE.md`, `schema generated indexes, a README, and CI that installs km through this repository's GitHub Action: ```yaml -- uses: pasrom/km@ # v1.0.1, installs the km CLI at that commit +- uses: pasrom/km@ # v1.0.2, installs the km CLI at that commit - run: km validate - run: km gen-index --check ``` The pre-commit hook works the same way (`repo: https://github.com/pasrom/km`, -`rev: # frozen: v1.0.1`, `id: km-validate`). Every pin names a release by its commit, so a +`rev: # frozen: v1.0.2`, `id: km-validate`). Every pin names a release by its commit, so a tag moved later cannot change what a brain runs; the version rides along as a comment, and `km upgrade` checks that the commit is that release's. km's own dependencies are pinned to exact versions. `km init` and `km upgrade` look a release's commit up by its tag once, so a release tag here diff --git a/src/km/__init__.py b/src/km/__init__.py index 25e575f..6395d4c 100644 --- a/src/km/__init__.py +++ b/src/km/__init__.py @@ -1,4 +1,4 @@ """km: knowledge management for markdown knowledge bases.""" -__version__ = "1.0.1" +__version__ = "1.0.2" KM_REF = f"v{__version__}" # this km's release tag; brains pin the commit it points at