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]
62
63
64@add_slots
65@dataclass(frozen=True)
66class PartialParserConfig:
67 r"""
68 An optional object that can be supplied to the parser entrypoints (e.g.
69 :func:`parse_module`) to configure the parser.
70
71 Unspecified fields will be inferred from the input source code or from the execution
72 environment.
73
74 >>> import libcst as cst
75 >>> tree = cst.parse_module("abc")
76 >>> tree.bytes
77 b'abc'
78 >>> # override the default utf-8 encoding
79 ... tree = cst.parse_module("abc", cst.PartialParserConfig(encoding="utf-32"))
80 >>> tree.bytes
81 b'\xff\xfe\x00\x00a\x00\x00\x00b\x00\x00\x00c\x00\x00\x00'
82 """
83
84 #: The version of Python that the input source code is expected to be syntactically
85 #: compatible with. This may be different from the Python interpreter being used to
86 #: run LibCST. For example, you can parse code as 3.7 with a CPython 3.6
87 #: interpreter.
88 #:
89 #: If unspecified, it will default to the syntax of the running interpreter
90 #: (rounding down from among the following list).
91 #:
92 #: Currently, only Python 3.0, 3.1, 3.3, and Python 3.5 through 3.14
93 #: syntax is supported.
94 #: The gaps did not have any syntax changes from the version prior.
95 python_version: Union[str, AutoConfig] = AutoConfig.token
96
97 #: A named tuple with the ``major`` and ``minor`` Python version numbers. This is
98 #: derived from :attr:`python_version` and should not be supplied to the
99 #: :class:`PartialParserConfig` constructor.
100 parsed_python_version: PythonVersionInfo = field(init=False)
101
102 #: The file's encoding format. When parsing a ``bytes`` object, this value may be
103 #: inferred from the contents of the parsed source code. When parsing a ``str``,
104 #: this value defaults to ``"utf-8"``.
105 encoding: Union[str, AutoConfig] = AutoConfig.token
106
107 #: Detected ``__future__`` import names
108 future_imports: Union[FrozenSet[str], AutoConfig] = AutoConfig.token
109
110 #: The indentation of the file, expressed as a series of tabs and/or spaces. This
111 #: value is inferred from the contents of the parsed source code by default.
112 default_indent: Union[str, AutoConfig] = AutoConfig.token
113
114 #: The newline of the file, expressed as ``\n``, ``\r\n``, or ``\r``. This value is
115 #: inferred from the contents of the parsed source code by default.
116 default_newline: Union[str, AutoConfig] = AutoConfig.token
117
118 def __post_init__(self) -> None:
119 raw_python_version = self.python_version
120
121 if isinstance(raw_python_version, AutoConfig):
122 # If unspecified, we'll try to pick the same as the running
123 # interpreter. There will always be at least one entry.
124 parsed_python_version = _pick_compatible_python_version()
125 else:
126 # If the caller specified a version, we require that to be a known
127 # version (because we don't want to encourage doing duplicate work
128 # when there weren't syntax changes).
129
130 # `parse_version_string` will raise a ValueError if the version is
131 # invalid.
132 parsed_python_version = parse_version_string(raw_python_version)
133
134 if not any(
135 parsed_python_version == parse_version_string(v)
136 for v in KNOWN_PYTHON_VERSION_STRINGS
137 ):
138 comma_versions = ", ".join(KNOWN_PYTHON_VERSION_STRINGS)
139 raise ValueError(
140 "LibCST can only parse code using one of the following versions of "
141 + f"Python's grammar: {comma_versions}. More versions may be "
142 + "supported by future releases."
143 )
144
145 # We use object.__setattr__ because the dataclass is frozen. See:
146 # https://docs.python.org/3/library/dataclasses.html#frozen-instances
147 # This should be safe behavior inside of `__post_init__`.
148 object.__setattr__(self, "parsed_python_version", parsed_python_version)
149
150 encoding = self.encoding
151 if not isinstance(encoding, AutoConfig):
152 try:
153 codecs.lookup(encoding)
154 except LookupError:
155 raise ValueError(f"{repr(encoding)} is not a supported encoding")
156
157 newline = self.default_newline
158 if (
159 not isinstance(newline, AutoConfig)
160 and NEWLINE_RE.fullmatch(newline) is None
161 ):
162 raise ValueError(
163 f"Got an invalid value for default_newline: {repr(newline)}"
164 )
165
166 indent = self.default_indent
167 if not isinstance(indent, AutoConfig) and _INDENT_RE.fullmatch(indent) is None:
168 raise ValueError(f"Got an invalid value for default_indent: {repr(indent)}")
169
170 def __repr__(self) -> str:
171 init_keys: List[str] = []
172
173 for f in fields(self):
174 # We don't display the parsed_python_version attribute because it contains
175 # the same value as python_version, only parsed.
176 if f.name == "parsed_python_version":
177 continue
178 value = getattr(self, f.name)
179 if not isinstance(value, AutoConfig):
180 init_keys.append(f"{f.name}={value!r}")
181
182 return f"{self.__class__.__name__}({', '.join(init_keys)})"
183
184
185def _pick_compatible_python_version(version: Optional[str] = None) -> PythonVersionInfo:
186 max_version = parse_version_string(version)
187 for v in KNOWN_PYTHON_VERSION_STRINGS[::-1]:
188 tmp = parse_version_string(v)
189 if tmp <= max_version:
190 return tmp
191
192 raise ValueError(
193 f"No version found older than {version} ({max_version}) while "
194 + f"running on {sys.version_info}"
195 )