Skip to content

Commit e3ab6c1

Browse files
DOC: Execute docs examples in CI (#3507)
Closes #2610.
1 parent 3b5c85f commit e3ab6c1

25 files changed

+619
-253
lines changed

.github/workflows/github-ci.yaml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,10 +180,17 @@ jobs:
180180
- name: Test with mypy
181181
run : |
182182
mypy pypdf
183-
- name: Test docs build
183+
- name: Install docs requirements
184184
run: |
185185
pip install -r requirements/docs.txt
186-
sphinx-build --nitpicky --fail-on-warning --keep-going --show-traceback --builder html docs build/sphinx/html
186+
- name: Test docs build
187+
working-directory: ./docs
188+
run: |
189+
sphinx-build --nitpicky --fail-on-warning --keep-going --show-traceback -d _build/doctrees --builder html . _build/html
190+
- name: Test docs examples
191+
working-directory: ./docs
192+
run: |
193+
sphinx-build -d _build/doctrees --builder doctest . _build/doctest
187194
- name: Check with pre-commit
188195
run: |
189196
pip install -r requirements/dev.txt

docs/conf.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import os
1515
import shutil
1616
import sys
17+
from pathlib import Path
1718

1819
sys.path.insert(0, os.path.abspath("."))
1920
sys.path.insert(0, os.path.abspath("../"))
@@ -53,6 +54,7 @@
5354
"sphinx.ext.mathjax",
5455
"sphinx.ext.viewcode",
5556
"sphinx.ext.napoleon",
57+
"sphinx.ext.doctest",
5658
# External
5759
"myst_parser",
5860
]
@@ -132,3 +134,93 @@
132134
napoleon_numpy_docstring = False # Explicitly prefer Google style docstring
133135
napoleon_use_param = True # for type hint support
134136
napoleon_use_rtype = False # False, so the return type is inline with the description.
137+
138+
# -- Options for Doctest ------------------------------------------------------
139+
140+
# Most of doc examples use hardcoded input and output file names.
141+
# To execute these examples real files need to be read and written.
142+
#
143+
# By default, documentation examples run with the working directory set to where
144+
# "sphinx-build" command was invoked. To avoid relative paths in docs and to
145+
# allow to run "sphinx-build" command from any directory, we modify the current
146+
# working directory in each tested file. Tests are executed against our
147+
# temporary directory where we have copied all nessesary resources.
148+
#
149+
# Each doc page that requires file operations must use "testsetup" directive
150+
# to call "pypdf_test_setup" function to prepare the test environment for that
151+
# page.
152+
#
153+
# def pypdf_test_setup(group: str, resources: dict[str, str] = {}) -> None
154+
#
155+
# Args:
156+
# group: A unique name for group of tests. Typically we group tests by doc page.
157+
# For each doc page we create a test folder under
158+
# "_build/doctest/pypdf_test/<group>". This allows to avoid file name conflicts
159+
# between different doc pages.
160+
# resources: A dictionary of source files to copy into the test folder.
161+
# Key is the destination file name (relative to the test folder).
162+
# Value is the source file path (relative to the root folder).
163+
#
164+
# Examples:
165+
# ```{testsetup}
166+
# pypdf_test_setup("user/add-javascript", {
167+
# "example.pdf": "../resources/example.pdf",
168+
# })
169+
# ```
170+
171+
pypdf_test_src_root_dir = os.path.abspath(".")
172+
pypdf_test_dst_root_dir = os.path.abspath("_build/doctest/pypdf_test")
173+
if Path(pypdf_test_dst_root_dir).exists():
174+
shutil.rmtree(pypdf_test_dst_root_dir)
175+
Path(pypdf_test_dst_root_dir).mkdir(parents=True)
176+
177+
doctest_global_setup = f"""
178+
def pypdf_test_global_setup():
179+
import os
180+
import shutil
181+
from pathlib import Path
182+
183+
src_root_dir = {pypdf_test_src_root_dir.__repr__()}
184+
dst_root_dir = {pypdf_test_dst_root_dir.__repr__()}
185+
186+
global pypdf_test_orig_dir
187+
pypdf_test_orig_dir = os.getcwd()
188+
os.chdir(dst_root_dir)
189+
190+
global pypdf_test_setup
191+
def pypdf_test_setup(group: str, resources: dict[str, str] = {{}}) -> None:
192+
dst_dir = os.path.join(dst_root_dir, group)
193+
Path(dst_dir).mkdir(parents=True)
194+
os.chdir(dst_dir)
195+
196+
for (dst_path, src_path) in resources.items():
197+
src = os.path.normpath(os.path.join(src_root_dir, src_path))
198+
dst = os.path.join(dst_dir, dst_path)
199+
200+
shutil.copyfile(src, dst)
201+
202+
pypdf_test_global_setup()
203+
"""
204+
205+
doctest_global_cleanup = f"""
206+
def pypdf_test_global_cleanup():
207+
import os
208+
209+
dst_root_dir = {pypdf_test_dst_root_dir.__repr__()}
210+
211+
os.chdir(pypdf_test_orig_dir)
212+
213+
has_files = False
214+
for name in os.listdir(dst_root_dir):
215+
file_name = os.path.join(dst_root_dir, name)
216+
if os.path.isfile(file_name):
217+
if not has_files:
218+
print("Docs page was not configured propery for running code examples")
219+
print("Please use 'pypdf_test_setup' function in 'testsetup' directive")
220+
print("Deleting unexpected file(s) in " + dst_root_dir)
221+
has_files = True
222+
print(f"- {{name}}")
223+
os.remove(file_name) # Avoid side effects on other tests
224+
225+
pypdf_test_global_cleanup()
226+
"""

docs/dev/documentation.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,42 @@
11
# Documentation
22

3+
This documentation is build with [Sphinx](https://www.sphinx-doc.org/) and
4+
hosted by [Read the Docs](https://about.readthedocs.com/)
5+
6+
## Testing code snippets
7+
8+
Almost all python code snippets in documentation tested using Sphinx's extension
9+
[sphinx.ext.doctest](https://www.sphinx-doc.org/en/master/usage/extensions/doctest.html).
10+
This allows to make sure that we have no typos, missed imports and other problems in:
11+
- code snippets marked with `testcode` directive in `*.md` files
12+
- code snippets from python's docstrings imported via `autoclass` directive in `*.rst` files
13+
14+
CI pipeline is configured run Sphinx's `doctest` build automatically for each PR.
15+
It is also possible to run it locally:
16+
17+
1. First you need to install docs requirements
18+
19+
```bash
20+
pip install -r requirements/docs.txt
21+
```
22+
23+
2. Change current directory
24+
25+
```bash
26+
cd docs
27+
```
28+
29+
3. Run `doctest` build. It uses indirectly `sphinx-build` command line tool
30+
installed with docs requrements. See
31+
[Sphinx's docs](https://www.sphinx-doc.org/en/master/usage/quickstart.html#running-the-build)
32+
for details.
33+
34+
```bash
35+
make doctest
36+
```
37+
38+
4. If everything is okay you should see in output `Doctest summary` without failures
39+
340
## API Reference
441

542
### Method / Function Docstrings

docs/modules/PaperSize.rst

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,24 +8,27 @@ The PaperSize Class
88

99
Add blank page with PaperSize
1010
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
11-
.. code-block:: python
12-
:linenos:
11+
12+
.. testsetup ::
13+
14+
pypdf_test_setup("modules/PaperSize", {
15+
"example.pdf": "../resources/example.pdf",
16+
})
17+
18+
.. testcode ::
1319
1420
from pypdf import PaperSize, PdfWriter
1521
16-
writer = PdfWriter(clone_from="sample.pdf")
22+
writer = PdfWriter(clone_from="example.pdf")
1723
writer.add_blank_page(PaperSize.A8.width, PaperSize.A8.height)
18-
with open("output.pdf", "wb") as output_stream:
19-
writer.write(output_stream)
24+
writer.write("out-add-page.pdf")
2025
2126
Insert blank page with PaperSize
2227
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
23-
.. code-block:: python
24-
:linenos:
28+
.. testcode ::
2529
2630
from pypdf import PaperSize, PdfWriter
2731
28-
writer = PdfWriter(clone_from="sample.pdf")
32+
writer = PdfWriter(clone_from="example.pdf")
2933
writer.insert_blank_page(PaperSize.A8.width, PaperSize.A8.height, 1)
30-
with open("output.pdf", "wb") as output_stream:
31-
writer.write(output_stream)
34+
writer.write("out-insert-page.pdf")

docs/user/add-javascript.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,19 @@ Adobe has documentation on its support here:
77

88
## Launch print window on opening
99

10-
```python
10+
```{testsetup}
11+
pypdf_test_setup("user/add-javascript", {
12+
"example.pdf": "../resources/example.pdf",
13+
})
14+
```
15+
16+
```{testcode}
1117
from pypdf import PdfWriter
1218
1319
writer = PdfWriter(clone_from="example.pdf")
1420
1521
# Add JavaScript to launch the print window on opening this PDF.
1622
writer.add_js("this.print({bUI:true,bSilent:false,bShrinkToFit:true});")
1723
18-
# Write to pypdf-output.pdf.
19-
with open("pypdf-output.pdf", "wb") as fp:
20-
writer.write(fp)
24+
writer.write("out-print-window.pdf")
2125
```

docs/user/add-watermark.md

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,28 @@ The process of stamping and watermarking is the same, you just need to set `over
1010

1111
You can use {func}`~pypdf._page.PageObject.merge_page` if you don't need to transform the stamp:
1212

13-
```python
13+
```{testsetup}
14+
pypdf_test_setup("user/add-watermark", {
15+
"crazyones.pdf": "../resources/crazyones.pdf",
16+
"nup-source.png": "../docs/user/nup-source.png",
17+
"jpeg.pdf": "../resources/jpeg.pdf",
18+
})
19+
```
20+
21+
```{testcode}
1422
from pypdf import PdfReader, PdfWriter
1523
16-
stamp = PdfReader("bg.pdf").pages[0]
17-
writer = PdfWriter(clone_from="source.pdf")
24+
stamp = PdfReader("jpeg.pdf").pages[0]
25+
writer = PdfWriter(clone_from="crazyones.pdf")
1826
for page in writer.pages:
1927
page.merge_page(stamp, over=False) # here set to False for watermarking
2028
21-
writer.write("out.pdf")
29+
writer.write("out-watermark.pdf")
2230
```
2331

2432
Otherwise use {func}`~pypdf._page.PageObject.merge_transformed_page` with {class}`~pypdf.Transformation` if you need to translate, rotate, scale, etc. the stamp before merging it to the content page.
2533

26-
```python
34+
```{testcode}
2735
from pathlib import Path
2836
from typing import List, Union
2937
@@ -52,7 +60,7 @@ def stamp(
5260
writer.write(pdf_result)
5361
5462
55-
stamp("example.pdf", "stamp.pdf", "out.pdf")
63+
stamp("crazyones.pdf", "jpeg.pdf", "out-scale.pdf")
5664
```
5765

5866
If you are experiencing wrongly rotated watermarks/stamps, try to use
@@ -73,7 +81,7 @@ However, you can easily convert an image to PDF image using
7381
[Pillow](https://pypi.org/project/Pillow/).
7482

7583

76-
```python
84+
```{testcode}
7785
from io import BytesIO
7886
from pathlib import Path
7987
from typing import List, Union
@@ -111,9 +119,8 @@ def stamp_img(
111119
Transformation(),
112120
)
113121
114-
with open(pdf_result, "wb") as fp:
115-
writer.write(fp)
122+
writer.write(pdf_result)
116123
117124
118-
stamp_img("example.pdf", "example.png", "out.pdf")
125+
stamp_img("crazyones.pdf", "nup-source.png", "out-image.pdf")
119126
```

0 commit comments

Comments
 (0)