]> git.madduck.net Git - etc/vim.git/blob - blackd.py

madduck's git repository

Every one of the projects in this repository is available at the canonical URL git://git.madduck.net/madduck/pub/<projectpath> — see each project's metadata for the exact URL.

All patches and comments are welcome. Please squash your changes to logical commits before using git-format-patch and git-send-email to patches@git.madduck.net. If you'd read over the Git project's submission guidelines and adhered to them, I'd be especially grateful.

SSH access, as well as push access can be individually arranged.

If you use my repositories frequently, consider adding the following snippet to ~/.gitconfig and using the third clone URL listed for each project:

[url "git://git.madduck.net/madduck/"]
  insteadOf = madduck:

bfc410d69bfaea382d41ca5d1545b3dc05fe5743
[etc/vim.git] / blackd.py
1 import asyncio
2 from concurrent.futures import Executor, ProcessPoolExecutor
3 from functools import partial
4 import logging
5 from multiprocessing import freeze_support
6 from typing import Set, Tuple
7
8 from aiohttp import web
9 import aiohttp_cors
10 import black
11 import click
12
13 # This is used internally by tests to shut down the server prematurely
14 _stop_signal = asyncio.Event()
15
16 VERSION_HEADER = "X-Protocol-Version"
17 LINE_LENGTH_HEADER = "X-Line-Length"
18 PYTHON_VARIANT_HEADER = "X-Python-Variant"
19 SKIP_STRING_NORMALIZATION_HEADER = "X-Skip-String-Normalization"
20 FAST_OR_SAFE_HEADER = "X-Fast-Or-Safe"
21
22 BLACK_HEADERS = [
23     VERSION_HEADER,
24     LINE_LENGTH_HEADER,
25     PYTHON_VARIANT_HEADER,
26     SKIP_STRING_NORMALIZATION_HEADER,
27     FAST_OR_SAFE_HEADER,
28 ]
29
30
31 class InvalidVariantHeader(Exception):
32     pass
33
34
35 @click.command(context_settings={"help_option_names": ["-h", "--help"]})
36 @click.option(
37     "--bind-host", type=str, help="Address to bind the server to.", default="localhost"
38 )
39 @click.option("--bind-port", type=int, help="Port to listen on", default=45484)
40 @click.version_option(version=black.__version__)
41 def main(bind_host: str, bind_port: int) -> None:
42     logging.basicConfig(level=logging.INFO)
43     app = make_app()
44     ver = black.__version__
45     black.out(f"blackd version {ver} listening on {bind_host} port {bind_port}")
46     web.run_app(app, host=bind_host, port=bind_port, handle_signals=True, print=None)
47
48
49 def make_app() -> web.Application:
50     app = web.Application()
51     executor = ProcessPoolExecutor()
52
53     cors = aiohttp_cors.setup(app)
54     resource = cors.add(app.router.add_resource("/"))
55     cors.add(
56         resource.add_route("POST", partial(handle, executor=executor)),
57         {
58             "*": aiohttp_cors.ResourceOptions(
59                 allow_headers=(*BLACK_HEADERS, "Content-Type"), expose_headers="*"
60             )
61         },
62     )
63
64     return app
65
66
67 async def handle(request: web.Request, executor: Executor) -> web.Response:
68     try:
69         if request.headers.get(VERSION_HEADER, "1") != "1":
70             return web.Response(
71                 status=501, text="This server only supports protocol version 1"
72             )
73         try:
74             line_length = int(
75                 request.headers.get(LINE_LENGTH_HEADER, black.DEFAULT_LINE_LENGTH)
76             )
77         except ValueError:
78             return web.Response(status=400, text="Invalid line length header value")
79
80         if PYTHON_VARIANT_HEADER in request.headers:
81             value = request.headers[PYTHON_VARIANT_HEADER]
82             try:
83                 pyi, versions = parse_python_variant_header(value)
84             except InvalidVariantHeader as e:
85                 return web.Response(
86                     status=400,
87                     text=f"Invalid value for {PYTHON_VARIANT_HEADER}: {e.args[0]}",
88                 )
89         else:
90             pyi = False
91             versions = set()
92
93         skip_string_normalization = bool(
94             request.headers.get(SKIP_STRING_NORMALIZATION_HEADER, False)
95         )
96         fast = False
97         if request.headers.get(FAST_OR_SAFE_HEADER, "safe") == "fast":
98             fast = True
99         mode = black.FileMode(
100             target_versions=versions,
101             is_pyi=pyi,
102             line_length=line_length,
103             string_normalization=not skip_string_normalization,
104         )
105         req_bytes = await request.content.read()
106         charset = request.charset if request.charset is not None else "utf8"
107         req_str = req_bytes.decode(charset)
108         loop = asyncio.get_event_loop()
109         formatted_str = await loop.run_in_executor(
110             executor, partial(black.format_file_contents, req_str, fast=fast, mode=mode)
111         )
112         return web.Response(
113             content_type=request.content_type, charset=charset, text=formatted_str
114         )
115     except black.NothingChanged:
116         return web.Response(status=204)
117     except black.InvalidInput as e:
118         return web.Response(status=400, text=str(e))
119     except Exception as e:
120         logging.exception("Exception during handling a request")
121         return web.Response(status=500, text=str(e))
122
123
124 def parse_python_variant_header(value: str) -> Tuple[bool, Set[black.TargetVersion]]:
125     if value == "pyi":
126         return True, set()
127     else:
128         versions = set()
129         for version in value.split(","):
130             tag = "cpy"
131             if version.startswith("cpy"):
132                 version = version[len("cpy") :]
133             elif version.startswith("pypy"):
134                 tag = "pypy"
135                 version = version[len("pypy") :]
136             major_str, *rest = version.split(".")
137             try:
138                 major = int(major_str)
139                 if major not in (2, 3):
140                     raise InvalidVariantHeader("major version must be 2 or 3")
141                 if len(rest) > 0:
142                     minor = int(rest[0])
143                     if major == 2 and minor != 7:
144                         raise InvalidVariantHeader(
145                             "minor version must be 7 for Python 2"
146                         )
147                 else:
148                     # Default to lowest supported minor version.
149                     minor = 7 if major == 2 else 3
150                 version_str = f"{tag.upper()}{major}{minor}"
151                 # If PyPY is the same as CPython in some version, use
152                 # the corresponding CPython version.
153                 if tag == "pypy" and not hasattr(black.TargetVersion, version_str):
154                     version_str = f"CPY{major}{minor}"
155                 if major == 3 and not hasattr(black.TargetVersion, version_str):
156                     raise InvalidVariantHeader(f"3.{minor} is not supported")
157                 versions.add(black.TargetVersion[version_str])
158             except (KeyError, ValueError):
159                 raise InvalidVariantHeader("expected e.g. '3.7', 'pypy3.5'")
160         return False, versions
161
162
163 def patched_main() -> None:
164     freeze_support()
165     black.patch_click()
166     main()
167
168
169 if __name__ == "__main__":
170     patched_main()