Repository: python-tap/tappy

Issue:
AttributeError when not all tap files are version 13
Small example with tappy 3.0:

```bash
$ cat version_13.tap 
TAP version 13
ok 1 test_foo
1..1
$ cat not_version_13.tap 
1..1
ok 1 test_bar
# tests 1
# pass 1
# fail 0
$ tappy version_13.tap not_version_13.tap 
Traceback (most recent call last):
  File "bin/tappy", line 8, in <module>
    sys.exit(main())
  File "lib/python3.7/site-packages/tap/main.py", line 15, in main
    suite = build_suite(args)
  File "lib/python3.7/site-packages/tap/main.py", line 28, in build_suite
    suite = loader.load(args.files)
  File "lib/python3.7/site-packages/tap/loader.py", line 31, in load
    suite.addTest(self.load_suite_from_file(filepath))
  File "lib/python3.7/site-packages/tap/loader.py", line 47, in load_suite_from_file
    return self._load_lines(filename, line_generator, suite, rules)
  File "lib/python3.7/site-packages/tap/loader.py", line 69, in _load_lines
    for line in line_generator:
  File "lib/python3.7/site-packages/tap/parser.py", line 116, in parse
    yield self.parse_line(line.rstrip(), fh_new)
  File "lib/python3.7/site-packages/tap/parser.py", line 122, in parse_line
    return self._parse_result(True, match, fh)
  File "lib/python3.7/site-packages/tap/parser.py", line 161, in _parse_result
    peek_match = self.yaml_block_start.match(fh.peek())
AttributeError: 'itertools.chain' object has no attribute 'peek'
```

This happens because the parser sets `self._try_peeking = True` on the first version 13 file it sees, and assumes it can peek on the remaining files.

Discussion:
codecov-io: # [Codecov](https://codecov.io/gh/python-tap/tappy/pull/108?src=pr&el=h1) Report
> Merging [#108](https://codecov.io/gh/python-tap/tappy/pull/108?src=pr&el=desc) into [master](https://codecov.io/gh/python-tap/tappy/commit/7ab5bd73bb1ecdecb70ad80d74c2cd91d4a3a3f3?src=pr&el=desc) will **not change** coverage.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/python-tap/tappy/pull/108/graphs/tree.svg?width=650&token=ULVfoGuR2p&height=150&src=pr)](https://codecov.io/gh/python-tap/tappy/pull/108?src=pr&el=tree)

```diff
@@          Coverage Diff          @@
##           master   #108   +/-   ##
=====================================
  Coverage     100%   100%           
=====================================
  Files          12     12           
  Lines         576    573    -3     
=====================================
- Hits          576    573    -3
```


| [Impacted Files](https://codecov.io/gh/python-tap/tappy/pull/108?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [tap/parser.py](https://codecov.io/gh/python-tap/tappy/pull/108/diff?src=pr&el=tree#diff-dGFwL3BhcnNlci5weQ==) | `100% <100%> (ø)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/python-tap/tappy/pull/108?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/python-tap/tappy/pull/108?src=pr&el=footer). Last update [7ab5bd7...7557862](https://codecov.io/gh/python-tap/tappy/pull/108?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

ecederstrand: Good point about `peekable` only being available when `ENABLE_VERSION_13=True`. I added a commit to address this.

I don't have any idea how to improve test coverage of this, if it's required for acceptance of this PR. Do you have some pointers to get me in the right direction?
mblayman: Great question! I think the way we can check on this is with `tox`. I made this local modification to generate a coverage report.
```diff
diff --git a/tox.ini b/tox.ini
index 72d5060..79a37cf 100644
--- a/tox.ini
+++ b/tox.ini
@@ -11,8 +11,12 @@ envlist =
 
 [testenv]
 deps =
+    coverage
     pytest
-commands = pytest {envsitepackagesdir}/tap
+commands =
+    # pytest {envsitepackagesdir}/tap
+    coverage run tap/tests/run.py
+    coverage report -m --include "*/tap/*" --omit "*/tests/*"
+    coverage html
 
 [testenv:windows]
 basepython = python3.6
```
Once that is complete, you can open `htmlcov/index.html` and look at the parser.py file. I'd suggest looking at it without your change to see if the line in question is covered or uncovered. I suspect it will be uncovered.

If that's the case, then a new test could be added to reach that line of code. I suspect that it will hit the `AttributeError` that we discussed.
ecederstrand: Thanks for the hints, and sorry for the delay. I have now committed a test case that fails on the master branch and succeeds with this PR.

Patch:
diff --git a/.travis.yml b/.travis.yml
index 2d6972d..45292de 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -12,6 +12,9 @@ matrix:
     - os: linux
       python: 3.7
       env: TOX_ENV=py37
+    - os: linux
+      python: 3.8
+      env: TOX_ENV=py38
     - os: linux
       python: pypy3.5
       env: TOX_ENV=pypy3
@@ -20,22 +23,22 @@ matrix:
       language: generic
       env: TOX_ENV=py37
     - os: linux
-      python: 3.6
+      python: 3.8
       env: TOX_ENV=with_optional
     - os: linux
-      python: 3.7
+      python: 3.8
       env: TOX_ENV=runner
     - os: linux
-      python: 3.7
+      python: 3.8
       env: TOX_ENV=module
     - os: linux
-      python: 3.7
+      python: 3.8
       env: TOX_ENV=lint
     - os: linux
-      python: 3.7
+      python: 3.8
       env: TOX_ENV=integration
     - os: linux
-      python: 3.7
+      python: 3.8
       env: TOX_ENV=coverage
 install:
   - pip3 install tox
diff --git a/README.md b/README.md
index 365f5a4..daaea28 100644
--- a/README.md
+++ b/README.md
@@ -31,6 +31,7 @@ on Python
 3.5,
 3.6,
 3.7,
+3.8,
 and PyPy.
 It is continuously tested on Linux, OS X, and Windows.
 
diff --git a/docs/index.rst b/docs/index.rst
index 5bc2d09..79da86a 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -28,6 +28,7 @@ Python
 3.5,
 3.6,
 3.7,
+3.8,
 and PyPy.
 It is continuously tested on Linux, OS X, and Windows.
 
diff --git a/docs/releases.rst b/docs/releases.rst
index 7669b08..dd80f97 100644
--- a/docs/releases.rst
+++ b/docs/releases.rst
@@ -1,6 +1,11 @@
 Releases
 ========
 
+Version 3.1, To Be Released
+---------------------------
+
+* Add support for Python 3.8.
+
 Version 3.0, Released January 10, 2020
 --------------------------------------
 
diff --git a/setup.py b/setup.py
index 4c59879..172e31e 100644
--- a/setup.py
+++ b/setup.py
@@ -73,6 +73,7 @@ if __name__ == "__main__":
             "Programming Language :: Python :: 3.5",
             "Programming Language :: Python :: 3.6",
             "Programming Language :: Python :: 3.7",
+            "Programming Language :: Python :: 3.8",
             "Programming Language :: Python :: Implementation :: PyPy",
             "Topic :: Software Development :: Testing",
         ],
diff --git a/tap/parser.py b/tap/parser.py
index 9ad7bee..637436b 100644
--- a/tap/parser.py
+++ b/tap/parser.py
@@ -60,9 +60,6 @@ class Parser(object):
 
     TAP_MINIMUM_DECLARED_VERSION = 13
 
-    def __init__(self):
-        self._try_peeking = False
-
     def parse_file(self, filename):
         """Parse a TAP file to an iterable of tap.line.Line objects.
 
@@ -103,7 +100,6 @@ class Parser(object):
             if first_parsed.category == "version" and first_parsed.version >= 13:
                 if ENABLE_VERSION_13:
                     fh_new = peekable(itertools.chain([first_line], fh))
-                    self._try_peeking = True
                 else:  # pragma no cover
                     print(
                         """
@@ -157,7 +153,7 @@ WARNING: Optional imports not found, TAP 13 output will be
         """Parse a matching result line into a result instance."""
         peek_match = None
         try:
-            if fh is not None and self._try_peeking:
+            if fh is not None and ENABLE_VERSION_13 and isinstance(fh, peekable):
                 peek_match = self.yaml_block_start.match(fh.peek())
         except StopIteration:
             pass
diff --git a/tox.ini b/tox.ini
index 72d5060..b3e4037 100644
--- a/tox.ini
+++ b/tox.ini
@@ -2,6 +2,8 @@
 envlist =
     py35
     py36
+    py37
+    py38
     pypy3
     runner
     module


Test patch:
diff --git a/tap/tests/test_parser.py b/tap/tests/test_parser.py
index 8e89ca0..431297a 100644
--- a/tap/tests/test_parser.py
+++ b/tap/tests/test_parser.py
@@ -10,6 +10,14 @@ from unittest import mock
 
 from tap.parser import Parser
 
+try:
+    import yaml
+    from more_itertools import peekable  # noqa
+
+    have_yaml = True
+except ImportError:
+    have_yaml = False
+
 
 @contextmanager
 def captured_output():
@@ -231,23 +239,74 @@ class TestParser(unittest.TestCase):
         for line in parser.parse_text(sample):
             lines.append(line)
 
-        try:
-            import yaml
-            from more_itertools import peekable  # noqa
-
+        if have_yaml:
             converted_yaml = yaml.safe_load(u"""test: sample yaml""")
             self.assertEqual(4, len(lines))
             self.assertEqual(13, lines[0].version)
             self.assertEqual(converted_yaml, lines[2].yaml_block)
             self.assertEqual("test", lines[3].category)
             self.assertIsNone(lines[3].yaml_block)
-        except ImportError:
+        else:
             self.assertEqual(7, len(lines))
             self.assertEqual(13, lines[0].version)
             for l in list(range(3, 6)):
                 self.assertEqual("unknown", lines[l].category)
             self.assertEqual("test", lines[6].category)
 
+    def test_parses_mixed(self):
+        # Test that we can parse both a version 13 and earlier version files
+        # using the same parser. Make sure that parsing works regardless of
+        # the order of the incoming documents.
+        sample_version_13 = inspect.cleandoc(
+            u"""TAP version 13
+            1..2
+            ok 1 A passing version 13 test
+               ---
+               test: sample yaml
+               ...
+            not ok 2 A failing version 13 test"""
+        )
+        sample_pre_13 = inspect.cleandoc(
+            """1..2
+            ok 1 A passing pre-13 test
+            not ok 2 A failing pre-13 test"""
+        )
+
+        parser = Parser()
+        lines = []
+        lines.extend(parser.parse_text(sample_version_13))
+        lines.extend(parser.parse_text(sample_pre_13))
+        if have_yaml:
+            self.assertEqual(13, lines[0].version)
+            self.assertEqual("A passing version 13 test", lines[2].description)
+            self.assertEqual("A failing version 13 test", lines[3].description)
+            self.assertEqual("A passing pre-13 test", lines[5].description)
+            self.assertEqual("A failing pre-13 test", lines[6].description)
+        else:
+            self.assertEqual(13, lines[0].version)
+            self.assertEqual("A passing version 13 test", lines[2].description)
+            self.assertEqual("A failing version 13 test", lines[6].description)
+            self.assertEqual("A passing pre-13 test", lines[8].description)
+            self.assertEqual("A failing pre-13 test", lines[9].description)
+
+        # Test parsing documents in reverse order
+        parser = Parser()
+        lines = []
+        lines.extend(parser.parse_text(sample_pre_13))
+        lines.extend(parser.parse_text(sample_version_13))
+        if have_yaml:
+            self.assertEqual("A passing pre-13 test", lines[1].description)
+            self.assertEqual("A failing pre-13 test", lines[2].description)
+            self.assertEqual(13, lines[3].version)
+            self.assertEqual("A passing version 13 test", lines[5].description)
+            self.assertEqual("A failing version 13 test", lines[6].description)
+        else:
+            self.assertEqual("A passing pre-13 test", lines[1].description)
+            self.assertEqual("A failing pre-13 test", lines[2].description)
+            self.assertEqual(13, lines[3].version)
+            self.assertEqual("A passing version 13 test", lines[5].description)
+            self.assertEqual("A failing version 13 test", lines[9].description)
+
     def test_parses_yaml_no_end(self):
         sample = inspect.cleandoc(
             u"""TAP version 13
@@ -263,17 +322,14 @@ class TestParser(unittest.TestCase):
         for line in parser.parse_text(sample):
             lines.append(line)
 
-        try:
-            import yaml
-            from more_itertools import peekable  # noqa
-
+        if have_yaml:
             converted_yaml = yaml.safe_load(u"""test: sample yaml""")
             self.assertEqual(4, len(lines))
             self.assertEqual(13, lines[0].version)
             self.assertEqual(converted_yaml, lines[2].yaml_block)
             self.assertEqual("test", lines[3].category)
             self.assertIsNone(lines[3].yaml_block)
-        except ImportError:
+        else:
             self.assertEqual(6, len(lines))
             self.assertEqual(13, lines[0].version)
             for l in list(range(3, 5)):
@@ -300,10 +356,7 @@ class TestParser(unittest.TestCase):
         for line in parser.parse_text(sample):
             lines.append(line)
 
-        try:
-            import yaml
-            from more_itertools import peekable  # noqa
-
+        if have_yaml:
             converted_yaml = yaml.safe_load(
                 u"""
                message: test
@@ -317,7 +370,7 @@ class TestParser(unittest.TestCase):
             self.assertEqual(3, len(lines))
             self.assertEqual(13, lines[0].version)
             self.assertEqual(converted_yaml, lines[2].yaml_block)
-        except ImportError:
+        else:
             self.assertEqual(11, len(lines))
             self.assertEqual(13, lines[0].version)
             for l in list(range(3, 11)):
@@ -395,10 +448,7 @@ WARNING: Optional imports not found, TAP 13 output will be
             for line in parser.parse_text(sample):
                 lines.append(line)
 
-        try:
-            import yaml  # noqa
-            from more_itertools import peekable  # noqa
-
+        if have_yaml:
             self.assertEqual(4, len(lines))
             self.assertEqual(13, lines[0].version)
             with captured_output() as (out, _):
@@ -408,7 +458,7 @@ WARNING: Optional imports not found, TAP 13 output will be
             )
             self.assertEqual("test", lines[3].category)
             self.assertIsNone(lines[3].yaml_block)
-        except ImportError:
+        else:
             self.assertEqual(8, len(lines))
             self.assertEqual(13, lines[0].version)
             for l in list(range(3, 7)):