Skip to content

Commit 8f5b384

Browse files
authored
feat(test): add doctest support to pytest (#268)
Supersedes #161. Adds pytest-doctestplus and fixes docstrings for doctest compatibility. Also adds a mkdocs hook to strip doctest flags from rendered docs.
1 parent a1ffed0 commit 8f5b384

File tree

15 files changed

+412
-277
lines changed

15 files changed

+412
-277
lines changed
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""MkDocs hook to strip doctest flags from rendered documentation.
2+
3+
Doctest flags like `# doctest: +SKIP` are useful for controlling doctest execution,
4+
but they clutter the documentation. This hook removes them from the rendered HTML.
5+
"""
6+
7+
import re
8+
from typing import Any
9+
10+
# Pattern to match doctest flags like: # doctest: +SKIP, # doctest: +ELLIPSIS, etc.
11+
# Also handles multiple flags like: # doctest: +SKIP, +ELLIPSIS
12+
DOCTEST_FLAG_PATTERN = re.compile(r"\s*#\s*doctest:\s*[+\w,\s]+")
13+
14+
15+
def on_page_content(html: str, **kwargs: Any) -> str:
16+
"""Remove doctest flags from page content.
17+
18+
Args:
19+
html: The rendered HTML content of the page.
20+
**kwargs: Additional keyword arguments passed by MkDocs.
21+
22+
Returns:
23+
The HTML content with doctest flags removed.
24+
"""
25+
return DOCTEST_FLAG_PATTERN.sub("", html)

fgpyo/collections/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
True
1818
>>> is_sorted([1, 2, 4, 3])
1919
False
20+
2021
```
2122
2223
## Examples of a "Peekable" Iterator
@@ -34,7 +35,10 @@
3435
>>> from fgpyo.collections import PeekableIterator
3536
>>> piter = PeekableIterator(iter([]))
3637
>>> piter.peek()
38+
Traceback (most recent call last):
39+
...
3740
StopIteration
41+
3842
```
3943
4044
A peekable iterator will return the next item before consuming it.
@@ -47,6 +51,7 @@
4751
1
4852
>>> [j for j in piter]
4953
[2, 3]
54+
5055
```
5156
5257
The [`can_peek()`][fgpyo.collections.PeekableIterator.can_peek] function can be used to determine if
@@ -63,7 +68,10 @@
6368
>>> piter.peek() if piter.can_peek() else -1
6469
-1
6570
>>> next(piter)
71+
Traceback (most recent call last):
72+
...
6673
StopIteration
74+
6775
```
6876
6977
[`PeekableIterator`][fgpyo.collections.PeekableIterator]'s constructor supports creation from

fgpyo/fasta/builder.py

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,31 +9,39 @@
99
Writing a FASTA with two contigs each with 100 bases:
1010
1111
```python
12-
>>> from fgpyo.fasta.builder import FastaBuilder
13-
>>> builder = FastaBuilder()
14-
>>> builder.add("chr10").add("AAAAAAAAAA", 10)
15-
>>> builder.add("chr11").add("GGGGGGGGGG", 10)
16-
>>> builder.to_file(path = pathlib.Path("test.fasta"))
12+
>>> from pathlib import Path
13+
>>> from fgpyo.fasta.builder import FastaBuilder
14+
>>> builder = FastaBuilder()
15+
>>> builder.add("chr10").add("AAAAAAAAAA", 10) # doctest: +ELLIPSIS
16+
<fgpyo.fasta.builder.ContigBuilder object at ...>
17+
>>> builder = builder.add("chr11").add("GGGGGGGGGG", 10)
18+
>>> fasta_path = Path(getfixture("tmp_path")) / "test.fasta"
19+
>>> builder.to_file(path=fasta_path) # doctest: +SKIP
20+
1721
```
1822
1923
Writing a FASTA with one contig with 100 A's and 50 T's:
2024
2125
```python
22-
>>> from fgpyo.fasta.builder import FastaBuilder
23-
>>> builder = FastaBuilder()
24-
>>> builder.add("chr10").add("AAAAAAAAAA", 10).add("TTTTTTTTTT", 5)
25-
>>> builder.to_file(path = pathlib.Path("test.fasta"))
26+
>>> from fgpyo.fasta.builder import FastaBuilder
27+
>>> builder = FastaBuilder()
28+
>>> builder.add("chr10").add("AAAAAAAAAA", 10).add("TTTTTTTTTT", 5) # doctest: +ELLIPSIS
29+
<fgpyo.fasta.builder.ContigBuilder object at ...>
30+
>>> builder.to_file(path=fasta_path) # doctest: +SKIP
31+
2632
```
2733
2834
Add bases to existing contig:
2935
3036
```python
31-
>>> from fgpyo.fasta.builder import FastaBuilder
32-
>>> builder = FastaBuilder()
33-
>>> contig_one = builder.add("chr10").add("AAAAAAAAAA", 1)
34-
>>> contig_one.add("NNN", 1)
35-
>>> contig_one.bases
36-
'AAAAAAAAAANNN'
37+
>>> from fgpyo.fasta.builder import FastaBuilder
38+
>>> builder = FastaBuilder()
39+
>>> contig_one = builder.add("chr10").add("AAAAAAAAAA", 1)
40+
>>> contig_one.add("NNN", 1) # doctest: +ELLIPSIS
41+
<fgpyo.fasta.builder.ContigBuilder object at ...>
42+
>>> contig_one.bases
43+
'AAAAAAAAAANNN'
44+
3745
```
3846
3947
"""

fgpyo/fasta/sequence_dictionary.py

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,9 @@
99
>>> import pysam
1010
>>> from fgpyo.fasta.sequence_dictionary import SequenceDictionary
1111
>>> sd: SequenceDictionary
12-
>>> with pysam.AlignmentFile("./fgpyo/sam/tests/data/valid.sam") as fh:
13-
... sd = SequenceDictionary.from_sam(header=fh.header)
14-
...
15-
>>> print(sd)
12+
>>> with pysam.AlignmentFile("./tests/fgpyo/sam/data/valid.sam") as fh:
13+
... sd = SequenceDictionary.from_sam(fh.header)
14+
>>> print(sd) # doctest: +NORMALIZE_WHITESPACE
1615
@SQ SN:chr1 LN:101
1716
@SQ SN:chr2 LN:101
1817
@SQ SN:chr3 LN:101
@@ -21,48 +20,53 @@
2120
@SQ SN:chr6 LN:101
2221
@SQ SN:chr7 LN:404
2322
@SQ SN:chr8 LN:202
23+
2424
```
2525
2626
Query based on index:
2727
2828
```python
29-
>>> print(sd[3])
29+
>>> print(sd[3]) # doctest: +NORMALIZE_WHITESPACE
3030
@SQ SN:chr4 LN:101
31+
3132
```
3233
3334
Query based on name:
3435
3536
```python
36-
>>> print(sd["chr6"])
37+
>>> print(sd["chr6"]) # doctest: +NORMALIZE_WHITESPACE
3738
@SQ SN:chr6 LN:101
39+
3840
```
3941
4042
Add, get, and delete attributes:
4143
4244
```python
45+
>>> from fgpyo.fasta.sequence_dictionary import Keys
4346
>>> meta = sd[0]
44-
>>> print(meta)
47+
>>> print(meta) # doctest: +NORMALIZE_WHITESPACE
4548
@SQ SN:chr1 LN:101
4649
>>> meta[Keys.ASSEMBLY] = "hg38"
47-
>>> print(meta))
50+
>>> print(meta) # doctest: +NORMALIZE_WHITESPACE
4851
@SQ SN:chr1 LN:101 AS:hg38
4952
>>> meta.get(Keys.ASSEMBLY)
50-
"hg38"
53+
'hg38'
5154
>>> meta.get(Keys.SPECIES) is None
5255
True
5356
>>> Keys.MD5 in meta
5457
False
5558
>>> del meta[Keys.ASSEMBLY]
56-
>>> print(meta)
59+
>>> print(meta) # doctest: +NORMALIZE_WHITESPACE
5760
@SQ SN:chr1 LN:101
61+
5862
```
5963
6064
Get a sequence based on one of its aliases
6165
6266
```python
6367
>>> meta[Keys.ALIASES] = "foo,bar,car"
6468
>>> sd = SequenceDictionary(infos=[meta] + sd.infos[1:])
65-
>>> print(sd)
69+
>>> print(sd) # doctest: +NORMALIZE_WHITESPACE
6670
@SQ SN:chr1 LN:101 AN:foo,bar,car
6771
@SQ SN:chr2 LN:101
6872
@SQ SN:chr3 LN:101
@@ -71,18 +75,19 @@
7175
@SQ SN:chr6 LN:101
7276
@SQ SN:chr7 LN:404
7377
@SQ SN:chr8 LN:202
74-
>>> print(sd["chr1"])
78+
>>> print(sd["chr1"]) # doctest: +NORMALIZE_WHITESPACE
7579
@SQ SN:chr1 LN:101 AN:foo,bar,car
76-
>>> print(sd["bar"])
80+
>>> print(sd["bar"]) # doctest: +NORMALIZE_WHITESPACE
7781
@SQ SN:chr1 LN:101 AN:foo,bar,car
82+
7883
```
7984
8085
Create a `pysam.AlignmentHeader` from a sequence dictionary:
8186
8287
```python
83-
>>> sd.to_sam_header()
84-
<pysam.libcalignmentfile.AlignmentHeader object at 0x10e93f5f0>
85-
>>> print(sd.to_sam_header())
88+
>>> sd.to_sam_header() # doctest: +ELLIPSIS
89+
<pysam.libcalignmentfile.AlignmentHeader object at ...>
90+
>>> print(sd.to_sam_header()) # doctest: +NORMALIZE_WHITESPACE
8691
@HD VN:1.5
8792
@SQ SN:chr1 LN:101 AN:foo,bar,car
8893
@SQ SN:chr2 LN:101
@@ -92,18 +97,19 @@
9297
@SQ SN:chr6 LN:101
9398
@SQ SN:chr7 LN:404
9499
@SQ SN:chr8 LN:202
100+
95101
```
96102
97103
Create a `pysam.AlignmentHeader` from a sequence dictionary with extra header items:
98104
99105
```python
100106
>>> sd.to_sam_header(
101107
... extra_header={"RG": [{"ID": "A", "LB": "a-library"}, {"ID": "B", "LB": "b-library"}]}
102-
... )
103-
<pysam.libcalignmentfile.AlignmentHeader object at 0x10e93fe30>
108+
... ) # doctest: +ELLIPSIS
109+
<pysam.libcalignmentfile.AlignmentHeader object at ...>
104110
>>> print(sd.to_sam_header(
105111
... extra_header={"RG": [{"ID": "A", "LB": "a-library"}, {"ID": "B", "LB": "b-library"}]}
106-
... ))
112+
... )) # doctest: +NORMALIZE_WHITESPACE
107113
@HD VN:1.5
108114
@SQ SN:chr1 LN:101 AN:foo,bar,car
109115
@SQ SN:chr2 LN:101
@@ -115,6 +121,7 @@
115121
@SQ SN:chr8 LN:202
116122
@RG ID:A LB:a-library
117123
@RG ID:B LB:b-library
124+
118125
```
119126
"""
120127

fgpyo/fastx/__init__.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,13 @@
1515
the state of all previously iterated records, set the parameter ``persist`` to `True`.
1616
1717
```python
18-
>>> from fgpyo.fastx import FastxZipped
19-
>>> with FastxZipped("r1.fq", "r2.fq", persist=False) as zipped:
20-
... for (r1, r2) in zipped:
21-
... print(f"{r1.name}: {r1.sequence}, {r2.name}: {r2.sequence}")
22-
seq1: AAAA, seq1: CCCC
23-
seq2: GGGG, seq2: TTTT
18+
>>> from fgpyo.fastx import FastxZipped
19+
>>> with FastxZipped("r1.fq", "r2.fq", persist=False) as zipped: # doctest: +SKIP
20+
... for (r1, r2) in zipped:
21+
... print(f"{r1.name}: {r1.sequence}, {r2.name}: {r2.sequence}")
22+
seq1: AAAA, seq1: CCCC
23+
seq2: GGGG, seq2: TTTT
24+
2425
```
2526
2627
"""

fgpyo/io/__init__.py

Lines changed: 52 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -14,30 +14,48 @@
1414
## fgpyo.io Examples:
1515
1616
```python
17+
>>> import fgpyo.io as fio
18+
>>> from fgpyo.io import write_lines, read_lines
19+
>>> from pathlib import Path
20+
21+
```
22+
23+
Assert that a path exists and is readable:
24+
25+
```python
26+
>>> tmp_dir = Path(getfixture("tmp_path"))
27+
>>> path_flat: Path = tmp_dir / "example.txt"
28+
>>> fio.assert_path_is_readable(path_flat) # doctest: +ELLIPSIS
29+
Traceback (most recent call last):
30+
...
31+
AssertionError: Cannot read non-existent path: ...
32+
33+
```
34+
35+
Write to and read from path:
36+
37+
```python
38+
>>> path_flat = tmp_dir / "example.txt"
39+
>>> path_compressed = tmp_dir / "example.txt.gz"
40+
>>> write_lines(path=path_flat, lines_to_write=["flat file", 10])
41+
>>> write_lines(path=path_compressed, lines_to_write=["gzip file", 10])
42+
43+
```
44+
45+
Read lines from a path into a generator:
46+
47+
```python
48+
>>> lines = read_lines(path=path_flat)
49+
>>> next(lines)
50+
'flat file'
51+
>>> next(lines)
52+
'10'
53+
>>> lines = read_lines(path=path_compressed)
54+
>>> next(lines)
55+
'gzip file'
56+
>>> next(lines)
57+
'10'
1758
18-
>>> import fgpyo.io as fio
19-
>>> from pathlib import Path
20-
Assert that a path exists and is readable
21-
>>> path_flat: Path = Path("example.txt")
22-
>>> path_compressed: Path = Path("example.txt.gz")
23-
>>> fio.path_is_readable(path_flat)
24-
AssertionError: Cannot read non-existent path: example.txt
25-
>>> fio.path_is_readable(compressed_file)
26-
AssertionError: Cannot read non-existent path: example.txt.gz
27-
Write to and read from path
28-
>>> write_lines(path = path_flat, lines_to_write=["flat file", 10])
29-
>>> write_lines(path = path_compressed, lines_to_write=["gzip file", 10])
30-
Read lines from a path into a generator
31-
>>> lines = read_lines(path = path_flat)
32-
>>> next(lines)
33-
"flat file"
34-
>>> next(lines)
35-
"10"
36-
>>> lines = read_lines(path = path_compressed)
37-
>>> next(lines)
38-
"gzip file"
39-
>>> next(lines)
40-
"10"
4159
```
4260
4361
"""
@@ -165,9 +183,10 @@ def to_reader(path: Path, threads: Optional[int] = None) -> TextIOWrapper:
165183
threads: the number of threads to use when decompressing gzip files
166184
167185
Example:
168-
>>> reader = fio.to_reader(path = Path("reader.txt"))
169-
>>> reader.readlines()
170-
>>> reader.close()
186+
>>> import fgpyo.io as fio
187+
>>> reader = fio.to_reader(path=Path("reader.txt")) # doctest: +SKIP
188+
>>> reader.readlines() # doctest: +SKIP
189+
>>> reader.close() # doctest: +SKIP
171190
172191
"""
173192
if path.suffix in COMPRESSED_FILE_EXTENSIONS:
@@ -189,9 +208,10 @@ def to_writer(path: Path, append: bool = False, threads: Optional[int] = None) -
189208
threads: the number of threads to use when compressing gzip files
190209
191210
Example:
192-
>>> writer = fio.to_writer(path = Path("writer.txt"))
193-
>>> writer.write(f'{something}\\n')
194-
>>> writer.close()
211+
>>> import fgpyo.io as fio
212+
>>> writer = fio.to_writer(path=Path("writer.txt")) # doctest: +SKIP
213+
>>> writer.write("something\\n") # doctest: +SKIP
214+
>>> writer.close() # doctest: +SKIP
195215
196216
"""
197217
mode_prefix: str = "a" if append else "w"
@@ -226,7 +246,9 @@ def read_lines(path: Path, strip: bool = False, threads: Optional[int] = None) -
226246
threads: the number of threads to use when decompressing gzip files
227247
228248
Example:
229-
read_back = fio.read_lines(path)
249+
>>> import fgpyo.io as fio
250+
>>> read_back = fio.read_lines(path) # doctest: +SKIP
251+
230252
"""
231253
with to_reader(path=path, threads=threads) as reader:
232254
if strip:

0 commit comments

Comments
 (0)