Skip to content

Commit 78704cb

Browse files
Add UTF-8 encoding and zipimport guard in CLI
Address two issues raised in PR #73 review: - _apply_init_to_file now reads and writes project AGENTS.md / CLAUDE.md with an explicit encoding="utf-8" on every read_text / write_text call. The platform default text encoding is not UTF-8 on Windows (cp1252), which would produce UnicodeDecodeError on UTF-8 content in existing files or mojibake when writing the pointer block. - _bundled_agents_md_path now uses importlib.resources.as_file to resolve the bundled AGENTS.md and raises RuntimeError with a clear message when the install isn't filesystem-backed (pure zipimport). Previously the function would print a non-existent path that the caller would then fail to open. cmd_docs handles the RuntimeError by printing the message to stderr and exiting with code 2. The docstring now claims only wheel and editable installs (the realistic shapes for this distribution) rather than implying "or zipped".
1 parent 4d7d74a commit 78704cb

1 file changed

Lines changed: 35 additions & 13 deletions

File tree

src/openarmature/cli.py

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121

2222
import argparse
2323
import sys
24-
from importlib.resources import files
24+
from importlib.resources import as_file, files
25+
from importlib.resources.abc import Traversable
2526
from pathlib import Path
2627

2728
# Comment marker that ``openarmature init`` writes into managed
@@ -57,15 +58,32 @@ def _pointer_block() -> str:
5758
def _bundled_agents_md_path() -> Path:
5859
"""Return the absolute path to the bundled ``AGENTS.md``.
5960
60-
Resolved via :mod:`importlib.resources` so it works whether the
61-
package is installed as a wheel, editable, or zipped — same
62-
mechanism the README discovery one-liner relies on.
61+
Resolved via :mod:`importlib.resources`. Works for wheel and
62+
editable installs (the realistic distribution shapes for this
63+
package) since both extract to a real filesystem path under
64+
``site-packages``. Pure zipimport installs don't surface a
65+
stable filesystem path; this function raises ``RuntimeError``
66+
in that case rather than printing a non-existent path.
6367
"""
64-
resource = files("openarmature").joinpath("AGENTS.md")
65-
# ``files()`` returns a Traversable; the bundle is a regular
66-
# file shipped at the package root, so ``str()`` is the on-disk
67-
# path. ``Path()`` normalizes platform separators.
68-
return Path(str(resource))
68+
resource: Traversable = files("openarmature").joinpath("AGENTS.md")
69+
# ``as_file`` returns the resource as a real filesystem path
70+
# when the loader exposes one (the typical case), and would
71+
# otherwise extract to a temp file inside the ``with`` block.
72+
# We need a stable path the caller can print and re-open, so
73+
# we exit the context manager immediately and verify the path
74+
# still exists — if not, the resource was only valid for the
75+
# duration of the temp-file context, which means we're under
76+
# a non-filesystem loader.
77+
with as_file(resource) as path:
78+
bundled = Path(path)
79+
if not bundled.is_file():
80+
raise RuntimeError(
81+
"openarmature/AGENTS.md is not available as a stable filesystem path "
82+
"(install appears to be zipimport-backed). Use the python -c discovery "
83+
"recipe instead: "
84+
"python -c \"import openarmature; print(openarmature.__path__[0] + '/AGENTS.md')\""
85+
)
86+
return bundled
6987

7088

7189
def _apply_init_to_file(target: Path, *, force: bool, dry_run: bool) -> tuple[str, str]:
@@ -93,10 +111,10 @@ def _apply_init_to_file(target: Path, *, force: bool, dry_run: bool) -> tuple[st
93111
# Fresh file gets the block verbatim: no leading blank line,
94112
# trailing newline preserved.
95113
if not dry_run:
96-
target.write_text(block)
114+
target.write_text(block, encoding="utf-8")
97115
return ("create", str(target))
98116

99-
existing = target.read_text()
117+
existing = target.read_text(encoding="utf-8")
100118
if INIT_MARKER in existing and not force:
101119
return ("skip", f"{target} already contains {INIT_MARKER}")
102120

@@ -105,7 +123,7 @@ def _apply_init_to_file(target: Path, *, force: bool, dry_run: bool) -> tuple[st
105123
# ``<existing trimmed>\n\n## OpenArmature\n...``.
106124
appended = existing.rstrip() + "\n\n" + block
107125
if not dry_run:
108-
target.write_text(appended)
126+
target.write_text(appended, encoding="utf-8")
109127
action = "force-append" if force and INIT_MARKER in existing else "append"
110128
return (action, str(target))
111129

@@ -127,7 +145,11 @@ def cmd_init(args: argparse.Namespace) -> int:
127145
def cmd_docs(args: argparse.Namespace) -> int:
128146
"""Handle ``openarmature docs``."""
129147
del args
130-
print(_bundled_agents_md_path())
148+
try:
149+
print(_bundled_agents_md_path())
150+
except RuntimeError as exc:
151+
print(f"error: {exc}", file=sys.stderr)
152+
return 2
131153
return 0
132154

133155

0 commit comments

Comments
 (0)