Skip to content

Commit eff8476

Browse files
AndydeCleyregraingertssbarnea
authored
Add --newline=[LF|CRLF|native|preserve] to compile, to override the line separator characters written (#1652)
Co-authored-by: Thomas Grainger <tagrain@gmail.com> Co-authored-by: Sorin Sbarnea <sorin.sbarnea@gmail.com>
1 parent 906bf36 commit eff8476

File tree

4 files changed

+146
-8
lines changed

4 files changed

+146
-8
lines changed

piptools/scripts/compile.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,35 @@ def _get_default_option(option_name: str) -> Any:
4949
return getattr(default_values, option_name)
5050

5151

52+
def _determine_linesep(
53+
strategy: str = "preserve", filenames: Tuple[str, ...] = ()
54+
) -> str:
55+
"""
56+
Determine and return linesep string for OutputWriter to use.
57+
Valid strategies: "LF", "CRLF", "native", "preserve"
58+
When preserving, files are checked in order for existing newlines.
59+
"""
60+
if strategy == "preserve":
61+
for fname in filenames:
62+
try:
63+
with open(fname, "rb") as existing_file:
64+
existing_text = existing_file.read()
65+
except FileNotFoundError:
66+
continue
67+
if b"\r\n" in existing_text:
68+
strategy = "CRLF"
69+
break
70+
elif b"\n" in existing_text:
71+
strategy = "LF"
72+
break
73+
return {
74+
"native": os.linesep,
75+
"LF": "\n",
76+
"CRLF": "\r\n",
77+
"preserve": "\n",
78+
}[strategy]
79+
80+
5281
@click.command(context_settings={"help_option_names": ("-h", "--help")})
5382
@click.version_option(**version_option_kwargs)
5483
@click.pass_context
@@ -165,6 +194,12 @@ def _get_default_option(option_name: str) -> Any:
165194
"Will be derived from input file otherwise."
166195
),
167196
)
197+
@click.option(
198+
"--newline",
199+
type=click.Choice(("LF", "CRLF", "native", "preserve"), case_sensitive=False),
200+
default="preserve",
201+
help="Override the newline control characters used",
202+
)
168203
@click.option(
169204
"--allow-unsafe/--no-allow-unsafe",
170205
is_flag=True,
@@ -279,6 +314,7 @@ def cli(
279314
upgrade: bool,
280315
upgrade_packages: Tuple[str, ...],
281316
output_file: Union[LazyFile, IO[Any], None],
317+
newline: str,
282318
allow_unsafe: bool,
283319
strip_extras: bool,
284320
generate_hashes: bool,
@@ -515,6 +551,10 @@ def cli(
515551

516552
log.debug("")
517553

554+
linesep = _determine_linesep(
555+
strategy=newline, filenames=(output_file.name, *src_files)
556+
)
557+
518558
##
519559
# Output
520560
##
@@ -534,6 +574,7 @@ def cli(
534574
index_urls=repository.finder.index_urls,
535575
trusted_hosts=repository.finder.trusted_hosts,
536576
format_control=repository.finder.format_control,
577+
linesep=linesep,
537578
allow_unsafe=allow_unsafe,
538579
find_links=repository.finder.find_links,
539580
emit_find_links=emit_find_links,

piptools/writer.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import io
12
import os
23
import re
34
import sys
@@ -98,6 +99,7 @@ def __init__(
9899
index_urls: Iterable[str],
99100
trusted_hosts: Iterable[str],
100101
format_control: FormatControl,
102+
linesep: str,
101103
allow_unsafe: bool,
102104
find_links: List[str],
103105
emit_find_links: bool,
@@ -117,6 +119,7 @@ def __init__(
117119
self.index_urls = index_urls
118120
self.trusted_hosts = trusted_hosts
119121
self.format_control = format_control
122+
self.linesep = linesep
120123
self.allow_unsafe = allow_unsafe
121124
self.find_links = find_links
122125
self.emit_find_links = emit_find_links
@@ -257,14 +260,25 @@ def write(
257260
hashes: Optional[Dict[InstallRequirement, Set[str]]],
258261
) -> None:
259262

260-
for line in self._iter_lines(results, unsafe_requirements, markers, hashes):
261-
if self.dry_run:
262-
# Bypass the log level to always print this during a dry run
263-
log.log(line)
264-
else:
265-
log.info(line)
266-
self.dst_file.write(unstyle(line).encode())
267-
self.dst_file.write(os.linesep.encode())
263+
if not self.dry_run:
264+
dst_file = io.TextIOWrapper(
265+
self.dst_file,
266+
encoding="utf8",
267+
newline=self.linesep,
268+
line_buffering=True,
269+
)
270+
try:
271+
for line in self._iter_lines(results, unsafe_requirements, markers, hashes):
272+
if self.dry_run:
273+
# Bypass the log level to always print this during a dry run
274+
log.log(line)
275+
else:
276+
log.info(line)
277+
dst_file.write(unstyle(line))
278+
dst_file.write("\n")
279+
finally:
280+
if not self.dry_run:
281+
dst_file.detach()
268282

269283
def _format_requirement(
270284
self,

tests/test_cli_compile.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -936,6 +936,88 @@ def test_generate_hashes_with_annotations(runner):
936936
)
937937

938938

939+
@pytest.mark.network
940+
@pytest.mark.parametrize("gen_hashes", (True, False))
941+
@pytest.mark.parametrize(
942+
"annotate_options",
943+
(
944+
("--no-annotate",),
945+
("--annotation-style", "line"),
946+
("--annotation-style", "split"),
947+
),
948+
)
949+
@pytest.mark.parametrize(
950+
("nl_options", "must_include", "must_exclude"),
951+
(
952+
pytest.param(("--newline", "lf"), "\n", "\r\n", id="LF"),
953+
pytest.param(("--newline", "crlf"), "\r\n", "\n", id="CRLF"),
954+
pytest.param(
955+
("--newline", "native"),
956+
os.linesep,
957+
{"\n": "\r\n", "\r\n": "\n"}[os.linesep],
958+
id="native",
959+
),
960+
),
961+
)
962+
def test_override_newline(
963+
runner, gen_hashes, annotate_options, nl_options, must_include, must_exclude
964+
):
965+
opts = annotate_options + nl_options
966+
if gen_hashes:
967+
opts += ("--generate-hashes",)
968+
969+
with open("requirements.in", "w") as req_in:
970+
req_in.write("six==1.15.0\n")
971+
req_in.write("setuptools\n")
972+
req_in.write("pip-tools @ git+https://github.com/jazzband/pip-tools\n")
973+
974+
runner.invoke(cli, [*opts, "requirements.in"])
975+
with open("requirements.txt", "rb") as req_txt:
976+
txt = req_txt.read().decode()
977+
978+
assert must_include in txt
979+
980+
if must_exclude in must_include:
981+
txt = txt.replace(must_include, "")
982+
assert must_exclude not in txt
983+
984+
# Do it again, with --newline=preserve:
985+
986+
opts = annotate_options + ("--newline", "preserve")
987+
if gen_hashes:
988+
opts += ("--generate-hashes",)
989+
990+
runner.invoke(cli, [*opts, "requirements.in"])
991+
with open("requirements.txt", "rb") as req_txt:
992+
txt = req_txt.read().decode()
993+
994+
assert must_include in txt
995+
996+
if must_exclude in must_include:
997+
txt = txt.replace(must_include, "")
998+
assert must_exclude not in txt
999+
1000+
1001+
@pytest.mark.network
1002+
@pytest.mark.parametrize(
1003+
("linesep", "must_exclude"),
1004+
(pytest.param("\n", "\r\n", id="LF"), pytest.param("\r\n", "\n", id="CRLF")),
1005+
)
1006+
def test_preserve_newline_from_input(runner, linesep, must_exclude):
1007+
with open("requirements.in", "wb") as req_in:
1008+
req_in.write(f"six{linesep}".encode())
1009+
1010+
runner.invoke(cli, ["--newline=preserve", "requirements.in"])
1011+
with open("requirements.txt", "rb") as req_txt:
1012+
txt = req_txt.read().decode()
1013+
1014+
assert linesep in txt
1015+
1016+
if must_exclude in linesep:
1017+
txt = txt.replace(linesep, "")
1018+
assert must_exclude not in txt
1019+
1020+
9391021
@pytest.mark.network
9401022
def test_generate_hashes_with_split_style_annotations(runner):
9411023
with open("requirements.in", "w") as fp:

tests/test_writer.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ def writer(tmpdir_cwd):
4242
index_urls=[],
4343
trusted_hosts=[],
4444
format_control=FormatControl(set(), set()),
45+
linesep="\n",
4546
allow_unsafe=False,
4647
find_links=[],
4748
emit_find_links=True,

0 commit comments

Comments
 (0)