Skip to content

Commit 58a7959

Browse files
committed
DOP-1816: Permit tarballing of manpages
1 parent 70f8374 commit 58a7959

File tree

4 files changed

+174
-65
lines changed

4 files changed

+174
-65
lines changed

snooty/builders/test_man.py

Lines changed: 100 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,17 @@
1+
import io
12
import re
23
import subprocess
4+
import tarfile
35
from pathlib import Path
46
from typing import Dict, Union, cast
57

68
import pytest
79

8-
from ..diagnostics import CannotOpenFile
10+
from ..diagnostics import CannotOpenFile, UnsupportedFormat
911
from ..types import FileId
1012
from ..util_test import make_test
1113

12-
13-
def normalize(text: str) -> str:
14-
"""Remove any non-word characters to make groff output comparable
15-
across platforms."""
16-
# Strip the strange escape characters that Groff inserts for TTYs
17-
text = re.sub(r".\x08", "", text)
18-
19-
# Strip the header: this varies platform to platform.
20-
text = text.split("\n", 1)[1]
21-
22-
# Remove non-word characters
23-
return re.sub(r"[^\w]+", "", text)
24-
25-
26-
def test_manpage() -> None:
27-
with make_test(
28-
{
29-
Path(
30-
"snooty.toml"
31-
): """
32-
name = "test_manpage"
33-
34-
[manpages.mongo]
35-
file = "index.txt"
36-
section = 1
37-
title = "The MongoDB Shell"
38-
39-
[manpages.missing]
40-
file = "missing.txt"
41-
section = 1
42-
title = "This Manpage Doesn't Exist"
43-
""",
44-
Path(
45-
"source/index.txt"
46-
): """
14+
PAGE_TEXT = """
4715
.. _mongo:
4816
4917
=========
@@ -147,35 +115,9 @@ def test_manpage() -> None:
147115
}
148116
149117
Trailing paragraph.
150-
""",
151-
}
152-
) as result:
153-
# Ensure that we have an error about the missing manpage
154-
assert [type(diag) for diag in result.diagnostics[FileId("snooty.toml")]] == [
155-
CannotOpenFile
156-
]
157-
158-
static_files = cast(
159-
Dict[str, Union[str, bytes]], result.metadata["static_files"]
160-
)
161-
162-
troff = static_files["mongo.1"]
163-
164-
assert isinstance(troff, str)
165-
166-
# Empty lines are discouraged in troff source
167-
assert "\n\n" not in troff
118+
"""
168119

169-
try:
170-
# Use GNU Roff to turn our generated troff source into text we can compare.
171-
text = subprocess.check_output(
172-
["groff", "-T", "utf8", "-t", "-man"], encoding="utf-8", input=troff,
173-
)
174-
except FileNotFoundError:
175-
pytest.xfail("groff is not installed")
176-
177-
assert normalize(text).strip() == normalize(
178-
"""mongo(1) General Commands Manual mongo(1)
120+
MANPAGE_TEXT = """mongo(1) General Commands Manual mongo(1)
179121
180122
181123
@@ -246,4 +188,98 @@ def test_manpage() -> None:
246188
247189
248190
mongo(1)"""
191+
192+
193+
def normalize(text: str) -> str:
194+
"""Remove any non-word characters to make groff output comparable
195+
across platforms."""
196+
# Strip the strange escape characters that Groff inserts for TTYs
197+
text = re.sub(r".\x08", "", text)
198+
199+
# Strip the header: this varies platform to platform.
200+
text = text.split("\n", 1)[1]
201+
202+
# Remove non-word characters
203+
return re.sub(r"[^\w]+", "", text)
204+
205+
206+
def test_manpage() -> None:
207+
with make_test(
208+
{
209+
Path(
210+
"snooty.toml"
211+
): """
212+
name = "test_manpage"
213+
214+
[bundle]
215+
manpages = "manpages.tar.gz"
216+
217+
[manpages.mongo]
218+
file = "index.txt"
219+
section = 1
220+
title = "The MongoDB Shell"
221+
222+
[manpages.missing]
223+
file = "missing.txt"
224+
section = 1
225+
title = "This Manpage Doesn't Exist"
226+
""",
227+
Path("source/index.txt"): PAGE_TEXT,
228+
}
229+
) as result:
230+
# Ensure that we have an error about the missing manpage
231+
assert [type(diag) for diag in result.diagnostics[FileId("snooty.toml")]] == [
232+
CannotOpenFile
233+
]
234+
235+
static_files = cast(
236+
Dict[str, Union[str, bytes]], result.metadata["static_files"]
249237
)
238+
239+
troff = static_files["mongo.1"]
240+
241+
assert isinstance(troff, str)
242+
243+
# Empty lines are discouraged in troff source
244+
assert "\n\n" not in troff
245+
246+
try:
247+
# Use GNU Roff to turn our generated troff source into text we can compare.
248+
text = subprocess.check_output(
249+
["groff", "-T", "utf8", "-t", "-man"], encoding="utf-8", input=troff,
250+
)
251+
except FileNotFoundError:
252+
pytest.xfail("groff is not installed")
253+
254+
assert normalize(text).strip() == normalize(MANPAGE_TEXT)
255+
256+
tarball_data = static_files["manpages.tar.gz"]
257+
assert isinstance(tarball_data, bytes)
258+
tarball = io.BytesIO(tarball_data)
259+
with tarfile.open(None, "r:*", tarball) as tf:
260+
names = tf.getnames()
261+
assert sorted(names) == sorted(["mongo.1"])
262+
assert tf.getmember("mongo.1").size == len(troff)
263+
264+
265+
def test_bundling_error() -> None:
266+
with make_test(
267+
{
268+
Path(
269+
"snooty.toml"
270+
): """
271+
name = "test_manpage"
272+
273+
[bundle]
274+
manpages = "manpages.goofy"
275+
276+
[manpages.mongo]
277+
file = "index.txt"
278+
section = 1
279+
title = "The MongoDB Shell"
280+
""",
281+
Path("source/index.txt"): PAGE_TEXT,
282+
}
283+
) as result:
284+
diagnostics = result.diagnostics[FileId("snooty.toml")]
285+
assert UnsupportedFormat in [type(diag) for diag in diagnostics]

snooty/diagnostics.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import enum
22
from pathlib import Path
3-
from typing import Dict, List, Optional, Set, Tuple, Union
3+
from typing import Dict, List, Optional, Sequence, Set, Tuple, Union
44

55
from . import n
66
from .n import SerializableType
@@ -498,3 +498,20 @@ def __init__(
498498
start,
499499
end,
500500
)
501+
502+
503+
class UnsupportedFormat(Diagnostic):
504+
severity = Diagnostic.Level.error
505+
506+
def __init__(
507+
self,
508+
actual: str,
509+
expected: Sequence[str],
510+
start: Union[int, Tuple[int, int]],
511+
end: Union[None, int, Tuple[int, int]] = None,
512+
) -> None:
513+
super().__init__(
514+
f"Unsupported file format: {actual}. Must be one of {','.join(expected)}",
515+
start,
516+
end,
517+
)

snooty/parser.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import collections
22
import errno
33
import getpass
4+
import io
45
import logging
56
import multiprocessing
67
import os
78
import re
89
import subprocess
910
import threading
11+
import time
1012
from copy import deepcopy
1113
from dataclasses import dataclass
1214
from functools import partial
@@ -52,6 +54,7 @@
5254
UnexpectedIndentation,
5355
UnknownTabID,
5456
UnknownTabset,
57+
UnsupportedFormat,
5558
)
5659
from .gizaparser.nodes import GizaCategory
5760
from .gizaparser.published_branches import PublishedBranches, parse_published_branches
@@ -72,6 +75,35 @@
7275
logger = logging.getLogger(__name__)
7376

7477

78+
def bundle(
79+
filename: PurePath, members: Iterable[Tuple[str, Union[str, bytes]]]
80+
) -> bytes:
81+
if filename.suffixes[-2:] == [".tar", ".gz"] or filename.suffixes[-1] == ".tar":
82+
import tarfile
83+
84+
compression_flag = "gz" if filename.suffix == ".gz" else "::"
85+
86+
current_time = time.time()
87+
output_file = io.BytesIO()
88+
with tarfile.open(
89+
None, f"w:{compression_flag}", output_file, format=tarfile.PAX_FORMAT
90+
) as tf:
91+
for member_name, member_data in members:
92+
if isinstance(member_data, str):
93+
member_data = bytes(member_data, "utf-8")
94+
member_file = io.BytesIO(member_data)
95+
tar_info = tarfile.TarInfo(name=member_name)
96+
tar_info.size = len(member_data)
97+
tar_info.mtime = int(current_time)
98+
tar_info.mode = 0x644
99+
tf.addfile(tar_info, member_file)
100+
101+
return output_file.getvalue()
102+
103+
else:
104+
raise ValueError(f"Unknown bundling format: {filename.as_posix()}")
105+
106+
75107
@dataclass
76108
class _DefinitionListTerm(n.InlineParent):
77109
"""A vate node used for internal book-keeping that should not be exported to the AST."""
@@ -1318,6 +1350,7 @@ def create_page(filename: str) -> Tuple[Page, EmbeddedRstParser]:
13181350
self.backend.flush()
13191351

13201352
# Build manpages
1353+
manpages: List[Tuple[str, str]] = []
13211354
for name, definition in self.config.manpages.items():
13221355
fileid = FileId(definition.file)
13231356
manpage_page = self.pages.get(fileid)
@@ -1330,8 +1363,24 @@ def create_page(filename: str) -> Tuple[Page, EmbeddedRstParser]:
13301363
for filename, rendered in man.render(
13311364
manpage_page, name, definition.title, definition.section
13321365
).items():
1366+
manpages.append((filename.as_posix(), rendered))
13331367
static_files[filename.as_posix()] = rendered
13341368

1369+
if manpages and self.config.bundle.manpages:
1370+
try:
1371+
static_files[self.config.bundle.manpages] = bundle(
1372+
PurePath(self.config.bundle.manpages), manpages
1373+
)
1374+
except ValueError:
1375+
self.backend.on_diagnostics(
1376+
FileId(self.config.config_path.relative_to(self.config.root)),
1377+
[
1378+
UnsupportedFormat(
1379+
self.config.bundle.manpages, (".tar", ".tar.gz"), 0
1380+
)
1381+
],
1382+
)
1383+
13351384
post_metadata["static_files"] = static_files
13361385
for fileid, diagnostics in post_diagnostics.items():
13371386
self.backend.on_diagnostics(fileid, diagnostics)

snooty/types.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,12 @@ class ManPageConfig:
104104
section: int
105105

106106

107+
@checked
108+
@dataclass
109+
class BundleConfig:
110+
manpages: Optional[str] = field(default=None)
111+
112+
107113
@checked
108114
@dataclass
109115
class ProjectConfig:
@@ -122,6 +128,7 @@ class ProjectConfig:
122128
toc_landing_pages: List[str] = field(default_factory=list)
123129
page_groups: Dict[str, List[str]] = field(default_factory=dict)
124130
manpages: Dict[str, ManPageConfig] = field(default_factory=dict)
131+
bundle: BundleConfig = field(default_factory=lambda: BundleConfig())
125132

126133
@property
127134
def source_path(self) -> Path:

0 commit comments

Comments
 (0)