Skip to content

Commit 4da0851

Browse files
authored
Add option to skip the first line of source code (#3299)
* Add option to skip the first line in source file This commit adds a CLi option to skip the first line in the source files, just like the Cpython command line allows [1]. By enabling the flag, using `-x` or `--skip-source-first-line`, the first line is removed temporarilly while the remaining contents are formatted. The first line is added back before returning the formatted output. [1]: https://docs.python.org/dev/using/cmdline.html#cmdoption-x Signed-off-by: Antonio Ossa Guerra <aaossa@uc.cl> * Add tests for `--skip-source-first-line` option When the flag is disabled (default), black formats the entire source file, as in every line. In the other hand, if the flag is enabled, by using `-x` or `--skip-source-first-line`, the first line is retained while the rest of the source is formatted and then is added back. These tests use an empty Python file that contains invalid syntax in its first line (`invalid_header.py`, at `miscellaneous/`). First, Black is invoked without enabling the flag which should result in an exit code different than 0. When the flag is enabled, Black is expected to return a successful exit code and the header is expected to be retained (even if its not valid Python syntax). Signed-off-by: Antonio Ossa Guerra <aaossa@uc.cl> * Support skip source first line option for blackd The recently added option can be added as an acceptable header for blackd. The arguments are passed in such a way that using the new header will activate the skip source first line behaviour as expected Signed-off-by: Antonio Ossa Guerra <aaossa@uc.cl> * Add skip source first line option to blackd docs The new option can be passed to blackd as a header. This commit updates the blackd docs to include the new header. Signed-off-by: Antonio Ossa Guerra <aaossa@uc.cl> * Update CHANGES.md Include the new Black option to skip the first line of source code in the configuration section Signed-off-by: Antonio Ossa Guerra <aaossa@uc.cl> * Update skip first line test including valid syntax Including valid Python syntax help us make sure that the file is still actually valid after skipping the first line of the source file (which contains invalid Python syntax) Signed-off-by: Antonio Ossa Guerra <aaossa@uc.cl> * Skip first source line at `format_file_in_place` Instead of skipping the first source line at `format_file_contents`, do it before. This allow us to find the correct newline and encoding on the actual source code (everything that's after the header). This change is also applied at Blackd: take the header before passing the source to `format_file_contents` and put the header back once we get the formatted result. Signed-off-by: Antonio Ossa Guerra <aaossa@uc.cl> * Test output newlines when skipping first line When skipping the first line of source code, the reference newline must be taken from the second line of the file instead of the first one, in case that the file mixes more than one kind of newline character Signed-off-by: Antonio Ossa Guerra <aaossa@uc.cl> * Test that Blackd also skips first line correctly Simliarly to the Black tests, we first compare that Blackd fails when the first line is invalid Python syntax and then check that the result is the expected when tha flag is activated Signed-off-by: Antonio Ossa Guerra <aaossa@uc.cl> * Use the content encoding to decode the header When decoding the header to put it back at the top of the contents of the file, use the same encoding used in the content. This should be a better "guess" that using the default value Signed-off-by: Antonio Ossa Guerra <aaossa@uc.cl>
1 parent 0359b85 commit 4da0851

8 files changed

Lines changed: 76 additions & 0 deletions

File tree

CHANGES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
<!-- Changes to how Black can be configured -->
2929

3030
- `.ipynb_checkpoints` directories are now excluded by default (#3293)
31+
- Add `--skip-source-first-line` / `-x` option to ignore the first line of source code
32+
while formatting (#3299)
3133

3234
### Packaging
3335

docs/usage_and_configuration/black_as_a_server.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ is rejected with `HTTP 501` (Not Implemented).
5050
The headers controlling how source code is formatted are:
5151

5252
- `X-Line-Length`: corresponds to the `--line-length` command line flag.
53+
- `X-Skip-Source-First-Line`: corresponds to the `--skip-source-first-line` command line
54+
flag. If present and its value is not an empty string, the first line of the source
55+
code will be ignored.
5356
- `X-Skip-String-Normalization`: corresponds to the `--skip-string-normalization`
5457
command line flag. If present and its value is not the empty string, no string
5558
normalization will be performed.

src/black/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,12 @@ def validate_regex(
248248
),
249249
default=[],
250250
)
251+
@click.option(
252+
"-x",
253+
"--skip-source-first-line",
254+
is_flag=True,
255+
help="Skip the first line of the source code.",
256+
)
251257
@click.option(
252258
"-S",
253259
"--skip-string-normalization",
@@ -428,6 +434,7 @@ def main( # noqa: C901
428434
pyi: bool,
429435
ipynb: bool,
430436
python_cell_magics: Sequence[str],
437+
skip_source_first_line: bool,
431438
skip_string_normalization: bool,
432439
skip_magic_trailing_comma: bool,
433440
experimental_string_processing: bool,
@@ -528,6 +535,7 @@ def main( # noqa: C901
528535
line_length=line_length,
529536
is_pyi=pyi,
530537
is_ipynb=ipynb,
538+
skip_source_first_line=skip_source_first_line,
531539
string_normalization=not skip_string_normalization,
532540
magic_trailing_comma=not skip_magic_trailing_comma,
533541
experimental_string_processing=experimental_string_processing,
@@ -790,7 +798,10 @@ def format_file_in_place(
790798
mode = replace(mode, is_ipynb=True)
791799

792800
then = datetime.utcfromtimestamp(src.stat().st_mtime)
801+
header = b""
793802
with open(src, "rb") as buf:
803+
if mode.skip_source_first_line:
804+
header = buf.readline()
794805
src_contents, encoding, newline = decode_bytes(buf.read())
795806
try:
796807
dst_contents = format_file_contents(src_contents, fast=fast, mode=mode)
@@ -800,6 +811,8 @@ def format_file_in_place(
800811
raise ValueError(
801812
f"File '{src}' cannot be parsed as valid Jupyter notebook."
802813
) from None
814+
src_contents = header.decode(encoding) + src_contents
815+
dst_contents = header.decode(encoding) + dst_contents
803816

804817
if write_back == WriteBack.YES:
805818
with open(src, "w", encoding=encoding, newline=newline) as f:

src/black/mode.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ class Mode:
170170
string_normalization: bool = True
171171
is_pyi: bool = False
172172
is_ipynb: bool = False
173+
skip_source_first_line: bool = False
173174
magic_trailing_comma: bool = True
174175
experimental_string_processing: bool = False
175176
python_cell_magics: Set[str] = field(default_factory=set)
@@ -208,6 +209,7 @@ def get_cache_key(self) -> str:
208209
str(int(self.string_normalization)),
209210
str(int(self.is_pyi)),
210211
str(int(self.is_ipynb)),
212+
str(int(self.skip_source_first_line)),
211213
str(int(self.magic_trailing_comma)),
212214
str(int(self.experimental_string_processing)),
213215
str(int(self.preview)),

src/blackd/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
PROTOCOL_VERSION_HEADER = "X-Protocol-Version"
3131
LINE_LENGTH_HEADER = "X-Line-Length"
3232
PYTHON_VARIANT_HEADER = "X-Python-Variant"
33+
SKIP_SOURCE_FIRST_LINE = "X-Skip-Source-First-Line"
3334
SKIP_STRING_NORMALIZATION_HEADER = "X-Skip-String-Normalization"
3435
SKIP_MAGIC_TRAILING_COMMA = "X-Skip-Magic-Trailing-Comma"
3536
PREVIEW = "X-Preview"
@@ -40,6 +41,7 @@
4041
PROTOCOL_VERSION_HEADER,
4142
LINE_LENGTH_HEADER,
4243
PYTHON_VARIANT_HEADER,
44+
SKIP_SOURCE_FIRST_LINE,
4345
SKIP_STRING_NORMALIZATION_HEADER,
4446
SKIP_MAGIC_TRAILING_COMMA,
4547
PREVIEW,
@@ -111,6 +113,9 @@ async def handle(request: web.Request, executor: Executor) -> web.Response:
111113
skip_magic_trailing_comma = bool(
112114
request.headers.get(SKIP_MAGIC_TRAILING_COMMA, False)
113115
)
116+
skip_source_first_line = bool(
117+
request.headers.get(SKIP_SOURCE_FIRST_LINE, False)
118+
)
114119
preview = bool(request.headers.get(PREVIEW, False))
115120
fast = False
116121
if request.headers.get(FAST_OR_SAFE_HEADER, "safe") == "fast":
@@ -119,6 +124,7 @@ async def handle(request: web.Request, executor: Executor) -> web.Response:
119124
target_versions=versions,
120125
is_pyi=pyi,
121126
line_length=line_length,
127+
skip_source_first_line=skip_source_first_line,
122128
string_normalization=not skip_string_normalization,
123129
magic_trailing_comma=not skip_magic_trailing_comma,
124130
preview=preview,
@@ -128,6 +134,12 @@ async def handle(request: web.Request, executor: Executor) -> web.Response:
128134
req_str = req_bytes.decode(charset)
129135
then = datetime.utcnow()
130136

137+
header = ""
138+
if skip_source_first_line:
139+
first_newline_position: int = req_str.find("\n") + 1
140+
header = req_str[:first_newline_position]
141+
req_str = req_str[first_newline_position:]
142+
131143
loop = asyncio.get_event_loop()
132144
formatted_str = await loop.run_in_executor(
133145
executor, partial(black.format_file_contents, req_str, fast=fast, mode=mode)
@@ -140,6 +152,10 @@ async def handle(request: web.Request, executor: Executor) -> web.Response:
140152
if formatted_str == req_str:
141153
raise black.NothingChanged
142154

155+
# Put the source first line back
156+
req_str = header + req_str
157+
formatted_str = header + formatted_str
158+
143159
# Only output the diff in the HTTP response
144160
only_diff = bool(request.headers.get(DIFF_HEADER, False))
145161
if only_diff:
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
This is not valid Python syntax
2+
y = "This is valid syntax"

tests/test_black.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,30 @@ def test_string_quotes(self) -> None:
341341
black.assert_equivalent(source, not_normalized)
342342
black.assert_stable(source, not_normalized, mode=mode)
343343

344+
def test_skip_source_first_line(self) -> None:
345+
source, _ = read_data("miscellaneous", "invalid_header")
346+
tmp_file = Path(black.dump_to_file(source))
347+
# Full source should fail (invalid syntax at header)
348+
self.invokeBlack([str(tmp_file), "--diff", "--check"], exit_code=123)
349+
# So, skipping the first line should work
350+
result = BlackRunner().invoke(
351+
black.main, [str(tmp_file), "-x", f"--config={EMPTY_CONFIG}"]
352+
)
353+
self.assertEqual(result.exit_code, 0)
354+
with open(tmp_file, encoding="utf8") as f:
355+
actual = f.read()
356+
self.assertFormatEqual(source, actual)
357+
358+
def test_skip_source_first_line_when_mixing_newlines(self) -> None:
359+
code_mixing_newlines = b"Header will be skipped\r\ni = [1,2,3]\nj = [1,2,3]\n"
360+
expected = b"Header will be skipped\r\ni = [1, 2, 3]\nj = [1, 2, 3]\n"
361+
with TemporaryDirectory() as workspace:
362+
test_file = Path(workspace) / "skip_header.py"
363+
test_file.write_bytes(code_mixing_newlines)
364+
mode = replace(DEFAULT_MODE, skip_source_first_line=True)
365+
ff(test_file, mode=mode, write_back=black.WriteBack.YES)
366+
self.assertEqual(test_file.read_bytes(), expected)
367+
344368
def test_skip_magic_trailing_comma(self) -> None:
345369
source, _ = read_data("simple_cases", "expression")
346370
expected, _ = read_data(

tests/test_blackd.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,20 @@ async def test_blackd_invalid_line_length(self) -> None:
177177
)
178178
self.assertEqual(response.status, 400)
179179

180+
@unittest_run_loop
181+
async def test_blackd_skip_first_source_line(self) -> None:
182+
invalid_first_line = b"Header will be skipped\r\ni = [1,2,3]\nj = [1,2,3]\n"
183+
expected_result = b"Header will be skipped\r\ni = [1, 2, 3]\nj = [1, 2, 3]\n"
184+
response = await self.client.post("/", data=invalid_first_line)
185+
self.assertEqual(response.status, 400)
186+
response = await self.client.post(
187+
"/",
188+
data=invalid_first_line,
189+
headers={blackd.SKIP_SOURCE_FIRST_LINE: "true"},
190+
)
191+
self.assertEqual(response.status, 200)
192+
self.assertEqual(await response.read(), expected_result)
193+
180194
@unittest_run_loop
181195
async def test_blackd_preview(self) -> None:
182196
response = await self.client.post(

0 commit comments

Comments
 (0)