Infer target version based on project metadata
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 2d5ac26b629..4896f9e4e57 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -48,6 +48,7 @@ repos:
           - tomli >= 0.2.6, < 2.0.0
           - types-typed-ast >= 1.4.1
           - click >= 8.1.0
+          - packaging >= 22.0
           - platformdirs >= 2.1.0
           - pytest
           - hypothesis
diff --git a/CHANGES.md b/CHANGES.md
index 313536e8480..70d54daaac2 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -42,6 +42,9 @@
 
 <!-- Changes to how Black can be configured -->
 
+- Black now tries to infer its `--target-version` from the project metadata specified in
+  `pyproject.toml` (#3219)
+
 ### Packaging
 
 <!-- Changes to how Black is packaged, such as dependency requirements -->
@@ -51,6 +54,8 @@
 - Drop specific support for the `tomli` requirement on 3.11 alpha releases, working
   around a bug that would cause the requirement not to be installed on any non-final
   Python releases (#3448)
+- Black now depends on `packaging` version `22.0` or later. This is required for new
+  functionality that needs to parse part of the project metadata (#3219)
 
 ### Parser
 
diff --git a/pyproject.toml b/pyproject.toml
index ab38908ba15..435626ac8f4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -65,6 +65,7 @@ classifiers = [
 dependencies = [
   "click>=8.0.0",
   "mypy_extensions>=0.4.3",
+  "packaging>=22.0",
   "pathspec>=0.9.0",
   "platformdirs>=2",
   "tomli>=1.1.0; python_version < '3.11'",
diff --git a/src/black/__init__.py b/src/black/__init__.py
index 5d35c805bac..eab54b8c5ab 100644
--- a/src/black/__init__.py
+++ b/src/black/__init__.py
@@ -219,8 +219,9 @@ def validate_regex(
     callback=target_version_option_callback,
     multiple=True,
     help=(
-        "Python versions that should be supported by Black's output. [default: per-file"
-        " auto-detection]"
+        "Python versions that should be supported by Black's output. By default, Black"
+        " will try to infer this from the project metadata in pyproject.toml. If this"
+        " does not yield conclusive results, Black will use per-file auto-detection."
     ),
 )
 @click.option(
diff --git a/src/black/files.py b/src/black/files.py
index ea517f4ece9..8c0131126b7 100644
--- a/src/black/files.py
+++ b/src/black/files.py
@@ -18,6 +18,8 @@
 )
 
 from mypy_extensions import mypyc_attr
+from packaging.specifiers import InvalidSpecifier, Specifier, SpecifierSet
+from packaging.version import InvalidVersion, Version
 from pathspec import PathSpec
 from pathspec.patterns.gitwildmatch import GitWildMatchPatternError
 
@@ -32,6 +34,7 @@
     import tomli as tomllib
 
 from black.handle_ipynb_magics import jupyter_dependencies_are_installed
+from black.mode import TargetVersion
 from black.output import err
 from black.report import Report
 
@@ -108,14 +111,103 @@ def find_pyproject_toml(path_search_start: Tuple[str, ...]) -> Optional[str]:
 
 @mypyc_attr(patchable=True)
 def parse_pyproject_toml(path_config: str) -> Dict[str, Any]:
-    """Parse a pyproject toml file, pulling out relevant parts for Black
+    """Parse a pyproject toml file, pulling out relevant parts for Black.
 
-    If parsing fails, will raise a tomllib.TOMLDecodeError
+    If parsing fails, will raise a tomllib.TOMLDecodeError.
     """
     with open(path_config, "rb") as f:
         pyproject_toml = tomllib.load(f)
-    config = pyproject_toml.get("tool", {}).get("black", {})
-    return {k.replace("--", "").replace("-", "_"): v for k, v in config.items()}
+    config: Dict[str, Any] = pyproject_toml.get("tool", {}).get("black", {})
+    config = {k.replace("--", "").replace("-", "_"): v for k, v in config.items()}
+
+    if "target_version" not in config:
+        inferred_target_version = infer_target_version(pyproject_toml)
+        if inferred_target_version is not None:
+            config["target_version"] = [v.name.lower() for v in inferred_target_version]
+
+    return config
+
+
+def infer_target_version(
+    pyproject_toml: Dict[str, Any]
+) -> Optional[List[TargetVersion]]:
+    """Infer Black's target version from the project metadata in pyproject.toml.
+
+    Supports the PyPA standard format (PEP 621):
+    https://packaging.python.org/en/latest/specifications/declaring-project-metadata/#requires-python
+
+    If the target version cannot be inferred, returns None.
+    """
+    project_metadata = pyproject_toml.get("project", {})
+    requires_python = project_metadata.get("requires-python", None)
+    if requires_python is not None:
+        try:
+            return parse_req_python_version(requires_python)
+        except InvalidVersion:
+            pass
+        try:
+            return parse_req_python_specifier(requires_python)
+        except (InvalidSpecifier, InvalidVersion):
+            pass
+
+    return None
+
+
+def parse_req_python_version(requires_python: str) -> Optional[List[TargetVersion]]:
+    """Parse a version string (i.e. ``"3.7"``) to a list of TargetVersion.
+
+    If parsing fails, will raise a packaging.version.InvalidVersion error.
+    If the parsed version cannot be mapped to a valid TargetVersion, returns None.
+    """
+    version = Version(requires_python)
+    if version.release[0] != 3:
+        return None
+    try:
+        return [TargetVersion(version.release[1])]
+    except (IndexError, ValueError):
+        return None
+
+
+def parse_req_python_specifier(requires_python: str) -> Optional[List[TargetVersion]]:
+    """Parse a specifier string (i.e. ``">=3.7,<3.10"``) to a list of TargetVersion.
+
+    If parsing fails, will raise a packaging.specifiers.InvalidSpecifier error.
+    If the parsed specifier cannot be mapped to a valid TargetVersion, returns None.
+    """
+    specifier_set = strip_specifier_set(SpecifierSet(requires_python))
+    if not specifier_set:
+        return None
+
+    target_version_map = {f"3.{v.value}": v for v in TargetVersion}
+    compatible_versions: List[str] = list(specifier_set.filter(target_version_map))
+    if compatible_versions:
+        return [target_version_map[v] for v in compatible_versions]
+    return None
+
+
+def strip_specifier_set(specifier_set: SpecifierSet) -> SpecifierSet:
+    """Strip minor versions for some specifiers in the specifier set.
+
+    For background on version specifiers, see PEP 440:
+    https://peps.python.org/pep-0440/#version-specifiers
+    """
+    specifiers = []
+    for s in specifier_set:
+        if "*" in str(s):
+            specifiers.append(s)
+        elif s.operator in ["~=", "==", ">=", "==="]:
+            version = Version(s.version)
+            stripped = Specifier(f"{s.operator}{version.major}.{version.minor}")
+            specifiers.append(stripped)
+        elif s.operator == ">":
+            version = Version(s.version)
+            if len(version.release) > 2:
+                s = Specifier(f">={version.major}.{version.minor}")
+            specifiers.append(s)
+        else:
+            specifiers.append(s)
+
+    return SpecifierSet(",".join(str(s) for s in specifiers))
 
 
 @lru_cache()
diff --git a/src/black/parsing.py b/src/black/parsing.py
index c37c12b868d..ba474c5e047 100644
--- a/src/black/parsing.py
+++ b/src/black/parsing.py
@@ -11,7 +11,7 @@
 else:
     from typing import Final
 
-from black.mode import Feature, TargetVersion, supports_feature
+from black.mode import VERSION_TO_FEATURES, Feature, TargetVersion, supports_feature
 from black.nodes import syms
 from blib2to3 import pygram
 from blib2to3.pgen2 import driver
@@ -52,7 +52,7 @@ def get_grammars(target_versions: Set[TargetVersion]) -> List[Grammar]:
     if not target_versions:
         # No target_version specified, so try all grammars.
         return [
-            # Python 3.7+
+            # Python 3.7-3.9
             pygram.python_grammar_no_print_statement_no_exec_statement_async_keywords,
             # Python 3.0-3.6
             pygram.python_grammar_no_print_statement_no_exec_statement,
@@ -72,7 +72,7 @@ def get_grammars(target_versions: Set[TargetVersion]) -> List[Grammar]:
     if not supports_feature(target_versions, Feature.ASYNC_KEYWORDS):
         # Python 3.0-3.6
         grammars.append(pygram.python_grammar_no_print_statement_no_exec_statement)
-    if supports_feature(target_versions, Feature.PATTERN_MATCHING):
+    if any(Feature.PATTERN_MATCHING in VERSION_TO_FEATURES[v] for v in target_versions):
         # Python 3.10+
         grammars.append(pygram.python_grammar_soft_keywords)
 
diff --git a/tests/data/project_metadata/both_pyproject.toml b/tests/data/project_metadata/both_pyproject.toml
new file mode 100644
index 00000000000..cf8f148f856
--- /dev/null
+++ b/tests/data/project_metadata/both_pyproject.toml
@@ -0,0 +1,8 @@
+[project]
+name = "test"
+version = "1.0.0"
+requires-python = ">=3.7,<3.11"
+
+[tool.black]
+line-length = 79
+target-version = ["py310"]
diff --git a/tests/data/project_metadata/neither_pyproject.toml b/tests/data/project_metadata/neither_pyproject.toml
new file mode 100644
index 00000000000..67623d2279b
--- /dev/null
+++ b/tests/data/project_metadata/neither_pyproject.toml
@@ -0,0 +1,6 @@
+[project]
+name = "test"
+version = "1.0.0"
+
+[tool.black]
+line-length = 79
diff --git a/tests/data/project_metadata/only_black_pyproject.toml b/tests/data/project_metadata/only_black_pyproject.toml
new file mode 100644
index 00000000000..94058bb3b1e
--- /dev/null
+++ b/tests/data/project_metadata/only_black_pyproject.toml
@@ -0,0 +1,7 @@
+[project]
+name = "test"
+version = "1.0.0"
+
+[tool.black]
+line-length = 79
+target-version = ["py310"]
diff --git a/tests/data/project_metadata/only_metadata_pyproject.toml b/tests/data/project_metadata/only_metadata_pyproject.toml
new file mode 100644
index 00000000000..1c8cdbb31ad
--- /dev/null
+++ b/tests/data/project_metadata/only_metadata_pyproject.toml
@@ -0,0 +1,7 @@
+[project]
+name = "test"
+version = "1.0.0"
+requires-python = ">=3.7,<3.11"
+
+[tool.black]
+line-length = 79
diff --git a/tests/test_black.py b/tests/test_black.py
index 44d617244f1..e24b47d155c 100644
--- a/tests/test_black.py
+++ b/tests/test_black.py
@@ -1559,6 +1559,72 @@ def test_parse_pyproject_toml(self) -> None:
         self.assertEqual(config["exclude"], r"\.pyi?$")
         self.assertEqual(config["include"], r"\.py?$")
 
+    def test_parse_pyproject_toml_project_metadata(self) -> None:
+        for test_toml, expected in [
+            ("only_black_pyproject.toml", ["py310"]),
+            ("only_metadata_pyproject.toml", ["py37", "py38", "py39", "py310"]),
+            ("neither_pyproject.toml", None),
+            ("both_pyproject.toml", ["py310"]),
+        ]:
+            test_toml_file = THIS_DIR / "data" / "project_metadata" / test_toml
+            config = black.parse_pyproject_toml(str(test_toml_file))
+            self.assertEqual(config.get("target_version"), expected)
+
+    def test_infer_target_version(self) -> None:
+        for version, expected in [
+            ("3.6", [TargetVersion.PY36]),
+            ("3.11.0rc1", [TargetVersion.PY311]),
+            (">=3.10", [TargetVersion.PY310, TargetVersion.PY311]),
+            (">=3.10.6", [TargetVersion.PY310, TargetVersion.PY311]),
+            ("<3.6", [TargetVersion.PY33, TargetVersion.PY34, TargetVersion.PY35]),
+            (">3.7,<3.10", [TargetVersion.PY38, TargetVersion.PY39]),
+            (">3.7,!=3.8,!=3.9", [TargetVersion.PY310, TargetVersion.PY311]),
+            (
+                "> 3.9.4, != 3.10.3",
+                [TargetVersion.PY39, TargetVersion.PY310, TargetVersion.PY311],
+            ),
+            (
+                "!=3.3,!=3.4",
+                [
+                    TargetVersion.PY35,
+                    TargetVersion.PY36,
+                    TargetVersion.PY37,
+                    TargetVersion.PY38,
+                    TargetVersion.PY39,
+                    TargetVersion.PY310,
+                    TargetVersion.PY311,
+                ],
+            ),
+            (
+                "==3.*",
+                [
+                    TargetVersion.PY33,
+                    TargetVersion.PY34,
+                    TargetVersion.PY35,
+                    TargetVersion.PY36,
+                    TargetVersion.PY37,
+                    TargetVersion.PY38,
+                    TargetVersion.PY39,
+                    TargetVersion.PY310,
+                    TargetVersion.PY311,
+                ],
+            ),
+            ("==3.8.*", [TargetVersion.PY38]),
+            (None, None),
+            ("", None),
+            ("invalid", None),
+            ("==invalid", None),
+            (">3.9,!=invalid", None),
+            ("3", None),
+            ("3.2", None),
+            ("2.7.18", None),
+            ("==2.7", None),
+            (">3.10,<3.11", None),
+        ]:
+            test_toml = {"project": {"requires-python": version}}
+            result = black.files.infer_target_version(test_toml)
+            self.assertEqual(result, expected)
+
     def test_read_pyproject_toml(self) -> None:
         test_toml_file = THIS_DIR / "test.toml"
         fake_ctx = FakeContext()

<!-- Hello! Thanks for submitting a PR. To help make things go a bit more
     smoothly we would appreciate that you go through this template. -->

### Description

<!-- Good things to put here include: reasoning for the change (please link
     any relevant issues!), any noteworthy (or hacky) choices to be aware of,
     or what the problem resolved here looked like ... we won't mind a ranty
     story :) -->

Fixes #3124

The logic implemented is as follows:
* If Black's `target_version` is specified in the `pyproject.toml`, we ignore the project metadata.
* If it is not specified, we try to read and parse the project metadata to determine a valid list of `TargetVersion`. If we fail for whatever reason, we continue without a `target_version`.
* The list of target versions should include all major Python versions allowed by the `requires-python` specifier.
  * `3.8.5` becomes `["py38"]`
  * `>3.6,<3.11` becomes `["py37", "py38", "py39", "py310"]`
  * `<3.7` becomes `["py33", "py34", "py35", "py36"]` (Python 3.3 is the minimal supported version for Black)

_(for more examples, check the test cases)_

Changes:
* Updated `parse_pyproject_toml`
  * When reading the TOML file, we check if `target_version` is specified in Black's config. If not, we try to infer it from the project metadata.
  * This seemed like the logical place - since we are reading the file anyway, we might aswell extract the project metadata if we need it.
* Added basic functionality for reading project metadata.
  * Only the PyPA standard is now supported: `project` -> `requires-python`.
  * We could add support for Poetry or other tools later.
* Added parsing of the version / specifier based on `packaging`'s `Version` and `SpecifierSet`
  * There is some black magic (pun intended) going on in the `strip_specifier_set` function that I wrote. Unfortunately, working with these kinds of specifiers is nasty when it comes to edge cases. My implementation satisfies all specifier sets I could come up with, but there are probably some corner cases that I did not cover...
* Added `packaging>=20.0` as a dependency. This is the minimum version that includes the functionality needed for this implementation.
* Updated the help text for the CLI command.

### Checklist - did you ...

<!-- If any of the following items aren't relevant for your contribution
     please still tick them so we know you've gone through the checklist.

    All user-facing changes should get an entry. Otherwise, signal to us
    this should get the magical label to silence the CHANGELOG entry check.
    Tests are required for bugfixes and new features. Documentation changes
    are necessary for formatting and most enhancement changes. -->

- [x] Add a CHANGELOG entry if necessary?
- [x] Add / update tests if necessary?
- [x] Add new / update outdated documentation? _--> Updated the CLI argument help text_

<!-- Just as a reminder, everyone in all psf/black spaces including PRs
     must follow the PSF Code of Conduct (link below).

     Finally, once again thanks for your time and effort. If you have any
     feedback in regards to your experience contributing here, please
     let us know!

     Helpful links:

      PSF COC: https://www.python.org/psf/conduct/
      Contributing docs: https://black.readthedocs.io/en/latest/contributing/index.html
      Chat on Python Discord: https://discord.gg/RtVdv86PrH -->
