Skip to content

Commit 1540ee2

Browse files
authored
RHOAIENG-17257: chore(tests): add testcontainers test to check Rmd to PDF rendering in RStudio (#857)
* RHOAIENG-17257: chore(tests): add testcontainers test to check Rmd to PDF rendering in RStudio * fixup, from review, avoid a copy
1 parent cdb479d commit 1540ee2

File tree

2 files changed

+169
-0
lines changed

2 files changed

+169
-0
lines changed

tests/containers/docker_utils.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,20 @@ def tar_filter(f: tarfile.TarInfo) -> tarfile.TarInfo:
6666
container.put_archive(dst, fh)
6767

6868

69+
def from_container_cp(container: Container, src: str, dst: str) -> None:
70+
fh = io.BytesIO()
71+
bits, stat = container.get_archive(src, encode_stream=True)
72+
for chunk in bits:
73+
fh.write(chunk)
74+
fh.seek(0)
75+
tar = tarfile.open(fileobj=fh, mode="r")
76+
try:
77+
tar.extractall(path=dst, filter=tarfile.data_filter)
78+
finally:
79+
tar.close()
80+
fh.close()
81+
82+
6983
def container_exec(
7084
container: Container,
7185
cmd: str | list[str],
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import logging
5+
import pathlib
6+
import subprocess
7+
import tempfile
8+
import textwrap
9+
from typing import TYPE_CHECKING
10+
11+
import allure
12+
import pytest
13+
14+
from tests.containers import docker_utils
15+
from tests.containers.workbenches.workbench_image_test import WorkbenchContainer, skip_if_not_workbench_image
16+
17+
if TYPE_CHECKING:
18+
import docker.models.images
19+
20+
21+
class TestRStudioImage:
22+
"""Tests for RStudio Workbench images in this repository."""
23+
24+
APP_ROOT_HOME = "/opt/app-root/src/"
25+
26+
@allure.issue("RHOAIENG-17256")
27+
def test_rmd_to_pdf_rendering(self, image: str) -> None:
28+
"""
29+
References:
30+
https://stackoverflow.com/questions/40563479/relationship-between-r-markdown-knitr-pandoc-and-bookdown
31+
https://www.earthdatascience.org/courses/earth-analytics/document-your-science/knit-rmarkdown-document-to-pdf/
32+
"""
33+
skip_if_not_rstudio_image(image)
34+
35+
container = WorkbenchContainer(image=image, user=1000, group_add=[0])
36+
try:
37+
container.start(wait_for_readiness=False)
38+
39+
# language=R
40+
script = textwrap.dedent("""
41+
library(knitr)
42+
library(rmarkdown)
43+
render("document.Rmd", output_format = "pdf_document")
44+
""")
45+
# language=markdown
46+
document = textwrap.dedent("""
47+
---
48+
title: "Untitled"
49+
output: pdf_document
50+
date: "2025-01-22"
51+
---
52+
53+
```{r setup, include=FALSE}
54+
knitr::opts_chunk$set(echo = TRUE)
55+
```
56+
57+
## R Markdown
58+
59+
This is an R Markdown document. Markdown is a simple formatting syntax for authoring HTML, PDF, and MS Word documents. For more details on using R Markdown see <http://rmarkdown.rstudio.com>.
60+
61+
When you click the **Knit** button a document will be generated that includes both content as well as the output of any embedded R code chunks within the document. You can embed an R code chunk like this:
62+
63+
```{r cars}
64+
summary(cars)
65+
```
66+
67+
## Including Plots
68+
69+
You can also embed plots, for example:
70+
71+
```{r pressure, echo=FALSE}
72+
plot(pressure)
73+
```
74+
75+
Note that the `echo = FALSE` parameter was added to the code chunk to prevent printing of the R code that generated the plot.
76+
""")
77+
78+
with tempfile.TemporaryDirectory() as tmpdir:
79+
tmpdir = pathlib.Path(tmpdir)
80+
(tmpdir / "script.R").write_text(script)
81+
docker_utils.container_cp(container.get_wrapped_container(), src=str(tmpdir / "script.R"),
82+
dst=self.APP_ROOT_HOME)
83+
(tmpdir / "document.Rmd").write_text(document)
84+
docker_utils.container_cp(container.get_wrapped_container(), src=str(tmpdir / "document.Rmd"),
85+
dst=self.APP_ROOT_HOME)
86+
87+
# https://stackoverflow.com/questions/28432607/pandoc-version-1-12-3-or-higher-is-required-and-was-not-found-r-shiny
88+
check_call(container,
89+
f"bash -c 'RSTUDIO_PANDOC=/usr/lib/rstudio-server/bin/quarto/bin/tools/x86_64 Rscript {self.APP_ROOT_HOME}/script.R'")
90+
91+
with tempfile.TemporaryDirectory() as tmpdir:
92+
docker_utils.from_container_cp(container.get_wrapped_container(), src=self.APP_ROOT_HOME, dst=tmpdir)
93+
allure.attach.file(
94+
pathlib.Path(tmpdir) / "src/document.pdf",
95+
name="rendered-pdf",
96+
attachment_type=allure.attachment_type.PDF
97+
)
98+
99+
finally:
100+
docker_utils.NotebookContainer(container).stop(timeout=0)
101+
102+
103+
def check_call(container: WorkbenchContainer, cmd: str) -> int:
104+
"""Like subprocess.check_output, but in a container."""
105+
logging.debug(_("Running command", cmd=cmd))
106+
rc, result = container.exec(cmd)
107+
result = result.decode("utf-8")
108+
logging.debug(_("Command execution finished", rc=rc, result=result))
109+
if rc != 0:
110+
raise subprocess.CalledProcessError(rc, cmd, output=result)
111+
return rc
112+
113+
114+
def check_output(container: WorkbenchContainer, cmd: str) -> str:
115+
"""Like subprocess.check_output, but in a container."""
116+
logging.debug(_("Running command", cmd=cmd))
117+
rc, result = container.exec(cmd)
118+
result = result.decode("utf-8")
119+
logging.debug(_("Command execution finished", rc=rc, result=result))
120+
if rc != 0:
121+
raise subprocess.CalledProcessError(rc, cmd, output=result)
122+
return result
123+
124+
125+
def skip_if_not_rstudio_image(image: str) -> docker.models.images.Image:
126+
image_metadata = skip_if_not_workbench_image(image)
127+
if "-rstudio-" not in image_metadata.labels['name']:
128+
pytest.skip(
129+
f"Image {image} does not have '-rstudio-' in {image_metadata.labels['name']=}'")
130+
131+
return image_metadata
132+
133+
134+
class StructuredMessage:
135+
"""https://docs.python.org/3/howto/logging-cookbook.html#implementing-structured-logging"""
136+
137+
def __init__(self, message, /, **kwargs):
138+
self.message = message
139+
self.kwargs = kwargs
140+
141+
def __str__(self):
142+
s = Encoder().encode(self.kwargs)
143+
return '%s >>> %s' % (self.message, s)
144+
145+
146+
class Encoder(json.JSONEncoder):
147+
def default(self, o):
148+
if isinstance(o, set):
149+
return tuple(o)
150+
elif isinstance(o, str):
151+
return o.encode('unicode_escape').decode('ascii')
152+
return super().default(o)
153+
154+
155+
_ = StructuredMessage # optional shortcut, to improve readability

0 commit comments

Comments
 (0)