File: test_Tutorial.py

package info (click to toggle)
python-biopython 1.85%2Bdfsg-4
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 126,372 kB
  • sloc: xml: 1,047,995; python: 332,722; ansic: 16,944; sql: 1,208; makefile: 140; sh: 81
file content (310 lines) | stat: -rw-r--r-- 10,375 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
# Copyright 2011-2023 by Peter Cock.  All rights reserved.
# Revisions copyright 2019 by Anil Tuncel. All rights reserved.
#
# This code is part of the Biopython distribution and governed by its
# license.  Please see the LICENSE file that should have been included
# as part of this package.
#
# This script looks for entries in the RST source for the
# Biopython Tutorial which can be turned into Python doctests,
# e.g.
#
# .. doctest
#
# .. code:: pycon
#
#    >>> from Bio.Seq import Seq
#    >>> s = Seq("ACGT")
#    >>> len(s)
#    4
#
# Code snippets can be extended using a similar syntax, which
# will create a single combined doctest:
#
# .. cont-doctest
#
# .. code:: pycon
#
#   >>> s == "ACGT"
#   True
#
# The doctest comment line also supports a relative working directory,
# and listing multiple Python dependencies as lib:XXX which will
# ensure "import XXX" works before using the test. e.g.
#
# .. doctest examples lib:numpy lib:scipy
#
# Additionally after the path, special keyword 'internet' is
# used to flag online tests.
#
# Note if using lib:XXX or special value 'internet' you must
# include a relative path to the working directory, just use '.'
# for the default path, e.g.
#
# .. doctest . lib:reportlab
#
# .. doctest . internet
#
# TODO: Adding bin:XXX for checking binary XXX is on $PATH?
#
# See also "Writing doctests in the Tutorial" in the Tutorial
# itself.


"""Tests for Tutorial module."""

import doctest
import os
import sys
import unittest
import warnings

# This is the same mechanism used for run_tests.py --offline
# to skip tests requiring the network.
import requires_internet

from Bio import BiopythonDeprecationWarning
from Bio import BiopythonExperimentalWarning
from Bio import MissingExternalDependencyError

try:
    requires_internet.check()
    online = True
except MissingExternalDependencyError:
    online = False
if "--offline" in sys.argv:
    # Allow manual override via "python test_Tutorial.py --offline"
    online = False

# Cache this to restore the cwd at the end of the tests
original_path = os.path.abspath(".")

if os.path.basename(sys.argv[0]) == "test_Tutorial.py":
    # sys.argv[0] will be (relative) path to test_Tutorial.py - use this to allow, e.g.
    # [base]$ python Tests/test_Tutorial.py
    # [Tests/]$ python test_Tutorial.py
    tutorial_base = os.path.abspath(
        os.path.join(os.path.dirname(sys.argv[0]), "../Doc/")
    )
    tutorial = os.path.join(tutorial_base, "Tutorial/index.rst")
else:
    # Probably called via run_tests.py so current directory should (now) be Tests/
    # but may have been changed by run_tests.py so can't infer from sys.argv[0] with e.g.
    # [base]$ python Tests/run_tests.py test_Tutorial
    tutorial_base = os.path.abspath("../Doc/")
    tutorial = os.path.join(tutorial_base, "Tutorial/index.rst")
if not os.path.isfile(tutorial):
    from Bio import MissingExternalDependencyError

    raise MissingExternalDependencyError(
        "Could not find ../Doc/Tutorial/index.rst file"
    )

# Build a list of all the Tutorial RST files:
files = []
for rst in os.listdir(os.path.join(tutorial_base, "Tutorial/")):
    if rst.startswith("chapter_") and rst.endswith(".rst"):
        files.append(os.path.join(tutorial_base, "Tutorial", rst))


def _extract(handle):
    line = handle.readline()
    if line != "\n":
        raise ValueError(
            "Any '.. doctest' or '.. cont-doctest' line should "
            "be followed by an empty line"
        )

    line = handle.readline()
    if line.lstrip() != ".. code:: pycon\n":
        raise ValueError(
            "Any '.. doctest' or '.. cont-doctest' line should "
            r"be followed by '\n.. code:: pycon\n\n"
        )

    line = handle.readline()
    if line != "\n":
        raise ValueError(
            "Any '.. doctest' or '.. cont-doctest' line should "
            r"be followed by '\n.. code:: pycon\n\n"
        )

    lines = []
    while True:
        line = handle.readline()
        if not line:
            if lines:
                # print("".join(lines[:30]))
                break
            else:
                raise ValueError("Didn't find lines!")
        elif line == "\n":
            break
        else:
            lines.append(line)
    return lines


def extract_doctests(rst_filename):
    """Scan RST file and pull out marked doctests as strings.

    This is a generator, yielding one tuple per doctest.
    """
    base_name = os.path.splitext(os.path.basename(rst_filename))[0]
    name = None
    deps = ""
    folder = ""
    with open(rst_filename, encoding="utf8") as handle:
        line_number = 0
        lines = []
        while True:
            line = handle.readline()
            line_number += 1
            if not line:
                # End of file
                break
            elif line.lstrip().startswith(".. cont-doctest"):
                x = _extract(handle)
                lines.extend(x)
                line_number += len(x) + 2
            elif line.lstrip().startswith(".. doctest"):
                if lines:
                    if not lines[0].lstrip().startswith(">>> "):
                        raise ValueError(
                            f"Should start with '>>> ' (indented), not {lines[0]!r}"
                        )
                    yield name, "".join(lines), folder, deps
                    lines = []
                deps = [x.strip() for x in line.split()[2:]]
                if deps:
                    folder = deps[0]
                    deps = deps[1:]
                else:
                    folder = ""
                name = "test_%s_line_%05i" % (base_name, line_number)
                x = _extract(handle)
                lines.extend(x)
                line_number += len(x) + 2
    if lines:
        if not name:
            raise ValueError(f"Unanchored doctest in {rst_filename}: {lines}")
        if not lines[0].lstrip().startswith(">>> "):
            raise ValueError(f"Should start '>>> ' not {lines[0]!r}")
        yield name, "".join(lines), folder, deps
    # yield "dummy", ">>> 2 + 2\n5\n"


class TutorialDocTestHolder:
    """Python doctests extracted from the Biopython Tutorial."""


def check_deps(dependencies):
    """Check 'lib:XXX' and 'internet' dependencies are met."""
    missing = []
    for dep in dependencies:
        if dep == "internet":
            if not online:
                missing.append("internet")
        else:
            assert dep.startswith("lib:"), dep
            lib = dep[4:]
            try:
                tmp = __import__(lib)
                del tmp
            except ImportError:
                missing.append(lib)
    return missing


# Create dummy methods on the object purely to hold doctests
missing_deps = set()
for rst in files:
    # print("Extracting doctests from %s" % rst)
    for name, example, folder, deps in extract_doctests(rst):
        assert name, rst
        missing = check_deps(deps)
        if missing:
            missing_deps.update(missing)
            continue

        def funct(n, d, f):
            global tutorial_base
            method = lambda x: None  # noqa: E731
            if f:
                p = os.path.join(tutorial_base, f)
                method.__doc__ = f"{n}\n\n>>> import os\n>>> os.chdir({p!r})\n{d}\n"
            else:
                method.__doc__ = f"{n}\n\n{d}\n"
            method._folder = f
            return method

        setattr(
            TutorialDocTestHolder,
            f"doctest_{name.replace(' ', '_')}",
            funct(name, example, folder),
        )
        del funct


# This is a TestCase class so it is found by run_tests.py
class TutorialTestCase(unittest.TestCase):
    """Python doctests extracted from the Biopython Tutorial."""

    # Single method to be invoked by run_tests.py
    def test_doctests(self):
        """Run tutorial doctests."""
        runner = doctest.DocTestRunner()
        failures = []
        with warnings.catch_warnings():
            warnings.simplefilter("ignore", BiopythonDeprecationWarning)
            warnings.simplefilter("ignore", BiopythonExperimentalWarning)
            for test in doctest.DocTestFinder().find(TutorialDocTestHolder):
                failed, success = runner.run(test)
                if failed:
                    name = test.name
                    assert name.startswith("TutorialDocTestHolder.doctest_")
                    failures.append(name[30:])
                    # raise ValueError("Tutorial doctest %s failed" % test.name[30:])
        if failures:
            raise ValueError(
                "%i Tutorial doctests failed: %s" % (len(failures), ", ".join(failures))
            )

    def tearDown(self):
        global original_path
        os.chdir(original_path)
        # files currently don't get created during test with python3.5 and pypy
        # remove files created from chapter_phylo.tex
        delete_phylo_tutorial = ["examples/tree1.nwk", "examples/other_trees.xml"]
        for file in delete_phylo_tutorial:
            if os.path.exists(os.path.join(tutorial_base, file)):
                os.remove(os.path.join(tutorial_base, file))
        # remove files created from chapter_cluster.tex
        tutorial_cluster_base = os.path.abspath("../Tests/")
        delete_cluster_tutorial = [
            "Cluster/cyano_result.atr",
            "Cluster/cyano_result.cdt",
            "Cluster/cyano_result.gtr",
            "Cluster/cyano_result_K_A2.kag",
            "Cluster/cyano_result_K_G5.kgg",
            "Cluster/cyano_result_K_G5_A2.cdt",
        ]
        for file in delete_cluster_tutorial:
            if os.path.exists(os.path.join(tutorial_cluster_base, file)):
                os.remove(os.path.join(tutorial_cluster_base, file))


# This is to run the doctests if the script is called directly:
if __name__ == "__main__":
    if missing_deps:
        print("Skipping tests needing the following:")
        for dep in sorted(missing_deps):
            print(f" - {dep}")
    print("Running Tutorial doctests...")
    with warnings.catch_warnings():
        warnings.simplefilter("ignore", BiopythonDeprecationWarning)
        warnings.simplefilter("ignore", BiopythonExperimentalWarning)
        tests = doctest.testmod()
    if tests.failed:
        raise RuntimeError("%i/%i tests failed" % tests)
    print("Tests done")