1# Copyright (c) Meta Platforms, Inc. and affiliates.
2#
3# This source code is licensed under the MIT license found in the
4# LICENSE file in the root directory of this source tree.
5
6import codecs
7import re
8import sys
9from dataclasses import dataclass, field, fields
10from enum import Enum
11from typing import Any, Callable, FrozenSet, List, Mapping, Optional, Pattern, Union
12
13from libcst._add_slots import add_slots
14from libcst._nodes.whitespace import NEWLINE_RE
15from libcst._parser.parso.utils import parse_version_string, PythonVersionInfo
16
17_INDENT_RE: Pattern[str] = re.compile(r"[ \t]+")
18
19try:
20 from libcst_native import parser_config as config_mod
21
22 MockWhitespaceParserConfig = config_mod.BaseWhitespaceParserConfig
23except ImportError:
24 from libcst._parser.types import py_config as config_mod
25
26 MockWhitespaceParserConfig = config_mod.MockWhitespaceParserConfig
27
28BaseWhitespaceParserConfig = config_mod.BaseWhitespaceParserConfig
29ParserConfig = config_mod.ParserConfig
30parser_config_asdict: Callable[[ParserConfig], Mapping[str, Any]] = (
31 config_mod.parser_config_asdict
32)
33
34
35class AutoConfig(Enum):
36 """
37 A sentinel value used in PartialParserConfig
38 """
39
40 token: int = 0
41
42 def __repr__(self) -> str:
43 return str(self)
44
45
46# This list should be kept in sorted order.
47KNOWN_PYTHON_VERSION_STRINGS = [
48 "3.0",
49 "3.1",
50 "3.3",
51 "3.5",
52 "3.6",
53 "3.7",
54 "3.8",
55 "3.9",
56 "3.10",
57 "3.11",
58 "3.12",
59 "3.13",
60 "3.14",
61 "3.15",
62]
63
64
65@add_slots
66@dataclass(frozen=True)
67class PartialParserConfig:
68 r"""
69 An optional object that can be supplied to the parser entrypoints (e.g.
70 :func:`parse_module`) to configure the parser.
71
72 Unspecified fields will be inferred from the input source code or from the execution
73 environment.
74
75 >>> import libcst as cst
76 >>> tree = cst.parse_module("abc")
77 >>> tree.bytes
78 b'abc'
79 >>> # override the default utf-8 encoding
80 ... tree = cst.parse_module("abc", cst.PartialParserConfig(encoding="utf-32"))
81 >>> tree.bytes
82 b'\xff\xfe\x00\x00a\x00\x00\x00b\x00\x00\x00c\x00\x00\x00'
83 """
84
85 #: The version of Python that the input source code is expected to be syntactically
86 #: compatible with. This may be different from the Python interpreter being used to
87 #: run LibCST. For example, you can parse code as 3.7 with a CPython 3.6
88 #: interpreter.
89 #:
90 #: If unspecified, it will default to the syntax of the running interpreter
91 #: (rounding down from among the following list).
92 #:
93 #: Currently, only Python 3.0, 3.1, 3.3, and Python 3.5 through 3.15
94 #: syntax is supported.
95 #: The gaps did not have any syntax changes from the version prior.
96 python_version: Union[str, AutoConfig] = AutoConfig.token
97
98 #: A named tuple with the ``major`` and ``minor`` Python version numbers. This is
99 #: derived from :attr:`python_version` and should not be supplied to the
100 #: :class:`PartialParserConfig` constructor.
101 parsed_python_version: PythonVersionInfo = field(init=False)
102
103 #: The file's encoding format. When parsing a ``bytes`` object, this value may be
104 #: inferred from the contents of the parsed source code. When parsing a ``str``,
105 #: this value defaults to ``"utf-8"``.
106 encoding: Union[str, AutoConfig] = AutoConfig.token
107
108 #: Detected ``__future__`` import names
109 future_imports: Union[FrozenSet[str], AutoConfig] = AutoConfig.token
110
111 #: The indentation of the file, expressed as a series of tabs and/or spaces. This
112 #: value is inferred from the contents of the parsed source code by default.
113 default_indent: Union[str, AutoConfig] = AutoConfig.token
114
115 #: The newline of the file, expressed as ``\n``, ``\r\n``, or ``\r``. This value is
116 #: inferred from the contents of the parsed source code by default.
117 default_newline: Union[str, AutoConfig] = AutoConfig.token
118
119 def __post_init__(self) -> None:
120 raw_python_version = self.python_version
121
122 if isinstance(raw_python_version, AutoConfig):
123 # If unspecified, we'll try to pick the same as the running
124 # interpreter. There will always be at least one entry.
125 parsed_python_version = _pick_compatible_python_version()
126 else:
127 # If the caller specified a version, we require that to be a known
128 # version (because we don't want to encourage doing duplicate work
129 # when there weren't syntax changes).
130
131 # `parse_version_string` will raise a ValueError if the version is
132 # invalid.
133 parsed_python_version = parse_version_string(raw_python_version)
134
135 if not any(
136 parsed_python_version == parse_version_string(v)
137 for v in KNOWN_PYTHON_VERSION_STRINGS
138 ):
139 comma_versions = ", ".join(KNOWN_PYTHON_VERSION_STRINGS)
140 raise ValueError(
141 "LibCST can only parse code using one of the following versions of "
142 + f"Python's grammar: {comma_versions}. More versions may be "
143 + "supported by future releases."
144 )
145
146 # We use object.__setattr__ because the dataclass is frozen. See:
147 # https://docs.python.org/3/library/dataclasses.html#frozen-instances
148 # This should be safe behavior inside of `__post_init__`.
149 object.__setattr__(self, "parsed_python_version", parsed_python_version)
150
151 encoding = self.encoding
152 if not isinstance(encoding, AutoConfig):
153 try:
154 codecs.lookup(encoding)
155 except LookupError:
156 raise ValueError(f"{repr(encoding)} is not a supported encoding")
157
158 newline = self.default_newline
159 if (
160 not isinstance(newline, AutoConfig)
161 and NEWLINE_RE.fullmatch(newline) is None
162 ):
163 raise ValueError(
164 f"Got an invalid value for default_newline: {repr(newline)}"
165 )
166
167 indent = self.default_indent
168 if not isinstance(indent, AutoConfig) and _INDENT_RE.fullmatch(indent) is None:
169 raise ValueError(f"Got an invalid value for default_indent: {repr(indent)}")
170
171 def __repr__(self) -> str:
172 init_keys: List[str] = []
173
174 for f in fields(self):
175 # We don't display the parsed_python_version attribute because it contains
176 # the same value as python_version, only parsed.
177 if f.name == "parsed_python_version":
178 continue
179 value = getattr(self, f.name)
180 if not isinstance(value, AutoConfig):
181 init_keys.append(f"{f.name}={value!r}")
182
183 return f"{self.__class__.__name__}({', '.join(init_keys)})"
184
185
186def _pick_compatible_python_version(version: Optional[str] = None) -> PythonVersionInfo:
187 max_version = parse_version_string(version)
188 for v in KNOWN_PYTHON_VERSION_STRINGS[::-1]:
189 tmp = parse_version_string(v)
190 if tmp <= max_version:
191 return tmp
192
193 raise ValueError(
194 f"No version found older than {version} ({max_version}) while "
195 + f"running on {sys.version_info}"
196 )