|
| 1 | +"""Shared cross-platform bootstrap helpers for hook scripts. |
| 2 | +
|
| 3 | +Stdlib-only. Hook scripts import this (same-directory import, like |
| 4 | +``_sdk_gate``) before doing any I/O: |
| 5 | +
|
| 6 | +- :func:`ensure_utf8_stdio` — force UTF-8 (``errors="replace"``) on |
| 7 | + stdout/stderr. On Windows the console default is often cp1252, |
| 8 | + which cannot encode em-dashes/emoji and would crash the hook. |
| 9 | +- :func:`read_stdin_utf8` — read the hook payload as UTF-8 bytes |
| 10 | + regardless of locale (``sys.stdin.buffer`` bypasses the |
| 11 | + locale-encoded text wrapper, which is cp1252 on most Windows |
| 12 | + machines). |
| 13 | +- :func:`ensure_repo_src_on_path` — make the repo's ``src/`` |
| 14 | + importable so hooks can lazily import ``attune.*`` without the |
| 15 | + POSIX-only ``PYTHONPATH=src python …`` env-prefix syntax in the |
| 16 | + hook registration. |
| 17 | +
|
| 18 | +Copyright 2026 Smart-AI-Memory |
| 19 | +Licensed under the Apache License, Version 2.0 |
| 20 | +""" |
| 21 | + |
| 22 | +from __future__ import annotations |
| 23 | + |
| 24 | +import sys |
| 25 | +from pathlib import Path |
| 26 | + |
| 27 | + |
| 28 | +def ensure_utf8_stdio() -> None: |
| 29 | + """Reconfigure stdout/stderr to UTF-8 with replacement. |
| 30 | +
|
| 31 | + No-op when the streams are already UTF-8 (macOS/Linux default) |
| 32 | + or do not support ``reconfigure`` (e.g. pytest capture objects). |
| 33 | + """ |
| 34 | + for stream in (sys.stdout, sys.stderr): |
| 35 | + encoding = getattr(stream, "encoding", None) |
| 36 | + if ( |
| 37 | + encoding |
| 38 | + and encoding.lower() not in ("utf-8", "utf8") |
| 39 | + and hasattr(stream, "reconfigure") |
| 40 | + ): |
| 41 | + stream.reconfigure(encoding="utf-8", errors="replace") |
| 42 | + |
| 43 | + |
| 44 | +def read_stdin_utf8(limit: int | None = None) -> str: |
| 45 | + """Read stdin as UTF-8 text, independent of the locale encoding. |
| 46 | +
|
| 47 | + Args: |
| 48 | + limit: Optional byte cap (e.g. 10_000 to bound hook input). |
| 49 | +
|
| 50 | + Returns: |
| 51 | + Decoded payload; undecodable bytes become U+FFFD replacements |
| 52 | + rather than raising, so a hook never crashes on odd input. |
| 53 | + """ |
| 54 | + buffer = getattr(sys.stdin, "buffer", None) |
| 55 | + if buffer is None: # already detached/wrapped (tests) |
| 56 | + return sys.stdin.read() if limit is None else sys.stdin.read(limit) |
| 57 | + data = buffer.read() if limit is None else buffer.read(limit) |
| 58 | + return data.decode("utf-8", errors="replace") |
| 59 | + |
| 60 | + |
| 61 | +def ensure_repo_src_on_path() -> None: |
| 62 | + """Insert the repo's ``src/`` directory at the front of ``sys.path``. |
| 63 | +
|
| 64 | + Resolved relative to this file — ``parents[3]`` climbs |
| 65 | + ``scripts/`` → ``hooks/`` → ``attune/`` → ``src/`` — so it works |
| 66 | + from any cwd, any worktree, and any platform without env-prefix |
| 67 | + syntax in the registration. |
| 68 | + """ |
| 69 | + try: |
| 70 | + src = Path(__file__).resolve().parents[3] |
| 71 | + except IndexError: |
| 72 | + return |
| 73 | + # Require the real package (__init__.py), not just a directory named |
| 74 | + # "attune" — from the plugin copy, parents[3] lands OUTSIDE the repo, |
| 75 | + # where an unrelated "attune" dir (e.g. a workspace umbrella checkout) |
| 76 | + # would otherwise shadow the installed package as a namespace package. |
| 77 | + if not (src / "attune" / "__init__.py").is_file(): # plugin copy / moved layout — no-op |
| 78 | + return |
| 79 | + src_str = str(src) |
| 80 | + if src_str not in sys.path: |
| 81 | + sys.path.insert(0, src_str) |
0 commit comments