Skip to content
This repository was archived by the owner on Jul 11, 2022. It is now read-only.

Commit a82f186

Browse files
zsolambv
authored andcommitted
blackd: a HTTP server for blackening (pytest-dev#460)
1 parent 8050074 commit a82f186

File tree

13 files changed

+536
-46
lines changed

13 files changed

+536
-46
lines changed

.appveyor.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
install:
22
- C:\Python36\python.exe -m pip install mypy
3-
- C:\Python36\python.exe -m pip install -e .
3+
- C:\Python36\python.exe -m pip install -e .[d]
44

55
# Not a C# project
66
build: off
77

88
test_script:
99
- C:\Python36\python.exe tests/test_black.py
10-
- C:\Python36\python.exe -m mypy black.py tests/test_black.py
10+
- C:\Python36\python.exe -m mypy black.py blackd.py tests/test_black.py

.travis.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@ language: python
44
cache: pip
55
install:
66
- pip install coverage coveralls flake8 flake8-bugbear mypy
7-
- pip install -e .
7+
- pip install -e '.[d]'
88
script:
99
- coverage run tests/test_black.py
10-
- if [[ $TRAVIS_PYTHON_VERSION == '3.6' ]]; then mypy black.py tests/test_black.py; fi
10+
- if [[ $TRAVIS_PYTHON_VERSION == '3.6' ]]; then mypy black.py blackd.py tests/test_black.py; fi
1111
- if [[ $TRAVIS_PYTHON_VERSION == '3.7' ]]; then black --check --verbose .; fi
12-
- if [[ $TRAVIS_PYTHON_VERSION == '3.8-dev' ]]; then flake8 black.py tests/test_black.py; fi
12+
- if [[ $TRAVIS_PYTHON_VERSION == '3.8-dev' ]]; then flake8 black.py blackd.py tests/test_black.py; fi
1313
after_success:
1414
- coveralls
1515
notifications:

Pipfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ verify_ssl = true
44
name = "pypi"
55

66
[packages]
7+
aiohttp = ">=3.3.2"
78
attrs = ">=17.4.0"
89
click = ">=6.5"
910
appdirs = "*"
1011
toml = ">=0.9.4"
12+
black = {editable = true, path = ".", extras = ["d"]}
1113

1214
[dev-packages]
1315
pre-commit = "*"

Pipfile.lock

Lines changed: 134 additions & 30 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Try it out now using the [Black Playground](https://black.now.sh).
3535
**[Code style](#the-black-code-style)** |
3636
**[pyproject.toml](#pyprojecttoml)** |
3737
**[Editor integration](#editor-integration)** |
38+
**[blackd](#blackd)** |
3839
**[Version control integration](#version-control-integration)** |
3940
**[Ignoring unmodified files](#ignoring-unmodified-files)** |
4041
**[Testimonials](#testimonials)** |
@@ -745,6 +746,76 @@ affect your use case.
745746

746747
This can be used for example with PyCharm's [File Watchers](https://www.jetbrains.com/help/pycharm/file-watchers.html).
747748

749+
## blackd
750+
751+
`blackd` is a small HTTP server that exposes *Black*'s functionality over
752+
a simple protocol. The main benefit of using it is to avoid paying the
753+
cost of starting up a new *Black* process every time you want to blacken
754+
a file.
755+
756+
### Usage
757+
758+
`blackd` is not packaged alongside *Black* by default because it has additional
759+
dependencies. You will need to do `pip install black[d]` to install it.
760+
761+
You can start the server on the default port, binding only to the local interface
762+
by running `blackd`. You will see a single line mentioning the server's version,
763+
and the host and port it's listening on. `blackd` will then print an access log
764+
similar to most web servers on standard output, merged with any exception traces
765+
caused by invalid formatting requests.
766+
767+
`blackd` provides even less options than *Black*. You can see them by running
768+
`blackd --help`:
769+
770+
```text
771+
Usage: blackd [OPTIONS]
772+
773+
Options:
774+
--bind-host TEXT Address to bind the server to.
775+
--bind-port INTEGER Port to listen on
776+
--version Show the version and exit.
777+
-h, --help Show this message and exit.
778+
```
779+
780+
### Protocol
781+
782+
`blackd` only accepts `POST` requests at the `/` path. The body of the request
783+
should contain the python source code to be formatted, encoded
784+
according to the `charset` field in the `Content-Type` request header. If no
785+
`charset` is specified, `blackd` assumes `UTF-8`.
786+
787+
There are a few HTTP headers that control how the source is formatted. These
788+
correspond to command line flags for *Black*. There is one exception to this:
789+
`X-Protocol-Version` which if present, should have the value `1`, otherwise the
790+
request is rejected with `HTTP 501` (Not Implemented).
791+
792+
The headers controlling how code is formatted are:
793+
794+
- `X-Line-Length`: corresponds to the `--line-length` command line flag.
795+
- `X-Skip-String-Normalization`: corresponds to the `--skip-string-normalization`
796+
command line flag. If present and its value is not the empty string, no string
797+
normalization will be performed.
798+
- `X-Fast-Or-Safe`: if set to `fast`, `blackd` will act as *Black* does when
799+
passed the `--fast` command line flag.
800+
- `X-Python-Variant`: if set to `pyi`, `blackd` will act as *Black* does when
801+
passed the `--pyi` command line flag. Otherwise, its value must correspond to
802+
a Python version. If this value represents at least Python 3.6, `blackd` will
803+
act as *Black* does when passed the `--py36` command line flag.
804+
805+
If any of these headers are set to invalid values, `blackd` returns a `HTTP 400`
806+
error response, mentioning the name of the problematic header in the message body.
807+
808+
Apart from the above, `blackd` can produce the following response codes:
809+
810+
- `HTTP 204`: If the input is already well-formatted. The response body is
811+
empty.
812+
- `HTTP 200`: If formatting was needed on the input. The response body
813+
contains the blackened Python code, and the `Content-Type` header is set
814+
accordingly.
815+
- `HTTP 400`: If the input contains a syntax error. Details of the error are
816+
returned in the response body.
817+
- `HTTP 500`: If there was any kind of error while trying to format the input.
818+
The response body contains a textual representation of the error.
748819

749820
## Version control integration
750821

@@ -850,8 +921,14 @@ More details can be found in [CONTRIBUTING](CONTRIBUTING.md).
850921

851922
### 18.8b0
852923

924+
* added `blackd`, see [its documentation](#blackd) for more info (#349)
925+
853926
* adjacent string literals are now correctly split into multiple lines (#463)
854927

928+
* added `blackd`, see [its documentation](#blackd) for more info (#349)
929+
930+
* code with `_` in numeric literals is recognized as Python 3.6+ (#461)
931+
855932
* numeric literals are now formatted by *Black* (#452, #461, #464, #469):
856933

857934
* numeric literals are normalized to include `_` separators on Python 3.6+ code

black.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,15 +79,15 @@
7979

8080

8181
class NothingChanged(UserWarning):
82-
"""Raised by :func:`format_file` when reformatted code is the same as source."""
82+
"""Raised when reformatted code is the same as source."""
8383

8484

8585
class CannotSplit(Exception):
86-
"""A readable split that fits the allotted line length is impossible.
86+
"""A readable split that fits the allotted line length is impossible."""
8787

88-
Raised by :func:`left_hand_split`, :func:`right_hand_split`, and
89-
:func:`delimiter_split`.
90-
"""
88+
89+
class InvalidInput(ValueError):
90+
"""Raised when input source code fails all parse attempts."""
9191

9292

9393
class WriteBack(Enum):
@@ -676,7 +676,7 @@ def lib2to3_parse(src_txt: str) -> Node:
676676
faulty_line = lines[lineno - 1]
677677
except IndexError:
678678
faulty_line = "<line number missing in source>"
679-
exc = ValueError(f"Cannot parse: {lineno}:{column}: {faulty_line}")
679+
exc = InvalidInput(f"Cannot parse: {lineno}:{column}: {faulty_line}")
680680
else:
681681
raise exc from None
682682

blackd.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import asyncio
2+
from concurrent.futures import Executor, ProcessPoolExecutor
3+
from functools import partial
4+
import logging
5+
6+
from aiohttp import web
7+
import black
8+
import click
9+
10+
# This is used internally by tests to shut down the server prematurely
11+
_stop_signal = asyncio.Event()
12+
13+
VERSION_HEADER = "X-Protocol-Version"
14+
LINE_LENGTH_HEADER = "X-Line-Length"
15+
PYTHON_VARIANT_HEADER = "X-Python-Variant"
16+
SKIP_STRING_NORMALIZATION_HEADER = "X-Skip-String-Normalization"
17+
FAST_OR_SAFE_HEADER = "X-Fast-Or-Safe"
18+
19+
20+
@click.command(context_settings={"help_option_names": ["-h", "--help"]})
21+
@click.option(
22+
"--bind-host", type=str, help="Address to bind the server to.", default="localhost"
23+
)
24+
@click.option("--bind-port", type=int, help="Port to listen on", default=45484)
25+
@click.version_option(version=black.__version__)
26+
def main(bind_host: str, bind_port: int) -> None:
27+
logging.basicConfig(level=logging.INFO)
28+
app = make_app()
29+
ver = black.__version__
30+
black.out(f"blackd version {ver} listening on {bind_host} port {bind_port}")
31+
web.run_app(app, host=bind_host, port=bind_port, handle_signals=True, print=None)
32+
33+
34+
def make_app() -> web.Application:
35+
app = web.Application()
36+
executor = ProcessPoolExecutor()
37+
app.add_routes([web.post("/", partial(handle, executor=executor))])
38+
return app
39+
40+
41+
async def handle(request: web.Request, executor: Executor) -> web.Response:
42+
try:
43+
if request.headers.get(VERSION_HEADER, "1") != "1":
44+
return web.Response(
45+
status=501, text="This server only supports protocol version 1"
46+
)
47+
try:
48+
line_length = int(
49+
request.headers.get(LINE_LENGTH_HEADER, black.DEFAULT_LINE_LENGTH)
50+
)
51+
except ValueError:
52+
return web.Response(status=400, text="Invalid line length header value")
53+
py36 = False
54+
pyi = False
55+
if PYTHON_VARIANT_HEADER in request.headers:
56+
value = request.headers[PYTHON_VARIANT_HEADER]
57+
if value == "pyi":
58+
pyi = True
59+
else:
60+
try:
61+
major, *rest = value.split(".")
62+
if int(major) == 3 and len(rest) > 0:
63+
if int(rest[0]) >= 6:
64+
py36 = True
65+
except ValueError:
66+
return web.Response(
67+
status=400, text=f"Invalid value for {PYTHON_VARIANT_HEADER}"
68+
)
69+
skip_string_normalization = bool(
70+
request.headers.get(SKIP_STRING_NORMALIZATION_HEADER, False)
71+
)
72+
fast = False
73+
if request.headers.get(FAST_OR_SAFE_HEADER, "safe") == "fast":
74+
fast = True
75+
mode = black.FileMode.from_configuration(
76+
py36=py36, pyi=pyi, skip_string_normalization=skip_string_normalization
77+
)
78+
req_bytes = await request.content.read()
79+
charset = request.charset if request.charset is not None else "utf8"
80+
req_str = req_bytes.decode(charset)
81+
loop = asyncio.get_event_loop()
82+
formatted_str = await loop.run_in_executor(
83+
executor,
84+
partial(
85+
black.format_file_contents,
86+
req_str,
87+
line_length=line_length,
88+
fast=fast,
89+
mode=mode,
90+
),
91+
)
92+
return web.Response(
93+
content_type=request.content_type, charset=charset, text=formatted_str
94+
)
95+
except black.NothingChanged:
96+
return web.Response(status=204)
97+
except black.InvalidInput as e:
98+
return web.Response(status=400, text=str(e))
99+
except Exception as e:
100+
logging.exception("Exception during handling a request")
101+
return web.Response(status=500, text=str(e))
102+
103+
104+
if __name__ == "__main__":
105+
black.patch_click()
106+
main()

docs/blackd.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
_build/generated/blackd.md

docs/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ Contents
5252
the_black_code_style
5353
pyproject_toml
5454
editor_integration
55+
blackd
5556
version_control_integration
5657
ignoring_unmodified_files
5758
contributing

mypy.ini

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,6 @@ check_untyped_defs=True
2929

3030
# No incremental mode
3131
cache_dir=/dev/null
32+
33+
[mypy-aiohttp.*]
34+
follow_imports=skip

0 commit comments

Comments
 (0)