From a82f1867875c906bedfe3ef675473b795d8b0440 Mon Sep 17 00:00:00 2001 From: Zsolt Dollenstein Date: Mon, 17 Sep 2018 18:02:25 +0100 Subject: [PATCH] blackd: a HTTP server for blackening (#460) --- .appveyor.yml | 4 +- .travis.yml | 6 +- Pipfile | 2 + Pipfile.lock | 164 +++++++++++++++++++++++++++++------- README.md | 77 +++++++++++++++++ black.py | 12 +-- blackd.py | 106 +++++++++++++++++++++++ docs/blackd.md | 1 + docs/index.rst | 1 + mypy.ini | 3 + pyproject.toml | 1 + setup.py | 5 +- tests/test_black.py | 200 +++++++++++++++++++++++++++++++++++++++++++- 13 files changed, 536 insertions(+), 46 deletions(-) create mode 100644 blackd.py create mode 120000 docs/blackd.md diff --git a/.appveyor.yml b/.appveyor.yml index 5003ce0..0e5582b 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -1,10 +1,10 @@ install: - C:\Python36\python.exe -m pip install mypy -- C:\Python36\python.exe -m pip install -e . +- C:\Python36\python.exe -m pip install -e .[d] # Not a C# project build: off test_script: - C:\Python36\python.exe tests/test_black.py -- C:\Python36\python.exe -m mypy black.py tests/test_black.py +- C:\Python36\python.exe -m mypy black.py blackd.py tests/test_black.py diff --git a/.travis.yml b/.travis.yml index 09c7251..04c0c08 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,12 +4,12 @@ language: python cache: pip install: - pip install coverage coveralls flake8 flake8-bugbear mypy -- pip install -e . +- pip install -e '.[d]' script: - coverage run tests/test_black.py -- if [[ $TRAVIS_PYTHON_VERSION == '3.6' ]]; then mypy black.py tests/test_black.py; fi +- if [[ $TRAVIS_PYTHON_VERSION == '3.6' ]]; then mypy black.py blackd.py tests/test_black.py; fi - if [[ $TRAVIS_PYTHON_VERSION == '3.7' ]]; then black --check --verbose .; fi -- if [[ $TRAVIS_PYTHON_VERSION == '3.8-dev' ]]; then flake8 black.py tests/test_black.py; fi +- if [[ $TRAVIS_PYTHON_VERSION == '3.8-dev' ]]; then flake8 black.py blackd.py tests/test_black.py; fi after_success: - coveralls notifications: diff --git a/Pipfile b/Pipfile index 6ac3f51..e35462e 100644 --- a/Pipfile +++ b/Pipfile @@ -4,10 +4,12 @@ verify_ssl = true name = "pypi" [packages] +aiohttp = ">=3.3.2" attrs = ">=17.4.0" click = ">=6.5" appdirs = "*" toml = ">=0.9.4" +black = {editable = true, path = ".", extras = ["d"]} [dev-packages] pre-commit = "*" diff --git a/Pipfile.lock b/Pipfile.lock index e976244..a47a6a8 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "83e7921ad3bcb0c1ee6c654ef6c2b737dd72629c2541f387ac186bedc55d33af" + "sha256": "1becc24ea9195b4295de408a96a019ad449aaee7da84555b6857bb2125749892" }, "pipfile-spec": 6, "requires": {}, @@ -14,6 +14,34 @@ ] }, "default": { + "aiohttp": { + "hashes": [ + "sha256:01a2059a0505460828854d218cf090d80db277033b8e6906144ab9bd4677fc82", + "sha256:01bcaf83911c5a88f74629f116540a1b80391e6e496e6fb8708bb2987b60da63", + "sha256:199ea4a9c424904f04a86563a8e9e2759d49e3a0bf789496714253237f16015f", + "sha256:229975cb8ff6056c8ef581383a653e7110480d52c9f46eaf560113f8d5005510", + "sha256:2bb4224e3a3d7dd2ee18f6c42c1925c3200cd46fe18ec9f293b9bc88644c4878", + "sha256:2ddf47c31048efad5a566d82822194bbb680fc1be852915c2949eb69891b5d5a", + "sha256:3bc9c87845962f583d6929f837b02b80d2544920be65daf0d0a1306ad1a2089b", + "sha256:3f88a3428f40c788321cf5b8191f9dd9e002145545fa0cefc023b4b11e17aaa7", + "sha256:4785935328facee0878c29d46f02b12f1e8e8db1cd3d9ec9af666eb163418a64", + "sha256:48e8d1973ba62a952f19a7916e54a7155f4b14505507432fc0559d8b5b0e5cad", + "sha256:5cd8662ddd7c95e99010e30cc52e20a092939844e8e8a4f37abf1866231f1880", + "sha256:6880406a0c776fbff63c0d9eb8a2d96d8134b17fafeeea01180b58ab8ff0f6f5", + "sha256:6a8e447742fc45791ffea0b3ce308f1476a9f4707fb6525a2f23b43d4b26cfb3", + "sha256:81456c04c54288928da4e7e1893314c8e74d5e9f33163e39aa47c26c5e5c7911", + "sha256:9b15efa7411dcf3b59c1f4766eb16ba1aba4531a33e54d469ee22106eabce460", + "sha256:a6132db365def76145084041cede574a0c8ed53aa1a680a3027e41ee8f291bd4", + "sha256:ddee38858a9ef52ca33cb5dd1607d07d0fb99e2efe523ecb437b1758c49622a5", + "sha256:de703f333381864dce788dbfa1a49ef4551e8f082b607a943b94b239d97965cc", + "sha256:e08cacfede41291c05b4668c3178d303d078417c013bc3d5287b2b0d0e6a3aa7", + "sha256:e4c37c7ec1e1157ae4af73fd1d7f201accebf6ed2222120bc660fd002c45cbac", + "sha256:e4f9fc91d617d2e54bda97bc1db9814918691fe799e037ccf973fda434fd2c18", + "sha256:f6f73c812c1830a06de76ccbea10a4ebb1fd46230a80f280362e84578e4932a2" + ], + "index": "pypi", + "version": "==3.4.0" + }, "appdirs": { "hashes": [ "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92", @@ -22,6 +50,14 @@ "index": "pypi", "version": "==1.4.3" }, + "async-timeout": { + "hashes": [ + "sha256:474d4bc64cee20603e225eb1ece15e248962958b45a3648a9f5cc29e827a610c", + "sha256:b3c0ddc416736619bd4a95ca31de8da6920c3b9a140c64dbef2b2fa7bf521287" + ], + "markers": "python_version >= '3.5.3'", + "version": "==3.0.0" + }, "attrs": { "hashes": [ "sha256:4b90b09eeeb9b88c35bc642cbac057e45a5fd85367b985bd2809c62b7b939265", @@ -30,6 +66,20 @@ "index": "pypi", "version": "==18.1.0" }, + "black": { + "editable": true, + "extras": [ + "d" + ], + "path": "." + }, + "chardet": { + "hashes": [ + "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", + "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" + ], + "version": "==3.0.4" + }, "click": { "hashes": [ "sha256:29f99fc6125fbc931b758dc053b3114e55c77a6e4c6c3a2674a2dc986016381d", @@ -38,12 +88,60 @@ "index": "pypi", "version": "==6.7" }, + "idna": { + "hashes": [ + "sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e", + "sha256:684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16" + ], + "version": "==2.7" + }, + "idna-ssl": { + "hashes": [ + "sha256:a933e3bb13da54383f9e8f35dc4f9cb9eb9b3b78c6b36f311254d6d0d92c6c7c" + ], + "markers": "python_version < '3.7'", + "version": "==1.1.0" + }, + "multidict": { + "hashes": [ + "sha256:1a1d76374a1e7fe93acef96b354a03c1d7f83e7512e225a527d283da0d7ba5e0", + "sha256:1d6e191965505652f194bc4c40270a842922685918a4f45e6936a6b15cc5816d", + "sha256:295961a6a88f1199e19968e15d9b42f3a191c89ec13034dbc212bf9c394c3c82", + "sha256:2be5af084de6c3b8e20d6421cb0346378a9c867dcf7c86030d6b0b550f9888e4", + "sha256:2eb99617c7a0e9f2b90b64bc1fb742611718618572747d6f3d6532b7b78755ab", + "sha256:4ba654c6b5ad1ae4a4d792abeb695b29ce981bb0f157a41d0fd227b385f2bef0", + "sha256:5ba766433c30d703f6b2c17eb0b6826c6f898e5f58d89373e235f07764952314", + "sha256:a59d58ee85b11f337b54933e8d758b2356fcdcc493248e004c9c5e5d11eedbe4", + "sha256:a6e35d28900cf87bcc11e6ca9e474db0099b78f0be0a41d95bef02d49101b5b2", + "sha256:b4df7ca9c01018a51e43937eaa41f2f5dce17a6382fda0086403bcb1f5c2cf8e", + "sha256:bbd5a6bffd3ba8bfe75b16b5e28af15265538e8be011b0b9fddc7d86a453fd4a", + "sha256:d870f399fcd58a1889e93008762a3b9a27cf7ea512818fc6e689f59495648355", + "sha256:e9404e2e19e901121c3c5c6cffd5a8ae0d1d67919c970e3b3262231175713068" + ], + "markers": "python_version >= '3.4.1'", + "version": "==4.3.1" + }, "toml": { "hashes": [ "sha256:8e86bd6ce8cc11b9620cb637466453d94f5d57ad86f17e98a98d1f73e3baab2d" ], "index": "pypi", "version": "==0.9.4" + }, + "yarl": { + "hashes": [ + "sha256:2556b779125621b311844a072e0ed367e8409a18fa12cbd68eb1258d187820f9", + "sha256:4aec0769f1799a9d4496827292c02a7b1f75c0bab56ab2b60dd94ebb57cbd5ee", + "sha256:55369d95afaacf2fa6b49c84d18b51f1704a6560c432a0f9a1aeb23f7b971308", + "sha256:6c098b85442c8fe3303e708bbb775afd0f6b29f77612e8892627bcab4b939357", + "sha256:9182cd6f93412d32e009020a44d6d170d2093646464a88aeec2aef50592f8c78", + "sha256:c8cbc21bbfa1dd7d5386d48cc814fe3d35b80f60299cdde9279046f399c3b0d8", + "sha256:db6f70a4b09cde813a4807843abaaa60f3b15fb4a2a06f9ae9c311472662daa1", + "sha256:f17495e6fe3d377e3faac68121caef6f974fcb9e046bc075bcff40d8e5cc69a4", + "sha256:f85900b9cca0c67767bb61b2b9bd53208aaa7373dae633dbe25d179b4bf38aa7" + ], + "markers": "python_version >= '3.4.1'", + "version": "==1.2.6" } }, "develop": { @@ -78,10 +176,10 @@ }, "bleach": { "hashes": [ - "sha256:b8fa79e91f96c2c2cd9fd1f9eda906efb1b88b483048978ba62fef680e962b34", - "sha256:eb7386f632349d10d9ce9d4a838b134d4731571851149f9cc2c05a9a837a9a44" + "sha256:0ee95f6167129859c5dce9b1ca291ebdb5d8cd7e382ca0e237dfd0dad63f63d8", + "sha256:24754b9a7d530bf30ce7cbc805bc6cce785660b4a10ff3a43633728438c105ab" ], - "version": "==2.1.3" + "version": "==2.1.4" }, "cached-property": { "hashes": [ @@ -92,10 +190,10 @@ }, "certifi": { "hashes": [ - "sha256:13e698f54293db9f89122b0581843a782ad0934a4fe0172d2a980ba77fc61bb7", - "sha256:9fa520c1bacfb634fa7af20a76bcbd3d5fb390481724c597da32c719a7dca4b0" + "sha256:376690d6f16d32f9d1fe8932551d80b23e9d393a8578c5633a2ed39a64861638", + "sha256:456048c7e371c089d0a77a5212fb37a2c2dce1e24146e3b7e0261736aaeaa22a" ], - "version": "==2018.4.16" + "version": "==2018.8.24" }, "cffi": { "hashes": [ @@ -152,6 +250,7 @@ "hashes": [ "sha256:0186dccca79483e3405217993b83b914ba4559fe9a8396efc4eea56561b74061", "sha256:1a625afc6f62da428df96ec325dc30866cc5781520cbd904ff4ec44cf018171c", + "sha256:207b7673ff4e177374c572feeae0e4ef33be620ec9171c08fd22e2b796e03e3d", "sha256:275905bb371a99285c74931700db3f0c078e7603bed383e8cf1a09f3ee05a3de", "sha256:50098f1c4950722521f0671e54139e0edc1837d63c990cf0f3d2c49607bb51a2", "sha256:50ed116d0b60a07df0dc7b180c28569064b9d37d1578d4c9021cff04d725cb63", @@ -163,15 +262,19 @@ "sha256:811527e9b7280b136734ed6cb6845e5fbccaeaa132ddf45f0246cbe544016957", "sha256:987b0e157f70c72a84f3c2f9ef2d7ab0f26c08f2bf326c12c087ff9eebcb3ff5", "sha256:9fc6a2183d0a9b0974ec7cdcdad42bd78a3be674cc3e65f87dd694419b3b0ab7", + "sha256:a3d17ee4ae739fe16f7501a52255c2e287ac817cfd88565b9859f70520afffea", + "sha256:ba5b5488719c0f2ced0aa1986376f7baff1a1653a8eb5fdfcf3f84c7ce46ef8d", "sha256:c573ea89dd95d41b6d8cf36799c34b6d5b1eac4aed0212dee0f0a11fb7b01e8f", "sha256:c5f1b9e8592d2c448c44e6bc0d91224b16ea5f8293908b1561de1f6d2d0658b1", "sha256:cbe581456357d8f0674d6a590b1aaf46c11d01dd0a23af147a51a798c3818034", "sha256:cf219bec69e601fe27e3974b7307d2f06082ab385d42752738ad2eb630a47d65", + "sha256:cf5014eb214d814a83a7a47407272d5db10b719dbeaf4d3cfe5969309d0fcf4b", "sha256:d08bad67fa18f7e8ff738c090628ee0cbf0505d74a991c848d6d04abfe67b697", "sha256:d6f716d7b1182bf35862b5065112f933f43dd1aa4f8097c9bcfb246f71528a34", "sha256:e08e479102627641c7cb4ece421c6ed4124820b1758765db32201136762282d9", "sha256:e20ac21418af0298437d29599f7851915497ce9f2866bc8e86b084d8911ee061", "sha256:e25f53c37e319241b9a412382140dffac98ca756ba8f360ac7ab5e30cad9670a", + "sha256:e8932bddf159064f04e946fbb64693753488de21586f20e840b3be51745c8c09", "sha256:f20900f16377f2109783ae9348d34bc80530808439591c3d3df73d5c7ef1a00c" ], "version": "==0.4.2" @@ -186,8 +289,11 @@ "hashes": [ "sha256:03481e81d558d30d230bc12999e3edffe392d244349a90f4ef9b88425fac74ba", "sha256:0b136648de27201056c1869a6c0d4e23f464750fd9a9ba9750b8336a244429ed", + "sha256:10a46017fef60e16694a30627319f38a2b9b52e90182dddb6e37dcdab0f4bf95", "sha256:198626739a79b09fa0a2f06e083ffd12eb55449b5f8bfdbeed1df4910b2ca640", + "sha256:23d341cdd4a0371820eb2b0bd6b88f5003a7438bbedb33688cd33b8eae59affd", "sha256:28b2191e7283f4f3568962e373b47ef7f0392993bb6660d079c62bd50fe9d162", + "sha256:2a5b73210bad5279ddb558d9a2bfedc7f4bf6ad7f3c988641d83c40293deaec1", "sha256:2eb564bbf7816a9d68dd3369a510be3327f1c618d2357fa6b1216994c2e3d508", "sha256:337ded681dd2ef9ca04ef5d93cfc87e52e09db2594c296b4a0a3662cb1b41249", "sha256:3a2184c6d797a125dca8367878d3b9a178b6fdd05fdc2d35d758c3006a1cd694", @@ -234,11 +340,11 @@ }, "flake8-bugbear": { "hashes": [ - "sha256:541746f0f3b2f1a8d7278e1d2d218df298996b60b02677708560db7c7e620e3b", - "sha256:5f14a99d458e29cb92be9079c970030e0dd398b2decb179d76d39a5266ea1578" + "sha256:07b6e769d7f4e168d590f7088eae40f6ddd9fa4952bed31602def65842682c83", + "sha256:0ccf56975f4db1d69dc1cf3598c99d768ebf95d0cad27d76087954aa399b515a" ], "index": "pypi", - "version": "==18.2.0" + "version": "==18.8.0" }, "flake8-mypy": { "hashes": [ @@ -263,10 +369,10 @@ }, "identify": { "hashes": [ - "sha256:0bab212939e3e83caf60788fac9bda5ea4a496f9147ed4746bb04f10ec6c0cbf", - "sha256:264f34cbd4002d5b2f4b323ae9e5776a16189363d82627f46570b9c703fea448" + "sha256:49845e70fc6b1ec3694ab930a2c558912d7de24548eebcd448f65567dc757c43", + "sha256:68daab16a3db364fa204591f97dc40bfffd1a7739f27788a4895b4d8fd3516e5" ], - "version": "==1.1.3" + "version": "==1.1.4" }, "idna": { "hashes": [ @@ -312,9 +418,9 @@ }, "nodeenv": { "hashes": [ - "sha256:0611c726af1b252908646787f4d49811aa69cd92ec19644ded06ad9d3162f88e" + "sha256:aa040ab5189bae17d272175609010be6c5b589ec4b8dbd832cc50c9e9cb7496f" ], - "version": "==1.3.1" + "version": "==1.3.2" }, "packaging": { "hashes": [ @@ -332,11 +438,11 @@ }, "pre-commit": { "hashes": [ - "sha256:2d57dd6b0c117ef8363233f256de8a3f26ee1c6e05ed96f9e2d9135ca5467d90", - "sha256:9807f29320547a8a13163c1977f6765e488a9349a01431ff4fbd196ff287b51c" + "sha256:c1472b0d73e27a5697a477f77a4973c708da4fc433cc89648e8612c8cd623b87", + "sha256:ec206de6fbcbd9381ff3169f9975571fb2efa99794c328518a5f0c06ae0b49c5" ], "index": "pypi", - "version": "==1.10.3" + "version": "==1.10.5" }, "pycodestyle": { "hashes": [ @@ -415,7 +521,6 @@ "sha256:63b52e3c866428a224f97cab011de738c36aec0185aa91cfacd418b5d58911d1", "sha256:ec22d826a36ed72a7358ff3fe56cbd4ba69dd7a6718ffd450ff0e9df7a47ce6a" ], - "markers": "python_version >= '2.6' and python_version != '3.2.*' and python_version < '4' and python_version != '3.3.*' and python_version != '3.0.*' and python_version != '3.1.*'", "version": "==2.19.1" }, "requests-toolbelt": { @@ -423,7 +528,6 @@ "sha256:42c9c170abc2cacb78b8ab23ac957945c7716249206f90874651971a4acff237", "sha256:f6a531936c6fa4c6cfce1b9c10d5c4f498d16528d2a54a22ca00011205a187b5" ], - "markers": "python_version >= '2.6' and python_version != '3.2.*' and python_version < '4' and python_version != '3.3.*' and python_version != '3.0.*' and python_version != '3.1.*'", "version": "==0.8.0" }, "six": { @@ -442,18 +546,18 @@ }, "sphinx": { "hashes": [ - "sha256:217ad9ece2156ed9f8af12b5d2c82a499ddf2c70a33c5f81864a08d8c67b9efc", - "sha256:a765c6db1e5b62aae857697cd4402a5c1a315a7b0854bbcd0fc8cdc524da5896" + "sha256:71531900af3f68625a29c4e00381bee8f85255219a3d500a3e255076a45b735e", + "sha256:a3defde5e17b5bc2aa21820674409287acc4d56bf8d009213d275e4b9d0d490d" ], "index": "pypi", - "version": "==1.7.6" + "version": "==1.7.7" }, "sphinxcontrib-websupport": { "hashes": [ "sha256:68ca7ff70785cbe1e7bccc71a48b5b6d965d79ca50629606c7861a21b206d9dd", "sha256:9de47f375baf1ea07cdb3436ff39d7a9c76042c10a769c52353ec46e4e8fc3b9" ], - "markers": "python_version != '3.2.*' and python_version >= '2.7' and python_version != '3.3.*' and python_version != '3.0.*' and python_version != '3.1.*'", + "markers": "python_version != '3.1.*' and python_version >= '2.7' and python_version != '3.3.*' and python_version != '3.0.*' and python_version != '3.2.*'", "version": "==1.1.0" }, "toml": { @@ -465,11 +569,11 @@ }, "tqdm": { "hashes": [ - "sha256:224291ee0d8c52d91b037fd90806f48c79bcd9994d3b0abc9e44b946a908fccd", - "sha256:77b8424d41b31e68f437c6dd9cd567aebc9a860507cb42fbd880a5f822d966fe" + "sha256:5ef526702c0d265d5a960a3b27f3971fac13c26cf0fb819294bfa71fc6026c88", + "sha256:a3364bd83ce4777320b862e3c8a93d7da91e20a95f06ef79bed7dd71c654cafa" ], - "markers": "python_version >= '2.6' and python_version != '3.0.*' and python_version != '3.1.*'", - "version": "==4.23.4" + "markers": "python_version != '3.1.*' and python_version != '3.0.*' and python_version >= '2.6'", + "version": "==4.25.0" }, "twine": { "hashes": [ @@ -512,7 +616,7 @@ "sha256:a68ac5e15e76e7e5dd2b8f94007233e01effe3e50e8daddf69acfd81cb686baf", "sha256:b5725a0bd4ba422ab0e66e89e030c806576753ea3ee08554382c14e685d117b5" ], - "markers": "python_version >= '2.6' and python_version != '3.2.*' and python_version < '4' and python_version != '3.3.*' and python_version != '3.0.*' and python_version != '3.1.*'", + "markers": "python_version != '3.1.*' and python_version >= '2.6' and python_version < '4' and python_version != '3.3.*' and python_version != '3.0.*' and python_version != '3.2.*'", "version": "==1.23" }, "virtualenv": { @@ -520,7 +624,7 @@ "sha256:2ce32cd126117ce2c539f0134eb89de91a8413a29baac49cbab3eb50e2026669", "sha256:ca07b4c0b54e14a91af9f34d0919790b016923d157afda5efdde55c96718f752" ], - "markers": "python_version != '3.0.*' and python_version != '3.2.*' and python_version != '3.1.*' and python_version >= '2.7'", + "markers": "python_version != '3.1.*' and python_version != '3.0.*' and python_version >= '2.7' and python_version != '3.2.*'", "version": "==16.0.0" }, "webencodings": { diff --git a/README.md b/README.md index 6d350d0..c26ef16 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Try it out now using the [Black Playground](https://black.now.sh). **[Code style](#the-black-code-style)** | **[pyproject.toml](#pyprojecttoml)** | **[Editor integration](#editor-integration)** | +**[blackd](#blackd)** | **[Version control integration](#version-control-integration)** | **[Ignoring unmodified files](#ignoring-unmodified-files)** | **[Testimonials](#testimonials)** | @@ -745,6 +746,76 @@ affect your use case. This can be used for example with PyCharm's [File Watchers](https://www.jetbrains.com/help/pycharm/file-watchers.html). +## blackd + +`blackd` is a small HTTP server that exposes *Black*'s functionality over +a simple protocol. The main benefit of using it is to avoid paying the +cost of starting up a new *Black* process every time you want to blacken +a file. + +### Usage + +`blackd` is not packaged alongside *Black* by default because it has additional +dependencies. You will need to do `pip install black[d]` to install it. + +You can start the server on the default port, binding only to the local interface +by running `blackd`. You will see a single line mentioning the server's version, +and the host and port it's listening on. `blackd` will then print an access log +similar to most web servers on standard output, merged with any exception traces +caused by invalid formatting requests. + +`blackd` provides even less options than *Black*. You can see them by running +`blackd --help`: + +```text +Usage: blackd [OPTIONS] + +Options: + --bind-host TEXT Address to bind the server to. + --bind-port INTEGER Port to listen on + --version Show the version and exit. + -h, --help Show this message and exit. +``` + +### Protocol + +`blackd` only accepts `POST` requests at the `/` path. The body of the request +should contain the python source code to be formatted, encoded +according to the `charset` field in the `Content-Type` request header. If no +`charset` is specified, `blackd` assumes `UTF-8`. + +There are a few HTTP headers that control how the source is formatted. These +correspond to command line flags for *Black*. There is one exception to this: +`X-Protocol-Version` which if present, should have the value `1`, otherwise the +request is rejected with `HTTP 501` (Not Implemented). + +The headers controlling how code is formatted are: + + - `X-Line-Length`: corresponds to the `--line-length` command line flag. + - `X-Skip-String-Normalization`: corresponds to the `--skip-string-normalization` + command line flag. If present and its value is not the empty string, no string + normalization will be performed. + - `X-Fast-Or-Safe`: if set to `fast`, `blackd` will act as *Black* does when + passed the `--fast` command line flag. + - `X-Python-Variant`: if set to `pyi`, `blackd` will act as *Black* does when + passed the `--pyi` command line flag. Otherwise, its value must correspond to + a Python version. If this value represents at least Python 3.6, `blackd` will + act as *Black* does when passed the `--py36` command line flag. + +If any of these headers are set to invalid values, `blackd` returns a `HTTP 400` +error response, mentioning the name of the problematic header in the message body. + +Apart from the above, `blackd` can produce the following response codes: + + - `HTTP 204`: If the input is already well-formatted. The response body is + empty. + - `HTTP 200`: If formatting was needed on the input. The response body + contains the blackened Python code, and the `Content-Type` header is set + accordingly. + - `HTTP 400`: If the input contains a syntax error. Details of the error are + returned in the response body. + - `HTTP 500`: If there was any kind of error while trying to format the input. + The response body contains a textual representation of the error. ## Version control integration @@ -850,8 +921,14 @@ More details can be found in [CONTRIBUTING](CONTRIBUTING.md). ### 18.8b0 +* added `blackd`, see [its documentation](#blackd) for more info (#349) + * adjacent string literals are now correctly split into multiple lines (#463) +* added `blackd`, see [its documentation](#blackd) for more info (#349) + +* code with `_` in numeric literals is recognized as Python 3.6+ (#461) + * numeric literals are now formatted by *Black* (#452, #461, #464, #469): * numeric literals are normalized to include `_` separators on Python 3.6+ code diff --git a/black.py b/black.py index 8d53194..d257563 100644 --- a/black.py +++ b/black.py @@ -79,15 +79,15 @@ syms = pygram.python_symbols class NothingChanged(UserWarning): - """Raised by :func:`format_file` when reformatted code is the same as source.""" + """Raised when reformatted code is the same as source.""" class CannotSplit(Exception): - """A readable split that fits the allotted line length is impossible. + """A readable split that fits the allotted line length is impossible.""" - Raised by :func:`left_hand_split`, :func:`right_hand_split`, and - :func:`delimiter_split`. - """ + +class InvalidInput(ValueError): + """Raised when input source code fails all parse attempts.""" class WriteBack(Enum): @@ -676,7 +676,7 @@ def lib2to3_parse(src_txt: str) -> Node: faulty_line = lines[lineno - 1] except IndexError: faulty_line = "" - exc = ValueError(f"Cannot parse: {lineno}:{column}: {faulty_line}") + exc = InvalidInput(f"Cannot parse: {lineno}:{column}: {faulty_line}") else: raise exc from None diff --git a/blackd.py b/blackd.py new file mode 100644 index 0000000..f2bbc8a --- /dev/null +++ b/blackd.py @@ -0,0 +1,106 @@ +import asyncio +from concurrent.futures import Executor, ProcessPoolExecutor +from functools import partial +import logging + +from aiohttp import web +import black +import click + +# This is used internally by tests to shut down the server prematurely +_stop_signal = asyncio.Event() + +VERSION_HEADER = "X-Protocol-Version" +LINE_LENGTH_HEADER = "X-Line-Length" +PYTHON_VARIANT_HEADER = "X-Python-Variant" +SKIP_STRING_NORMALIZATION_HEADER = "X-Skip-String-Normalization" +FAST_OR_SAFE_HEADER = "X-Fast-Or-Safe" + + +@click.command(context_settings={"help_option_names": ["-h", "--help"]}) +@click.option( + "--bind-host", type=str, help="Address to bind the server to.", default="localhost" +) +@click.option("--bind-port", type=int, help="Port to listen on", default=45484) +@click.version_option(version=black.__version__) +def main(bind_host: str, bind_port: int) -> None: + logging.basicConfig(level=logging.INFO) + app = make_app() + ver = black.__version__ + black.out(f"blackd version {ver} listening on {bind_host} port {bind_port}") + web.run_app(app, host=bind_host, port=bind_port, handle_signals=True, print=None) + + +def make_app() -> web.Application: + app = web.Application() + executor = ProcessPoolExecutor() + app.add_routes([web.post("/", partial(handle, executor=executor))]) + return app + + +async def handle(request: web.Request, executor: Executor) -> web.Response: + try: + if request.headers.get(VERSION_HEADER, "1") != "1": + return web.Response( + status=501, text="This server only supports protocol version 1" + ) + try: + line_length = int( + request.headers.get(LINE_LENGTH_HEADER, black.DEFAULT_LINE_LENGTH) + ) + except ValueError: + return web.Response(status=400, text="Invalid line length header value") + py36 = False + pyi = False + if PYTHON_VARIANT_HEADER in request.headers: + value = request.headers[PYTHON_VARIANT_HEADER] + if value == "pyi": + pyi = True + else: + try: + major, *rest = value.split(".") + if int(major) == 3 and len(rest) > 0: + if int(rest[0]) >= 6: + py36 = True + except ValueError: + return web.Response( + status=400, text=f"Invalid value for {PYTHON_VARIANT_HEADER}" + ) + skip_string_normalization = bool( + request.headers.get(SKIP_STRING_NORMALIZATION_HEADER, False) + ) + fast = False + if request.headers.get(FAST_OR_SAFE_HEADER, "safe") == "fast": + fast = True + mode = black.FileMode.from_configuration( + py36=py36, pyi=pyi, skip_string_normalization=skip_string_normalization + ) + req_bytes = await request.content.read() + charset = request.charset if request.charset is not None else "utf8" + req_str = req_bytes.decode(charset) + loop = asyncio.get_event_loop() + formatted_str = await loop.run_in_executor( + executor, + partial( + black.format_file_contents, + req_str, + line_length=line_length, + fast=fast, + mode=mode, + ), + ) + return web.Response( + content_type=request.content_type, charset=charset, text=formatted_str + ) + except black.NothingChanged: + return web.Response(status=204) + except black.InvalidInput as e: + return web.Response(status=400, text=str(e)) + except Exception as e: + logging.exception("Exception during handling a request") + return web.Response(status=500, text=str(e)) + + +if __name__ == "__main__": + black.patch_click() + main() diff --git a/docs/blackd.md b/docs/blackd.md new file mode 120000 index 0000000..015a8e8 --- /dev/null +++ b/docs/blackd.md @@ -0,0 +1 @@ +_build/generated/blackd.md \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index fab9851..4291de7 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -52,6 +52,7 @@ Contents the_black_code_style pyproject_toml editor_integration + blackd version_control_integration ignoring_unmodified_files contributing diff --git a/mypy.ini b/mypy.ini index 8fa7236..841b600 100644 --- a/mypy.ini +++ b/mypy.ini @@ -29,3 +29,6 @@ check_untyped_defs=True # No incremental mode cache_dir=/dev/null + +[mypy-aiohttp.*] +follow_imports=skip diff --git a/pyproject.toml b/pyproject.toml index aa44ec3..915f6d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,7 @@ attrs = "^17.4" click = "^6.5" toml = "^0.9.4" appdirs = "^1.4" +aiohttp = "^3.4" [tool.poetry.dev-dependencies] Sphinx = "^1.7" diff --git a/setup.py b/setup.py index ac0f874..2b255f8 100644 --- a/setup.py +++ b/setup.py @@ -36,12 +36,13 @@ setup( author_email="lukasz@langa.pl", url="https://github.com/ambv/black", license="MIT", - py_modules=["black"], + py_modules=["black", "blackd"], packages=["blib2to3", "blib2to3.pgen2"], package_data={"blib2to3": ["*.txt"]}, python_requires=">=3.6", zip_safe=False, install_requires=["click>=6.5", "attrs>=17.4.0", "appdirs", "toml>=0.9.4"], + extras_require={"d": ["aiohttp>=3.3.2"]}, test_suite="tests.test_black", classifiers=[ "Development Status :: 4 - Beta", @@ -56,5 +57,5 @@ setup( "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Software Development :: Quality Assurance", ], - entry_points={"console_scripts": ["black=black:main"]}, + entry_points={"console_scripts": ["black=black:main", "blackd=blackd:main [d]"]}, ) diff --git a/tests/test_black.py b/tests/test_black.py index 85ab2b6..6eaca98 100644 --- a/tests/test_black.py +++ b/tests/test_black.py @@ -2,14 +2,24 @@ import asyncio from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager -from functools import partial +from functools import partial, wraps from io import BytesIO, TextIOWrapper import os from pathlib import Path import re import sys from tempfile import TemporaryDirectory -from typing import Any, BinaryIO, Generator, List, Tuple, Iterator +from typing import ( + Any, + BinaryIO, + Callable, + Coroutine, + Generator, + List, + Tuple, + Iterator, + TypeVar, +) import unittest from unittest.mock import patch, MagicMock @@ -18,6 +28,14 @@ from click.testing import CliRunner import black +try: + import blackd + from aiohttp.test_utils import TestClient, TestServer +except ImportError: + has_blackd_deps = False +else: + has_blackd_deps = True + ll = 88 ff = partial(black.format_file_in_place, line_length=ll, fast=True) @@ -25,6 +43,8 @@ fs = partial(black.format_str, line_length=ll) THIS_FILE = Path(__file__) THIS_DIR = THIS_FILE.parent EMPTY_LINE = "# EMPTY LINE WITH WHITESPACE" + " (this comment will be removed)" +T = TypeVar("T") +R = TypeVar("R") def dump_to_stderr(*output: str) -> str: @@ -79,6 +99,15 @@ def event_loop(close: bool) -> Iterator[None]: loop.close() +def async_test(f: Callable[..., Coroutine[Any, None, R]]) -> Callable[..., None]: + @event_loop(close=True) + @wraps(f) + def wrapper(*args: Any, **kwargs: Any) -> None: + asyncio.get_event_loop().run_until_complete(f(*args, **kwargs)) + + return wrapper + + class BlackRunner(CliRunner): """Modify CliRunner so that stderr is not merged with stdout. @@ -824,7 +853,7 @@ class BlackTestCase(unittest.TestCase): actual = black.format_file_contents(different, line_length=ll, fast=False) self.assertEqual(expected, actual) invalid = "return if you can" - with self.assertRaises(ValueError) as e: + with self.assertRaises(black.InvalidInput) as e: black.format_file_contents(invalid, line_length=ll, fast=False) self.assertEqual(str(e.exception), "Cannot parse: 1:7: return if you can") @@ -1289,6 +1318,171 @@ class BlackTestCase(unittest.TestCase): except RuntimeError as re: self.fail(f"`patch_click()` failed, exception still raised: {re}") + @unittest.skipUnless(has_blackd_deps, "blackd's dependencies are not installed") + @async_test + async def test_blackd_request_needs_formatting(self) -> None: + app = blackd.make_app() + async with TestClient(TestServer(app)) as client: + response = await client.post("/", data=b"print('hello world')") + self.assertEqual(response.status, 200) + self.assertEqual(response.charset, "utf8") + self.assertEqual(await response.read(), b'print("hello world")\n') + + @unittest.skipUnless(has_blackd_deps, "blackd's dependencies are not installed") + @async_test + async def test_blackd_request_no_change(self) -> None: + app = blackd.make_app() + async with TestClient(TestServer(app)) as client: + response = await client.post("/", data=b'print("hello world")\n') + self.assertEqual(response.status, 204) + self.assertEqual(await response.read(), b"") + + @unittest.skipUnless(has_blackd_deps, "blackd's dependencies are not installed") + @async_test + async def test_blackd_request_syntax_error(self) -> None: + app = blackd.make_app() + async with TestClient(TestServer(app)) as client: + response = await client.post("/", data=b"what even ( is") + self.assertEqual(response.status, 400) + content = await response.text() + self.assertTrue( + content.startswith("Cannot parse"), + msg=f"Expected error to start with 'Cannot parse', got {repr(content)}", + ) + + @unittest.skipUnless(has_blackd_deps, "blackd's dependencies are not installed") + @async_test + async def test_blackd_unsupported_version(self) -> None: + app = blackd.make_app() + async with TestClient(TestServer(app)) as client: + response = await client.post( + "/", data=b"what", headers={blackd.VERSION_HEADER: "2"} + ) + self.assertEqual(response.status, 501) + + @unittest.skipUnless(has_blackd_deps, "blackd's dependencies are not installed") + @async_test + async def test_blackd_supported_version(self) -> None: + app = blackd.make_app() + async with TestClient(TestServer(app)) as client: + response = await client.post( + "/", data=b"what", headers={blackd.VERSION_HEADER: "1"} + ) + self.assertEqual(response.status, 200) + + @unittest.skipUnless(has_blackd_deps, "blackd's dependencies are not installed") + @async_test + async def test_blackd_invalid_python_variant(self) -> None: + app = blackd.make_app() + async with TestClient(TestServer(app)) as client: + response = await client.post( + "/", data=b"what", headers={blackd.PYTHON_VARIANT_HEADER: "lol"} + ) + self.assertEqual(response.status, 400) + + @unittest.skipUnless(has_blackd_deps, "blackd's dependencies are not installed") + @async_test + async def test_blackd_pyi(self) -> None: + app = blackd.make_app() + async with TestClient(TestServer(app)) as client: + source, expected = read_data("stub.pyi") + response = await client.post( + "/", data=source, headers={blackd.PYTHON_VARIANT_HEADER: "pyi"} + ) + self.assertEqual(response.status, 200) + self.assertEqual(await response.text(), expected) + + @unittest.skipUnless(has_blackd_deps, "blackd's dependencies are not installed") + @async_test + async def test_blackd_py36(self) -> None: + app = blackd.make_app() + async with TestClient(TestServer(app)) as client: + response = await client.post( + "/", + data=( + "def f(\n" + " and_has_a_bunch_of,\n" + " very_long_arguments_too,\n" + " and_lots_of_them_as_well_lol,\n" + " **and_very_long_keyword_arguments\n" + "):\n" + " pass\n" + ), + headers={blackd.PYTHON_VARIANT_HEADER: "3.6"}, + ) + self.assertEqual(response.status, 200) + response = await client.post( + "/", + data=( + "def f(\n" + " and_has_a_bunch_of,\n" + " very_long_arguments_too,\n" + " and_lots_of_them_as_well_lol,\n" + " **and_very_long_keyword_arguments\n" + "):\n" + " pass\n" + ), + headers={blackd.PYTHON_VARIANT_HEADER: "3.5"}, + ) + self.assertEqual(response.status, 204) + response = await client.post( + "/", + data=( + "def f(\n" + " and_has_a_bunch_of,\n" + " very_long_arguments_too,\n" + " and_lots_of_them_as_well_lol,\n" + " **and_very_long_keyword_arguments\n" + "):\n" + " pass\n" + ), + headers={blackd.PYTHON_VARIANT_HEADER: "2"}, + ) + self.assertEqual(response.status, 204) + + @unittest.skipUnless(has_blackd_deps, "blackd's dependencies are not installed") + @async_test + async def test_blackd_fast(self) -> None: + app = blackd.make_app() + async with TestClient(TestServer(app)) as client: + response = await client.post("/", data=b"ur'hello'") + self.assertEqual(response.status, 500) + self.assertIn("failed to parse source file", await response.text()) + response = await client.post( + "/", data=b"ur'hello'", headers={blackd.FAST_OR_SAFE_HEADER: "fast"} + ) + self.assertEqual(response.status, 200) + + @unittest.skipUnless(has_blackd_deps, "blackd's dependencies are not installed") + @async_test + async def test_blackd_line_length(self) -> None: + app = blackd.make_app() + async with TestClient(TestServer(app)) as client: + response = await client.post( + "/", data=b'print("hello")\n', headers={blackd.LINE_LENGTH_HEADER: "7"} + ) + self.assertEqual(response.status, 200) + + @unittest.skipUnless(has_blackd_deps, "blackd's dependencies are not installed") + @async_test + async def test_blackd_invalid_line_length(self) -> None: + app = blackd.make_app() + async with TestClient(TestServer(app)) as client: + response = await client.post( + "/", + data=b'print("hello")\n', + headers={blackd.LINE_LENGTH_HEADER: "NaN"}, + ) + self.assertEqual(response.status, 400) + + @unittest.skipUnless(has_blackd_deps, "blackd's dependencies are not installed") + def test_blackd_main(self) -> None: + with patch("blackd.web.run_app"): + result = CliRunner().invoke(blackd.main, []) + if result.exception is not None: + raise result.exception + self.assertEqual(result.exit_code, 0) + if __name__ == "__main__": unittest.main(module="test_black") -- 2.39.2