1# Copyright 2015 Google Inc. All Rights Reserved.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7#     http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14"""Interface to file resources.
15
16This module provides functions for interfacing with files: opening, writing, and
17querying.
18"""
19
20import fnmatch
21import os
22import re
23
24from lib2to3.pgen2 import tokenize
25
26from yapf.yapflib import errors
27from yapf.yapflib import py3compat
28from yapf.yapflib import style
29
30CR = '\r'
31LF = '\n'
32CRLF = '\r\n'
33
34
35def _GetExcludePatternsFromFile(filename):
36  """Get a list of file patterns to ignore."""
37  ignore_patterns = []
38  # See if we have a .yapfignore file.
39  if os.path.isfile(filename) and os.access(filename, os.R_OK):
40    with open(filename, 'r') as fd:
41      for line in fd:
42        if line.strip() and not line.startswith('#'):
43          ignore_patterns.append(line.strip())
44
45    if any(e.startswith('./') for e in ignore_patterns):
46      raise errors.YapfError('path in .yapfignore should not start with ./')
47
48  return ignore_patterns
49
50
51def GetExcludePatternsForDir(dirname):
52  """Return patterns of files to exclude from ignorefile in a given directory.
53
54  Looks for .yapfignore in the directory dirname.
55
56  Arguments:
57    dirname: (unicode) The name of the directory.
58
59  Returns:
60    A List of file patterns to exclude if ignore file is found, otherwise empty
61    List.
62  """
63  ignore_file = os.path.join(dirname, '.yapfignore')
64  return _GetExcludePatternsFromFile(ignore_file)
65
66
67def GetDefaultStyleForDir(dirname, default_style=style.DEFAULT_STYLE):
68  """Return default style name for a given directory.
69
70  Looks for .style.yapf or setup.cfg or pyproject.toml in the parent directories.
71
72  Arguments:
73    dirname: (unicode) The name of the directory.
74    default_style: The style to return if nothing is found. Defaults to the
75                   global default style ('pep8') unless otherwise specified.
76
77  Returns:
78    The filename if found, otherwise return the default style.
79  """
80  dirname = os.path.abspath(dirname)
81  while True:
82    # See if we have a .style.yapf file.
83    style_file = os.path.join(dirname, style.LOCAL_STYLE)
84    if os.path.exists(style_file):
85      return style_file
86
87    # See if we have a setup.cfg file with a '[yapf]' section.
88    config_file = os.path.join(dirname, style.SETUP_CONFIG)
89    try:
90      fd = open(config_file)
91    except IOError:
92      pass  # It's okay if it's not there.
93    else:
94      with fd:
95        config = py3compat.ConfigParser()
96        config.read_file(fd)
97        if config.has_section('yapf'):
98          return config_file
99
100    # See if we have a pyproject.toml file with a '[tool.yapf]'  section.
101    config_file = os.path.join(dirname, style.PYPROJECT_TOML)
102    try:
103      fd = open(config_file)
104    except IOError:
105      pass  # It's okay if it's not there.
106    else:
107      with fd:
108        try:
109          import toml
110        except ImportError:
111          raise errors.YapfError(
112              "toml package is needed for using pyproject.toml as a configuration file"
113          )
114
115        pyproject_toml = toml.load(config_file)
116        style_dict = pyproject_toml.get('tool', {}).get('yapf', None)
117        if style_dict is not None:
118          return config_file
119
120    if (not dirname or not os.path.basename(dirname) or
121        dirname == os.path.abspath(os.path.sep)):
122      break
123    dirname = os.path.dirname(dirname)
124
125  global_file = os.path.expanduser(style.GLOBAL_STYLE)
126  if os.path.exists(global_file):
127    return global_file
128
129  return default_style
130
131
132def GetCommandLineFiles(command_line_file_list, recursive, exclude):
133  """Return the list of files specified on the command line."""
134  return _FindPythonFiles(command_line_file_list, recursive, exclude)
135
136
137def WriteReformattedCode(filename,
138                         reformatted_code,
139                         encoding='',
140                         in_place=False):
141  """Emit the reformatted code.
142
143  Write the reformatted code into the file, if in_place is True. Otherwise,
144  write to stdout.
145
146  Arguments:
147    filename: (unicode) The name of the unformatted file.
148    reformatted_code: (unicode) The reformatted code.
149    encoding: (unicode) The encoding of the file.
150    in_place: (bool) If True, then write the reformatted code to the file.
151  """
152  if in_place:
153    with py3compat.open_with_encoding(
154        filename, mode='w', encoding=encoding, newline='') as fd:
155      fd.write(reformatted_code)
156  else:
157    py3compat.EncodeAndWriteToStdout(reformatted_code)
158
159
160def LineEnding(lines):
161  """Retrieve the line ending of the original source."""
162  endings = {CRLF: 0, CR: 0, LF: 0}
163  for line in lines:
164    if line.endswith(CRLF):
165      endings[CRLF] += 1
166    elif line.endswith(CR):
167      endings[CR] += 1
168    elif line.endswith(LF):
169      endings[LF] += 1
170  return (sorted(endings, key=endings.get, reverse=True) or [LF])[0]
171
172
173def _FindPythonFiles(filenames, recursive, exclude):
174  """Find all Python files."""
175  if exclude and any(e.startswith('./') for e in exclude):
176    raise errors.YapfError("path in '--exclude' should not start with ./")
177  exclude = exclude and [e.rstrip("/" + os.path.sep) for e in exclude]
178
179  python_files = []
180  for filename in filenames:
181    if filename != '.' and exclude and IsIgnored(filename, exclude):
182      continue
183    if os.path.isdir(filename):
184      if not recursive:
185        raise errors.YapfError(
186            "directory specified without '--recursive' flag: %s" % filename)
187
188      # TODO(morbo): Look into a version of os.walk that can handle recursion.
189      excluded_dirs = []
190      for dirpath, dirnames, filelist in os.walk(filename):
191        if dirpath != '.' and exclude and IsIgnored(dirpath, exclude):
192          excluded_dirs.append(dirpath)
193          continue
194        elif any(dirpath.startswith(e) for e in excluded_dirs):
195          continue
196        for f in filelist:
197          filepath = os.path.join(dirpath, f)
198          if exclude and IsIgnored(filepath, exclude):
199            continue
200          if IsPythonFile(filepath):
201            python_files.append(filepath)
202        # To prevent it from scanning the contents excluded folders, os.walk()
203        # lets you amend its list of child dirs `dirnames`. These edits must be
204        # made in-place instead of creating a modified copy of `dirnames`.
205        # list.remove() is slow and list.pop() is a headache. Instead clear
206        # `dirnames` then repopulate it.
207        dirnames_ = [dirnames.pop(0) for i in range(len(dirnames))]
208        for dirname in dirnames_:
209          dir_ = os.path.join(dirpath, dirname)
210          if IsIgnored(dir_, exclude):
211            excluded_dirs.append(dir_)
212          else:
213            dirnames.append(dirname)
214
215    elif os.path.isfile(filename):
216      python_files.append(filename)
217
218  return python_files
219
220
221def IsIgnored(path, exclude):
222  """Return True if filename matches any patterns in exclude."""
223  if exclude is None:
224    return False
225  path = path.lstrip(os.path.sep)
226  while path.startswith('.' + os.path.sep):
227    path = path[2:]
228  return any(fnmatch.fnmatch(path, e.rstrip(os.path.sep)) for e in exclude)
229
230
231def IsPythonFile(filename):
232  """Return True if filename is a Python file."""
233  if os.path.splitext(filename)[1] == '.py':
234    return True
235
236  try:
237    with open(filename, 'rb') as fd:
238      encoding = tokenize.detect_encoding(fd.readline)[0]
239
240    # Check for correctness of encoding.
241    with py3compat.open_with_encoding(
242        filename, mode='r', encoding=encoding) as fd:
243      fd.read()
244  except UnicodeDecodeError:
245    encoding = 'latin-1'
246  except (IOError, SyntaxError):
247    # If we fail to detect encoding (or the encoding cookie is incorrect - which
248    # will make detect_encoding raise SyntaxError), assume it's not a Python
249    # file.
250    return False
251
252  try:
253    with py3compat.open_with_encoding(
254        filename, mode='r', encoding=encoding) as fd:
255      first_line = fd.readline(256)
256  except IOError:
257    return False
258
259  return re.match(r'^#!.*\bpython[23]?\b', first_line)
260
261
262def FileEncoding(filename):
263  """Return the file's encoding."""
264  with open(filename, 'rb') as fd:
265    return tokenize.detect_encoding(fd.readline)[0]
266