1# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
2# For details: https://bitbucket.org/ned/coveragepy/src/default/NOTICE.txt
3
4"""Python source expertise for coverage.py"""
5
6import os.path
7import zipimport
8
9from coverage import env, files
10from coverage.misc import contract, expensive, NoSource, join_regex, isolate_module
11from coverage.parser import PythonParser
12from coverage.phystokens import source_token_lines, source_encoding
13from coverage.plugin import FileReporter
14
15os = isolate_module(os)
16
17
18@contract(returns='bytes')
19def read_python_source(filename):
20    """Read the Python source text from `filename`.
21
22    Returns bytes.
23
24    """
25    with open(filename, "rb") as f:
26        return f.read().replace(b"\r\n", b"\n").replace(b"\r", b"\n")
27
28
29@contract(returns='unicode')
30def get_python_source(filename):
31    """Return the source code, as unicode."""
32    base, ext = os.path.splitext(filename)
33    if ext == ".py" and env.WINDOWS:
34        exts = [".py", ".pyw"]
35    else:
36        exts = [ext]
37
38    for ext in exts:
39        try_filename = base + ext
40        if os.path.exists(try_filename):
41            # A regular text file: open it.
42            source = read_python_source(try_filename)
43            break
44
45        # Maybe it's in a zip file?
46        source = get_zip_bytes(try_filename)
47        if source is not None:
48            break
49    else:
50        # Couldn't find source.
51        raise NoSource("No source for code: '%s'." % filename)
52
53    source = source.decode(source_encoding(source), "replace")
54
55    # Python code should always end with a line with a newline.
56    if source and source[-1] != '\n':
57        source += '\n'
58
59    return source
60
61
62@contract(returns='bytes|None')
63def get_zip_bytes(filename):
64    """Get data from `filename` if it is a zip file path.
65
66    Returns the bytestring data read from the zip file, or None if no zip file
67    could be found or `filename` isn't in it.  The data returned will be
68    an empty string if the file is empty.
69
70    """
71    markers = ['.zip'+os.sep, '.egg'+os.sep]
72    for marker in markers:
73        if marker in filename:
74            parts = filename.split(marker)
75            try:
76                zi = zipimport.zipimporter(parts[0]+marker[:-1])
77            except zipimport.ZipImportError:
78                continue
79            try:
80                data = zi.get_data(parts[1])
81            except IOError:
82                continue
83            return data
84    return None
85
86
87class PythonFileReporter(FileReporter):
88    """Report support for a Python file."""
89
90    def __init__(self, morf, coverage=None):
91        self.coverage = coverage
92
93        if hasattr(morf, '__file__'):
94            filename = morf.__file__
95        else:
96            filename = morf
97
98        filename = files.unicode_filename(filename)
99
100        # .pyc files should always refer to a .py instead.
101        if filename.endswith(('.pyc', '.pyo')):
102            filename = filename[:-1]
103        elif filename.endswith('$py.class'):   # Jython
104            filename = filename[:-9] + ".py"
105
106        super(PythonFileReporter, self).__init__(files.canonical_filename(filename))
107
108        if hasattr(morf, '__name__'):
109            name = morf.__name__
110            name = name.replace(".", os.sep) + ".py"
111            name = files.unicode_filename(name)
112        else:
113            name = files.relative_filename(filename)
114        self.relname = name
115
116        self._source = None
117        self._parser = None
118        self._statements = None
119        self._excluded = None
120
121    @contract(returns='unicode')
122    def relative_filename(self):
123        return self.relname
124
125    @property
126    def parser(self):
127        """Lazily create a :class:`PythonParser`."""
128        if self._parser is None:
129            self._parser = PythonParser(
130                filename=self.filename,
131                exclude=self.coverage._exclude_regex('exclude'),
132            )
133        return self._parser
134
135    @expensive
136    def lines(self):
137        """Return the line numbers of statements in the file."""
138        if self._statements is None:
139            self._statements, self._excluded = self.parser.parse_source()
140        return self._statements
141
142    @expensive
143    def excluded_lines(self):
144        """Return the line numbers of statements in the file."""
145        if self._excluded is None:
146            self._statements, self._excluded = self.parser.parse_source()
147        return self._excluded
148
149    def translate_lines(self, lines):
150        return self.parser.translate_lines(lines)
151
152    def translate_arcs(self, arcs):
153        return self.parser.translate_arcs(arcs)
154
155    @expensive
156    def no_branch_lines(self):
157        no_branch = self.parser.lines_matching(
158            join_regex(self.coverage.config.partial_list),
159            join_regex(self.coverage.config.partial_always_list)
160            )
161        return no_branch
162
163    @expensive
164    def arcs(self):
165        return self.parser.arcs()
166
167    @expensive
168    def exit_counts(self):
169        return self.parser.exit_counts()
170
171    @contract(returns='unicode')
172    def source(self):
173        if self._source is None:
174            self._source = get_python_source(self.filename)
175        return self._source
176
177    def should_be_python(self):
178        """Does it seem like this file should contain Python?
179
180        This is used to decide if a file reported as part of the execution of
181        a program was really likely to have contained Python in the first
182        place.
183
184        """
185        # Get the file extension.
186        _, ext = os.path.splitext(self.filename)
187
188        # Anything named *.py* should be Python.
189        if ext.startswith('.py'):
190            return True
191        # A file with no extension should be Python.
192        if not ext:
193            return True
194        # Everything else is probably not Python.
195        return False
196
197    def source_token_lines(self):
198        return source_token_lines(self.source())
199