Skip to content

Commit b9f3b8c

Browse files
committed
feat: Implement FEATURE_BUNDLE_1 RFC 0004
Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
1 parent f999158 commit b9f3b8c

File tree

6 files changed

+170
-8
lines changed

6 files changed

+170
-8
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ classifiers = [
3131
"Topic :: Software Development :: Libraries"
3232
]
3333
dependencies = [
34-
"openjd-model >= 0.8,< 0.9",
34+
"openjd-model >= 0.9,< 0.10",
3535
"pywin32 >= 307; platform_system == 'Windows'",
3636
"psutil >= 5.9,< 7.3; platform_system == 'Windows'",
3737
]

src/openjd/sessions/_embedded_files.py

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22

33
import os
4+
import re
45
import stat
56
from contextlib import contextmanager
67
from dataclasses import dataclass
@@ -37,8 +38,40 @@ def _open_context(*args: Any, **kwargs: Any) -> Generator[int, None, None]:
3738
os.close(fd)
3839

3940

41+
# Regex to match LF not preceded by CR (for CRLF conversion)
42+
_LF_NOT_CRLF = re.compile(r"(?<!\r)\n")
43+
44+
45+
def _convert_line_endings(data: str, end_of_line: Optional[str]) -> str:
46+
"""Convert line endings based on the specified mode.
47+
48+
Args:
49+
data: The string data to convert
50+
end_of_line: One of None, "AUTO", "LF", or "CRLF"
51+
52+
Returns:
53+
The data with converted line endings
54+
"""
55+
if end_of_line is None or end_of_line == "AUTO":
56+
# AUTO: use OS native line endings
57+
if os.name == "nt":
58+
# Windows: ensure CRLF
59+
return _LF_NOT_CRLF.sub("\r\n", data)
60+
# POSIX: ensure LF
61+
return data.replace("\r\n", "\n")
62+
elif end_of_line == "LF":
63+
return data.replace("\r\n", "\n")
64+
elif end_of_line == "CRLF":
65+
return _LF_NOT_CRLF.sub("\r\n", data)
66+
return data
67+
68+
4069
def write_file_for_user(
41-
filename: Path, data: str, user: Optional[SessionUser], additional_permissions: int = 0
70+
filename: Path,
71+
data: str,
72+
user: Optional[SessionUser],
73+
additional_permissions: int = 0,
74+
end_of_line: Optional[str] = None,
4275
) -> None:
4376
# File should only be r/w by the owner, by default
4477

@@ -47,17 +80,22 @@ def write_file_for_user(
4780
# O_CREAT - create if it does not exist
4881
# O_TRUNC - truncate the file. If we overwrite an existing file, then we
4982
# need to clear its contents.
83+
# O_BINARY - (Windows only) prevent automatic \n to \r\n conversion
5084
# O_EXCL (intentionally not present) - fail if file exists
5185
# - We exclude this 'cause we expect to be writing the same embedded file
5286
# into the same location repeatedly with different contents as we run
5387
# multiple Tasks in the same Session.
5488
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
89+
# On Windows, use O_BINARY to prevent automatic line ending conversion
90+
# since we handle line endings explicitly via _convert_line_endings
91+
flags |= getattr(os, "O_BINARY", 0)
5592
# mode:
5693
# S_IRUSR - Read by owner
5794
# S_IWUSR - Write by owner
5895
mode = stat.S_IRUSR | stat.S_IWUSR | (additional_permissions & stat.S_IRWXU)
96+
converted_data = _convert_line_endings(data, end_of_line)
5997
with _open_context(filename, flags, mode=mode) as fd:
60-
os.write(fd, data.encode("utf-8"))
98+
os.write(fd, converted_data.encode("utf-8"))
6199

62100
if os.name == "posix":
63101
if user is not None:
@@ -227,8 +265,16 @@ def _materialize_file(
227265
execute_permissions |= stat.S_IXUSR | (stat.S_IXGRP if self._user is not None else 0)
228266

229267
data = file.data.resolve(symtab=symtab)
268+
# Get endOfLine setting if present
269+
end_of_line = file.endOfLine.value if file.endOfLine else None
230270
# Create the file as r/w owner, and optionally group
231-
write_file_for_user(filename, data, self._user, additional_permissions=execute_permissions)
271+
write_file_for_user(
272+
filename,
273+
data,
274+
self._user,
275+
additional_permissions=execute_permissions,
276+
end_of_line=end_of_line,
277+
)
232278

233279
self._logger.info(
234280
f"Wrote: {file.name} -> {str(filename)}",

src/openjd/sessions/_runner_base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -464,7 +464,7 @@ def _run_action(
464464
else:
465465
time_limit: Optional[timedelta] = default_timeout
466466
if action.timeout:
467-
time_limit = timedelta(seconds=action.timeout)
467+
time_limit = timedelta(seconds=action.timeout) # type: ignore[arg-type]
468468
self._run(command, time_limit)
469469

470470
def _cancel(

src/openjd/sessions/_runner_env_script.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ def cancel(
207207
method = NotifyCancelMethod(terminate_delay=timedelta(seconds=30))
208208
else:
209209
method = NotifyCancelMethod(
210-
terminate_delay=timedelta(seconds=model_cancel_method.notifyPeriodInSeconds)
210+
terminate_delay=timedelta(seconds=model_cancel_method.notifyPeriodInSeconds) # type: ignore[arg-type]
211211
)
212212

213213
# Note: If the given time_limit is less than that in the method, then the time_limit will be what's used.

src/openjd/sessions/_runner_step_script.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ def cancel(
143143
method = NotifyCancelMethod(terminate_delay=timedelta(seconds=120))
144144
else:
145145
method = NotifyCancelMethod(
146-
terminate_delay=timedelta(seconds=model_cancel_method.notifyPeriodInSeconds)
146+
terminate_delay=timedelta(seconds=model_cancel_method.notifyPeriodInSeconds) # type: ignore[arg-type]
147147
)
148148

149149
# Note: If the given time_limit is less than that in the method, then the time_limit will be what's used.

test/openjd/sessions/test_embedded_files.py

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import uuid
66
from dataclasses import dataclass
77
from pathlib import Path
8-
from unittest.mock import MagicMock
8+
from unittest.mock import MagicMock, patch
99
from openjd.sessions._os_checker import is_posix, is_windows
1010
import pytest
1111

@@ -19,6 +19,9 @@
1919
from openjd.model.v2023_09 import (
2020
EmbeddedFileTypes as EmbeddedFileTypes_2023_09,
2121
)
22+
from openjd.model.v2023_09 import (
23+
EndOfLine as EndOfLine_2023_09,
24+
)
2225
from openjd.sessions._embedded_files import EmbeddedFiles, EmbeddedFilesScope
2326
from openjd.sessions._session_user import PosixSessionUser, WindowsSessionUser
2427

@@ -365,6 +368,119 @@ def test_changes_owner(self, tmp_path: Path, windows_user: WindowsSessionUser) -
365368
result_contents = file.read()
366369
assert result_contents == testdata, "File contents are as expected"
367370

371+
class TestEndOfLine:
372+
"""Tests for endOfLine handling in embedded files."""
373+
374+
@pytest.mark.parametrize(
375+
"end_of_line,input_data,expected_bytes",
376+
[
377+
pytest.param(
378+
EndOfLine_2023_09.LF,
379+
"line1\nline2\nline3",
380+
b"line1\nline2\nline3",
381+
id="LF-only",
382+
),
383+
pytest.param(
384+
EndOfLine_2023_09.LF,
385+
"line1\r\nline2\r\nline3",
386+
b"line1\nline2\nline3",
387+
id="LF-converts-CRLF",
388+
),
389+
pytest.param(
390+
EndOfLine_2023_09.CRLF,
391+
"line1\nline2\nline3",
392+
b"line1\r\nline2\r\nline3",
393+
id="CRLF-converts-LF",
394+
),
395+
pytest.param(
396+
EndOfLine_2023_09.CRLF,
397+
"line1\r\nline2\r\nline3",
398+
b"line1\r\nline2\r\nline3",
399+
id="CRLF-preserves-CRLF",
400+
),
401+
],
402+
)
403+
def test_end_of_line_conversion(
404+
self,
405+
tmp_path: Path,
406+
end_of_line: EndOfLine_2023_09,
407+
input_data: str,
408+
expected_bytes: bytes,
409+
) -> None:
410+
# Test that endOfLine correctly converts line endings
411+
412+
# GIVEN
413+
test_obj = EmbeddedFiles(
414+
logger=MagicMock(), scope=EmbeddedFilesScope.STEP, session_files_directory=tmp_path
415+
)
416+
test_file = EmbeddedFileText_2023_09(
417+
name="Foo",
418+
type=EmbeddedFileTypes_2023_09.TEXT,
419+
data=DataString_2023_09(input_data),
420+
endOfLine=end_of_line,
421+
)
422+
filename = tmp_path / uuid.uuid4().hex
423+
symtab = SymbolTable()
424+
425+
# WHEN
426+
test_obj._materialize_file(filename, test_file, symtab)
427+
428+
# THEN
429+
with open(filename, "rb") as file:
430+
result_bytes = file.read()
431+
assert result_bytes == expected_bytes
432+
433+
@pytest.mark.skipif(is_windows(), reason="Cannot simulate POSIX file I/O on Windows")
434+
def test_auto_uses_lf_on_posix(self, tmp_path: Path) -> None:
435+
# Test that AUTO mode uses LF on POSIX systems
436+
437+
# GIVEN
438+
test_obj = EmbeddedFiles(
439+
logger=MagicMock(), scope=EmbeddedFilesScope.STEP, session_files_directory=tmp_path
440+
)
441+
test_file = EmbeddedFileText_2023_09(
442+
name="Foo",
443+
type=EmbeddedFileTypes_2023_09.TEXT,
444+
data=DataString_2023_09("line1\r\nline2"),
445+
endOfLine=EndOfLine_2023_09.AUTO,
446+
)
447+
filename = tmp_path / uuid.uuid4().hex
448+
symtab = SymbolTable()
449+
450+
# WHEN
451+
test_obj._materialize_file(filename, test_file, symtab)
452+
453+
# THEN
454+
with open(filename, "rb") as file:
455+
result_bytes = file.read()
456+
assert result_bytes == b"line1\nline2"
457+
458+
@pytest.mark.skipif(not is_windows(), reason="Windows-specific test")
459+
def test_auto_uses_crlf_on_windows(self, tmp_path: Path) -> None:
460+
# Test that AUTO mode uses CRLF on Windows systems
461+
462+
# GIVEN
463+
test_obj = EmbeddedFiles(
464+
logger=MagicMock(), scope=EmbeddedFilesScope.STEP, session_files_directory=tmp_path
465+
)
466+
test_file = EmbeddedFileText_2023_09(
467+
name="Foo",
468+
type=EmbeddedFileTypes_2023_09.TEXT,
469+
data=DataString_2023_09("line1\nline2"),
470+
endOfLine=EndOfLine_2023_09.AUTO,
471+
)
472+
filename = tmp_path / uuid.uuid4().hex
473+
symtab = SymbolTable()
474+
475+
# WHEN
476+
with patch("os.name", "nt"):
477+
test_obj._materialize_file(filename, test_file, symtab)
478+
479+
# THEN
480+
with open(filename, "rb") as file:
481+
result_bytes = file.read()
482+
assert result_bytes == b"line1\r\nline2"
483+
368484
class TestMaterialize:
369485
"""Tests for EmbeddedFiles.materialize()"""
370486

0 commit comments

Comments
 (0)