Skip to content

Commit

Permalink
feat: add schema and validate-pyproject support (#4181)
Browse files Browse the repository at this point in the history
Signed-off-by: Henry Schreiner <henryschreineriii@gmail.com>
Co-authored-by: Jelle Zijlstra <jelle.zijlstra@gmail.com>
  • Loading branch information
henryiii and JelleZijlstra authored Jan 29, 2024
1 parent 177e306 commit 2bc5ce8
Show file tree
Hide file tree
Showing 11 changed files with 288 additions and 5 deletions.
5 changes: 5 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,8 @@ jobs:
- name: Format ourselves
run: |
tox -e run_self
- name: Regenerate schema
run: |
tox -e generate_schema
git diff --exit-code
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,4 +106,4 @@ jobs:
python -m pip install -e ".[uvloop]"
- name: Format ourselves
run: python -m black --check .
run: python -m black --check src/ tests/
11 changes: 8 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@ repos:
rev: v1.8.0
hooks:
- id: mypy
exclude: ^docs/conf.py
args: ["--config-file", "pyproject.toml"]
additional_dependencies:
exclude: ^(docs/conf.py|scripts/generate_schema.py)$
args: []
additional_dependencies: &mypy_deps
- types-PyYAML
- tomli >= 0.2.6, < 2.0.0
- click >= 8.1.0, != 8.1.4, != 8.1.5
Expand All @@ -56,6 +56,11 @@ repos:
- types-commonmark
- urllib3
- hypothesmith
- id: mypy
name: mypy (Python 3.10)
files: scripts/generate_schema.py
args: ["--python-version=3.10"]
additional_dependencies: *mypy_deps

- repo: https://github.com/pre-commit/mirrors-prettier
rev: v4.0.0-alpha.8
Expand Down
2 changes: 2 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@

<!-- For example, Docker, GitHub Actions, pre-commit, editors -->

- Add a JSONSchema and provide a validate-pyproject entry-point (#4181)

### Documentation

<!-- Major changes to documentation and policies. Small docs changes
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ jupyter = [
black = "black:patched_main"
blackd = "blackd:patched_main [d]"

[project.entry-points."validate_pyproject.tool_schema"]
black = "black.schema:get_schema"

[project.urls]
Changelog = "https://github.com/psf/black/blob/main/CHANGES.md"
Homepage = "https://github.com/psf/black"
Expand Down
74 changes: 74 additions & 0 deletions scripts/generate_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import json
from typing import IO, Any

import click

import black


def generate_schema_from_click(
cmd: click.Command,
) -> dict[str, Any]:
result: dict[str, dict[str, Any]] = {}
for param in cmd.params:
if not isinstance(param, click.Option) or param.is_eager:
continue

assert param.name
name = param.name.replace("_", "-")

result[name] = {}

match param.type:
case click.types.IntParamType():
result[name]["type"] = "integer"
case click.types.StringParamType() | click.types.Path():
result[name]["type"] = "string"
case click.types.Choice(choices=choices):
result[name]["enum"] = choices
case click.types.BoolParamType():
result[name]["type"] = "boolean"
case _:
msg = f"{param.type!r} not a known type for {param}"
raise TypeError(msg)

if param.multiple:
result[name] = {"type": "array", "items": result[name]}

result[name]["description"] = param.help

if param.default is not None and not param.multiple:
result[name]["default"] = param.default

return result


@click.command(context_settings={"help_option_names": ["-h", "--help"]})
@click.option("--schemastore", is_flag=True, help="SchemaStore format")
@click.option("--outfile", type=click.File(mode="w"), help="Write to file")
def main(schemastore: bool, outfile: IO[str]) -> None:
properties = generate_schema_from_click(black.main)
del properties["line-ranges"]

schema: dict[str, Any] = {
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": (
"https://github.com/psf/black/blob/main/black/resources/black.schema.json"
),
"$comment": "tool.black table in pyproject.toml",
"type": "object",
"additionalProperties": False,
"properties": properties,
}

if schemastore:
schema["$id"] = ("https://json.schemastore.org/partial-black.json",)
# The precise list of unstable features may change frequently, so don't
# bother putting it in SchemaStore
schema["properties"]["enable-unstable-feature"]["items"] = {"type": "string"}

print(json.dumps(schema, indent=2), file=outfile)


if __name__ == "__main__":
main()
Empty file added src/black/resources/__init__.py
Empty file.
149 changes: 149 additions & 0 deletions src/black/resources/black.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://github.com/psf/black/blob/main/black/resources/black.schema.json",
"$comment": "tool.black table in pyproject.toml",
"type": "object",
"additionalProperties": false,
"properties": {
"code": {
"type": "string",
"description": "Format the code passed in as a string."
},
"line-length": {
"type": "integer",
"description": "How many characters per line to allow.",
"default": 88
},
"target-version": {
"type": "array",
"items": {
"enum": [
"py33",
"py34",
"py35",
"py36",
"py37",
"py38",
"py39",
"py310",
"py311",
"py312"
]
},
"description": "Python versions that should be supported by Black's output. You should include all versions that your code supports. By default, Black will infer target versions from the project metadata in pyproject.toml. If this does not yield conclusive results, Black will use per-file auto-detection."
},
"pyi": {
"type": "boolean",
"description": "Format all input files like typing stubs regardless of file extension. This is useful when piping source on standard input.",
"default": false
},
"ipynb": {
"type": "boolean",
"description": "Format all input files like Jupyter Notebooks regardless of file extension. This is useful when piping source on standard input.",
"default": false
},
"python-cell-magics": {
"type": "array",
"items": {
"type": "string"
},
"description": "When processing Jupyter Notebooks, add the given magic to the list of known python-magics (capture, prun, pypy, python, python3, time, timeit). Useful for formatting cells with custom python magics."
},
"skip-source-first-line": {
"type": "boolean",
"description": "Skip the first line of the source code.",
"default": false
},
"skip-string-normalization": {
"type": "boolean",
"description": "Don't normalize string quotes or prefixes.",
"default": false
},
"skip-magic-trailing-comma": {
"type": "boolean",
"description": "Don't use trailing commas as a reason to split lines.",
"default": false
},
"preview": {
"type": "boolean",
"description": "Enable potentially disruptive style changes that may be added to Black's main functionality in the next major release.",
"default": false
},
"unstable": {
"type": "boolean",
"description": "Enable potentially disruptive style changes that have known bugs or are not currently expected to make it into the stable style Black's next major release. Implies --preview.",
"default": false
},
"enable-unstable-feature": {
"type": "array",
"items": {
"enum": [
"hex_codes_in_unicode_sequences",
"string_processing",
"hug_parens_with_braces_and_square_brackets",
"unify_docstring_detection",
"no_normalize_fmt_skip_whitespace",
"wrap_long_dict_values_in_parens",
"multiline_string_handling",
"typed_params_trailing_comma"
]
},
"description": "Enable specific features included in the `--unstable` style. Requires `--preview`. No compatibility guarantees are provided on the behavior or existence of any unstable features."
},
"check": {
"type": "boolean",
"description": "Don't write the files back, just return the status. Return code 0 means nothing would change. Return code 1 means some files would be reformatted. Return code 123 means there was an internal error.",
"default": false
},
"diff": {
"type": "boolean",
"description": "Don't write the files back, just output a diff to indicate what changes Black would've made. They are printed to stdout so capturing them is simple.",
"default": false
},
"color": {
"type": "boolean",
"description": "Show (or do not show) colored diff. Only applies when --diff is given.",
"default": false
},
"fast": {
"type": "boolean",
"description": "By default, Black performs an AST safety check after formatting your code. The --fast flag turns off this check and the --safe flag explicitly enables it. [default: --safe]",
"default": false
},
"required-version": {
"type": "string",
"description": "Require a specific version of Black to be running. This is useful for ensuring that all contributors to your project are using the same version, because different versions of Black may format code a little differently. This option can be set in a configuration file for consistent results across environments."
},
"exclude": {
"type": "string",
"description": "A regular expression that matches files and directories that should be excluded on recursive searches. An empty value means no paths are excluded. Use forward slashes for directories on all platforms (Windows, too). By default, Black also ignores all paths listed in .gitignore. Changing this value will override all default exclusions. [default: /(\\.direnv|\\.eggs|\\.git|\\.hg|\\.ipynb_checkpoints|\\.mypy_cache|\\.nox|\\.pytest_cache|\\.ruff_cache|\\.tox|\\.svn|\\.venv|\\.vscode|__pypackages__|_build|buck-out|build|dist|venv)/]"
},
"extend-exclude": {
"type": "string",
"description": "Like --exclude, but adds additional files and directories on top of the default values instead of overriding them."
},
"force-exclude": {
"type": "string",
"description": "Like --exclude, but files and directories matching this regex will be excluded even when they are passed explicitly as arguments. This is useful when invoking Black programmatically on changed files, such as in a pre-commit hook or editor plugin."
},
"include": {
"type": "string",
"description": "A regular expression that matches files and directories that should be included on recursive searches. An empty value means all files are included regardless of the name. Use forward slashes for directories on all platforms (Windows, too). Overrides all exclusions, including from .gitignore and command line options.",
"default": "(\\.pyi?|\\.ipynb)$"
},
"workers": {
"type": "integer",
"description": "When Black formats multiple files, it may use a process pool to speed up formatting. This option controls the number of parallel workers. This can also be specified via the BLACK_NUM_WORKERS environment variable. Defaults to the number of CPUs in the system."
},
"quiet": {
"type": "boolean",
"description": "Stop emitting all non-critical output. Error messages will still be emitted (which can silenced by 2>/dev/null).",
"default": false
},
"verbose": {
"type": "boolean",
"description": "Emit messages about files that were not changed or were ignored due to exclusion patterns. If Black is using a configuration file, a message detailing which one it is using will be emitted.",
"default": false
}
}
}
20 changes: 20 additions & 0 deletions src/black/schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import importlib.resources
import json
import sys
from typing import Any


def get_schema(tool_name: str = "black") -> Any:
"""Get the stored complete schema for black's settings."""
assert tool_name == "black", "Only black is supported."

pkg = "black.resources"
fname = "black.schema.json"

if sys.version_info < (3, 9):
with importlib.resources.open_text(pkg, fname, encoding="utf-8") as f:
return json.load(f)

schema = importlib.resources.files(pkg).joinpath(fname) # type: ignore[unreachable]
with schema.open(encoding="utf-8") as f:
return json.load(f)
17 changes: 17 additions & 0 deletions tests/test_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import importlib.metadata
import sys


def test_schema_entrypoint() -> None:
if sys.version_info < (3, 10):
eps = importlib.metadata.entry_points()["validate_pyproject.tool_schema"]
(black_ep,) = [ep for ep in eps if ep.name == "black"]
else:
(black_ep,) = importlib.metadata.entry_points(
group="validate_pyproject.tool_schema", name="black"
)

black_fn = black_ep.load()
schema = black_fn()
assert schema == black_fn("black")
assert schema["properties"]["line-length"]["type"] == "integer"
10 changes: 9 additions & 1 deletion tox.ini
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tox]
isolated_build = true
envlist = {,ci-}py{38,39,310,311,py3},fuzz,run_self
envlist = {,ci-}py{38,39,310,311,py3},fuzz,run_self,generate_schema

[testenv]
setenv =
Expand Down Expand Up @@ -96,3 +96,11 @@ skip_install = True
commands =
pip install -e .
black --check {toxinidir}/src {toxinidir}/tests

[testenv:generate_schema]
setenv = PYTHONWARNDEFAULTENCODING =
skip_install = True
deps =
commands =
pip install -e .
python {toxinidir}/scripts/generate_schema.py --outfile {toxinidir}/src/black/resources/black.schema.json

0 comments on commit 2bc5ce8

Please sign in to comment.