Skip to content

Commit b594825

Browse files
committed
Read and write text files as explicit UTF-8
FileManager and LspService opened files with the locale default encoding. On Windows that is cp1252, so a source file containing characters outside it (e.g. U+201D or an emoji) raised UnicodeDecodeError on read, and writes produced non-UTF-8 bytes. Linux and macOS hide this behind UTF-8 locales, which is why #46 and #47 showed up only on the Windows CI. - Pass encoding="utf-8" in FileManager.get_content/save_file and in the LspService file-open read, and document the UTF-8 contract on IFileManager. - Add regression tests using text outside cp1252, so a locale-decoded read raises rather than silently producing mojibake. - Record the convention in developing-finecode.md, with the env vars that reproduce a non-UTF-8 locale. PLW1514 is preview-only and misses method references passed as callables, so enforcement stays manual.
1 parent 1d2fd81 commit b594825

6 files changed

Lines changed: 72 additions & 4 deletions

File tree

‎docs/guides/developing-finecode.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,14 @@ The cap also has no remaining job now that the interpreter matrix exists. The se
580580

581581
If a genuinely newer Python breaks a package, fix it when that version exists — do not pre-emptively cap. New packages must follow this: declare `requires-python = ">=<min>"` with no upper component.
582582

583+
## Text file IO: explicit UTF-8
584+
585+
Every text-mode `open`, `Path.read_text`, `Path.write_text` and `NamedTemporaryFile` passes `encoding="utf-8"`. This includes callables passed by reference rather than called inline, e.g. `asyncio.to_thread(p.write_text, s, encoding="utf-8")`.
586+
587+
The default is `locale.getencoding()`, which on Windows is the ANSI code page (cp1252) and stays that way until Python 3.15 (PEP 686). FineCode supports 3.11+, so the locale default cannot be assumed to be UTF-8. Linux and macOS dev machines never see the failure — their UTF-8 locales hide it — but #46 and #47 were exactly that: Windows-only `UnicodeDecodeError`s on the source-file read path.
588+
589+
To reproduce a non-UTF-8 locale locally, run with `LC_ALL=C PYTHONUTF8=0 PYTHONCOERCECLOCALE=0`. Enforcement is not automated yet: `PLW1514` (`unspecified-encoding`) is preview-only and misses method references passed as callables, so it cannot guard the convention on its own.
590+
583591
## JSON-RPC key naming convention
584592

585593
All JSON-RPC channels in FineCode use **camelCase** for message keys:

‎finecode_extension_api/src/finecode_extension_api/contrib/lsp_service.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1199,7 +1199,7 @@ async def _forward_file_event(self, event: ifileeditor.FileEvent) -> None:
11991199
async with self._get_uri_lock(uri):
12001200
if uri not in self._open_documents:
12011201
try:
1202-
content = event.file_path.read_text()
1202+
content = event.file_path.read_text(encoding="utf-8")
12031203
except OSError:
12041204
return
12051205
lsp_version = self._next_version(uri)

‎finecode_extension_api/src/finecode_extension_api/interfaces/ifilemanager.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ class IFileManager(Protocol):
4444
async def get_content(self, file_path: Path) -> str:
4545
"""Return the content of ``file_path``.
4646
47+
The file is decoded as UTF-8; content that is not valid UTF-8 raises
48+
``UnicodeDecodeError``.
49+
4750
Raises:
4851
FileNotFound: ``file_path`` does not exist.
4952
"""
@@ -88,7 +91,9 @@ async def rename_file(
8891
"""
8992
...
9093

91-
async def save_file(self, file_path: Path, file_content: str) -> None: ...
94+
async def save_file(self, file_path: Path, file_content: str) -> None:
95+
"""Write ``file_content`` encoded as UTF-8."""
96+
...
9297

9398
async def create_dir(
9499
self, dir_path: Path, create_parents: bool = True, exist_ok: bool = True

‎finecode_extension_api/tests/test_lsp_service.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212
from finecode_extension_api.contrib.lsp_service import LspService, _FileChangeType
1313
from finecode_extension_api.interfaces import ifileeditor, ilspclient
1414

15+
# Em dash, right double quote, é and 😀 — the right double quote (U+201D) and
16+
# the emoji are outside cp1252, so a locale-decoded (cp1252 on Windows) read
17+
# raises instead of rounding them into mojibake.
18+
_NON_ASCII = "x = '— ” é \U0001f600'"
19+
1520

1621
@dataclasses.dataclass
1722
class _SentNotification:
@@ -406,6 +411,26 @@ async def test_file_open_event_and_hover_race_do_not_double_sync(
406411
assert session.sync_notification_count(file_path.as_uri()) == 1
407412

408413

414+
async def test_file_open_event_sends_utf8_file_content_in_did_open(
415+
tmp_path: Path,
416+
) -> None:
417+
"""UTF-8 file content must reach the server's `textDocument/didOpen` even
418+
where the default text encoding is the locale's, cp1252 on Windows."""
419+
file_path = tmp_path / "subject.py"
420+
file_path.write_bytes(_NON_ASCII.encode("utf-8"))
421+
422+
async with _running_service(file_path, _NON_ASCII) as (service, session, _):
423+
await service._handle_file_event(
424+
ifileeditor.FileOpenEvent(file_path=file_path)
425+
)
426+
427+
did_open = [
428+
n for n in session.notifications if n.method == "textDocument/didOpen"
429+
]
430+
assert len(did_open) == 1
431+
assert did_open[0].params["textDocument"]["text"] == _NON_ASCII
432+
433+
409434
class _FakeTransportError(Exception):
410435
"""Duck-typed shape of a transport-level JSON-RPC error carrying a code,
411436
without depending on finecode_jsonrpc (which this package must not

‎finecode_extension_runner/src/finecode_extension_runner/impls/file_manager.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ async def rename_file(
6868

6969
async def save_file(self, file_path: Path, file_content: str) -> None:
7070
self.logger.debug(f"Save file {file_path}")
71-
await asyncio.to_thread(file_path.write_text, file_content)
71+
await asyncio.to_thread(file_path.write_text, file_content, encoding="utf-8")
7272

7373
async def create_dir(
7474
self, dir_path: Path, create_parents: bool = True, exist_ok: bool = True
@@ -137,7 +137,7 @@ def _chmod_writable(self, path: Path) -> None:
137137
def read_content_file_from_fs(self, file_path: Path) -> str:
138138
# don't use this method directly, use `get_content` instead
139139
self.logger.debug(f"Read file: {file_path}")
140-
with open(file_path) as f:
140+
with open(file_path, encoding="utf-8") as f:
141141
file_content = f.read()
142142

143143
return file_content

‎finecode_extension_runner/tests/test_file_manager.py‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@
1010
from finecode_extension_runner.impls.file_manager import FileManager
1111

1212

13+
# Em dash, right double quote, é and 😀 — no newline, so the byte assertions
14+
# below do not depend on Windows \n→\r\n translation. The right double quote
15+
# (U+201D) and 😀 are outside cp1252, so a cp1252 read raises instead of
16+
# mojibake, while — and é would round-trip as wrong-but-non-raising bytes.
17+
_NON_ASCII = "x = '— ” é \U0001f600'"
18+
19+
1320
def _make_dir_tree(root: pathlib.Path) -> pathlib.Path:
1421
nested = root / "sub"
1522
nested.mkdir(parents=True)
@@ -191,3 +198,26 @@ async def test_delete_file_on_a_directory_raises(
191198
await FileManager(logger=logger).delete_file(directory)
192199

193200
assert directory.exists()
201+
202+
203+
async def test_get_content_decodes_utf8_regardless_of_locale(
204+
tmp_path: pathlib.Path,
205+
) -> None:
206+
"""UTF-8 content must decode even where the default text encoding is the
207+
locale's, cp1252 on Windows."""
208+
path = tmp_path / "subject.py"
209+
path.write_bytes(_NON_ASCII.encode("utf-8"))
210+
211+
assert await FileManager(logger=logger).get_content(path) == _NON_ASCII
212+
213+
214+
async def test_save_file_encodes_utf8_regardless_of_locale(
215+
tmp_path: pathlib.Path,
216+
) -> None:
217+
"""Content must be written as UTF-8 even where the default text encoding
218+
is the locale's, cp1252 on Windows."""
219+
path = tmp_path / "subject.py"
220+
221+
await FileManager(logger=logger).save_file(path, _NON_ASCII)
222+
223+
assert path.read_bytes() == _NON_ASCII.encode("utf-8")

0 commit comments

Comments
 (0)