Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ Nodeenv changelog
Version [unreleased]
--------------------

- Preserve non-UTF-8 filesystem bytes when writing activation scripts, so
environments in such paths can be activated on Linux
`#442 <https://github.com/ekalinin/nodeenv/issues/442>`_
- `nodeenv -p` no longer puts the python venv's `bin` directory on `PATH`
a second time: the venv's own `activate` and `activate.fish` put it there
already. Envs without those scripts, such as conda envs, and the git-bash
Expand Down
2 changes: 1 addition & 1 deletion nodeenv.py
Original file line number Diff line number Diff line change
Expand Up @@ -653,7 +653,7 @@ def writefile(dest, content, overwrite=True, append=False):
Create file and write content in it
"""
if not isinstance(content, bytes):
content = bytes(content, 'utf-8')
content = content.encode('utf-8', 'surrogateescape')
if not os.path.exists(dest):
logger.debug(' * Writing %s ... ', dest, extra=dict(continued=True))
with open(dest, 'wb') as f:
Expand Down
31 changes: 31 additions & 0 deletions tests/nodeenv_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -3007,6 +3007,37 @@ def test_with_certifi_is_configurable(self):
class TestWritefile:
"""Tests for writefile()"""

@pytest.mark.parametrize('append', [False, True])
def test_preserves_surrogateescaped_bytes(self, tmpdir, append):
"""Filesystem bytes in activation content must round-trip."""
dest = tmpdir.join('activate')
dest.write_binary(b'# existing\n')
raw = b"NODE_VIRTUAL_ENV='/tmp/caf\xe9/env'\n"

nodeenv.writefile(
str(dest), raw.decode('utf-8', 'surrogateescape'), append=append)

expected = b'# existing\n' + raw if append else raw
assert dest.read_binary() == expected
nodeenv.writefile(
str(dest), raw.decode('utf-8', 'surrogateescape'), append=append)
assert dest.read_binary() == expected

@pytest.mark.parametrize('content, expected', [
('caf\u00e9', b'caf\xc3\xa9'),
(b'caf\xe9', b'caf\xe9'),
])
def test_preserves_utf8_text_and_bytes(self, tmpdir, content, expected):
"""Regular Unicode stays UTF-8 and bytes stay unchanged."""
dest = tmpdir.join('activate')
nodeenv.writefile(str(dest), content)
assert dest.read_binary() == expected

def test_rejects_non_filesystem_surrogate(self, tmpdir):
"""surrogateescape must not conceal arbitrary invalid Unicode."""
with pytest.raises(UnicodeEncodeError):
nodeenv.writefile(str(tmpdir.join('activate')), '\ud800')

def test_append_starts_on_its_own_line(self, tmpdir):
"""A file not ending with a newline must not swallow the first
appended line: `:END` plus `@echo off` is a label, not a command
Expand Down
23 changes: 23 additions & 0 deletions tests/test_install_activate.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,29 @@ def test_write(tmpdir, name, content_var):
assert bin_dir.join(name).read() == fix_content(content, tmpdir)


@pytest.mark.skipif(not sys.platform.startswith('linux'),
reason='non-UTF-8 filesystem paths need Linux')
def test_activate_with_surrogateescaped_path(tmp_path):
"""Keep a raw filesystem path intact in generated scripts and sh."""
env_dir = tmp_path / os.fsdecode(b'caf\xe9')
bin_dir = env_dir / 'bin'
bin_dir.mkdir(parents=True)
node = bin_dir / 'node'
node.write_bytes(b'#!/bin/sh\necho stub\n')
node.chmod(0o755)
with mock.patch.object(sys, 'argv', ['nodeenv', str(env_dir)]):
opts = nodeenv.parse_args()
nodeenv.install_activate(str(env_dir), opts)

activate = bin_dir / 'activate'
assert os.fsencode(str(env_dir)) in activate.read_bytes()
output = subprocess.check_output([
'sh', '-c', '. "$1"; printf "%s" "$NODE_VIRTUAL_ENV"',
'sh', str(activate),
])
assert output == os.fsencode(str(env_dir))


@pytest.mark.skipif(nodeenv.is_WIN, reason='activate.fish is POSIX only')
def test_activate_fish_has_no_caret_redirect(tmpdir):
"""Generated activate.fish must not contain caret stderr redirects.
Expand Down
Loading