Skip to content

Commit e086ce2

Browse files
committed
Add Multidoc utiity for creating versioned documentation
1 parent eb7998a commit e086ce2

File tree

6 files changed

+1123
-315
lines changed

6 files changed

+1123
-315
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,4 @@ examples/data
145145
/imod/tests/mydask.png
146146
/imod/tests/*_report.xml
147147
docs/sg_execution_times.rst
148+
docs/workdir/

docs/conf.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88

99
# -- Path setup --------------------------------------------------------------
1010

11-
11+
import os
12+
import subprocess
1213
from importlib.metadata import distribution
1314

1415
# If extensions (or modules to document with autodoc) are in another directory,
@@ -126,6 +127,20 @@
126127
# further. For a list of options available for each theme, see the
127128
# documentation.
128129
#
130+
version_or_name = subprocess.run(
131+
"git symbolic-ref -q --short HEAD || git describe --tags --exact-match",
132+
capture_output=True,
133+
shell=True,
134+
text=True,
135+
).stdout.strip()
136+
137+
env = os.environ
138+
json_url = (
139+
"https://deltares.github.io/imod-python/_static/switcher.json"
140+
if "JSON_URL" not in env
141+
else env["JSON_URL"]
142+
)
143+
129144
html_theme_options = {
130145
"navbar_align": "content",
131146
"icon_links": [
@@ -146,6 +161,13 @@
146161
"image_light": "imod-python-logo-light.svg",
147162
"image_dark": "imod-python-logo-dark.svg",
148163
},
164+
"switcher": {
165+
"json_url": json_url,
166+
"version_match": version_or_name,
167+
},
168+
"navbar_end": ["theme-switcher", "navbar-icon-links", "version-switcher"],
169+
"show_version_warning_banner": True,
170+
"check_switcher": False,
149171
}
150172

151173
# Custom sidebar templates, must be a dictionary that maps document names

docs/multidoc.py

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
import argparse
2+
import os
3+
import shutil
4+
import subprocess
5+
import sys
6+
from pathlib import Path
7+
8+
import git
9+
import jinja2
10+
import packaging.version
11+
12+
13+
class MultiDoc:
14+
def __init__(self):
15+
# Define useful paths
16+
self.current_dir = Path(os.path.dirname(os.path.abspath(__file__)))
17+
18+
self.root_dir = self.current_dir.parent
19+
self.patch_file = self.current_dir / "version-switcher-patch.diff"
20+
21+
# Attach to existing repo
22+
self.repo = git.Repo.init(self.root_dir)
23+
24+
# Parse arguments
25+
root_parser = self.setup_arguments()
26+
args = root_parser.parse_args()
27+
self.config = vars(args)
28+
29+
# Set additional paths
30+
self.work_dir = (
31+
Path(self.config["build_folder"])
32+
if os.path.isabs(self.config["build_folder"])
33+
else self.current_dir / self.config["build_folder"]
34+
)
35+
self.doc_dir = (
36+
Path(self.config["doc_folder"])
37+
if os.path.isabs(self.config["doc_folder"])
38+
else self.current_dir / self.config["doc_folder"]
39+
)
40+
41+
# Setup url
42+
self.baseurl = (
43+
"https://deltares.github.io/imod-python"
44+
if not self.config["local_build"]
45+
else self.doc_dir.as_uri()
46+
)
47+
self.json_location = "_static/switcher.json"
48+
49+
# Execute command
50+
if self.config["command"] is None:
51+
root_parser.print_help()
52+
sys.exit(0)
53+
54+
getattr(self, self.config["command"].replace("-", "_"))()
55+
56+
def setup_arguments(self):
57+
root_parser = argparse.ArgumentParser(
58+
description="A simple multi version sphinx doc builder.",
59+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
60+
exit_on_error=True,
61+
)
62+
63+
# Common arguments shared by all commands
64+
root_parser.add_argument(
65+
"--doc-folder",
66+
action="store",
67+
default=self.current_dir / "_build" / "html",
68+
help="Folder that contains the existing documentation and where new documentation will be added",
69+
)
70+
root_parser.add_argument(
71+
"--build-folder",
72+
action="store",
73+
default=self.current_dir / "workdir",
74+
help="Folder in which the version is checked out and build.",
75+
)
76+
root_parser.add_argument(
77+
"--local-build",
78+
action="store_true",
79+
help="Changes the hardcoded url of the switcher.json to a local file url. This makes it possible to use the version switcher locally.",
80+
)
81+
subparsers = root_parser.add_subparsers(dest="command")
82+
83+
# Parser for "add-version"
84+
add_version_parser = subparsers.add_parser(
85+
"add-version",
86+
help="Build and add a version to the documentation.",
87+
exit_on_error=True,
88+
)
89+
add_version_parser.add_argument("version", help="Version to add.")
90+
91+
# Parser for "update-version"
92+
update_version_parser = subparsers.add_parser(
93+
"update-version",
94+
help="Build and override a version of the documentation.",
95+
exit_on_error=True,
96+
)
97+
update_version_parser.add_argument("version", help="Version to update.")
98+
99+
# Parser for "remove-version"
100+
remove_version_parser = subparsers.add_parser(
101+
"remove-version",
102+
help="Remove a version from the documentation.",
103+
exit_on_error=True,
104+
)
105+
remove_version_parser.add_argument("version", help="Version to remove.")
106+
107+
# Parser for "list-versions"
108+
_ = subparsers.add_parser(
109+
"list-versions", help="List present versions in the documentation"
110+
)
111+
_ = subparsers.add_parser(
112+
"create-switcher", help="List present versions in the documentation"
113+
)
114+
115+
return root_parser
116+
117+
def add_version(self):
118+
version = self.config["version"]
119+
print(f"add-version: {version}")
120+
121+
self._build_version(version)
122+
self._build_switcher()
123+
124+
def update_version(self):
125+
version = self.config["version"]
126+
print(f"update-version: {version}")
127+
128+
self._build_version(version)
129+
130+
def remove_version(self):
131+
version = self.config["version"]
132+
print(f"remove-version: {version}")
133+
134+
shutil.rmtree(self.doc_dir / version)
135+
self._build_switcher()
136+
137+
def _build_version(self, version):
138+
with GitWorktreeManager(self.repo, self.work_dir, version):
139+
# Define the branch documentation source folder and build folder
140+
local_source_dir = self.work_dir / "docs"
141+
local_build_dir = self.work_dir / "builddir"
142+
143+
# Apply patch to older version. Once it is known in which version(branch/tag) this file will be added
144+
# we can add a check to apply this patch only to older versions
145+
print("Applying patch")
146+
_ = subprocess.run(
147+
["git", "apply", self.patch_file],
148+
cwd=self.work_dir,
149+
check=True,
150+
)
151+
152+
# Clean existing Pixi enviroment settings
153+
print(
154+
"Clearing pixi enviroment settings. This is needed for a clean build."
155+
)
156+
env = os.environ
157+
path_items = os.environ["PATH"].split(os.pathsep)
158+
filtered_path = [
159+
path
160+
for path in path_items
161+
if not os.path.abspath(path).startswith(str(self.root_dir))
162+
]
163+
env["Path"] = os.pathsep.join(filtered_path)
164+
165+
pixi_env_vars = [
166+
"PIXI_PROJECT_ROOT",
167+
"PIXI_PROJECT_NAME",
168+
"PIXI_PROJECT_MANIFEST",
169+
"PIXI_PROJECT_VERSION",
170+
"PIXI_PROMPT",
171+
"PIXI_ENVIRONMENT_NAME",
172+
"PIXI_ENVIRONMENT_PLATFORMS",
173+
"CONDA_PREFIX",
174+
"CONDA_DEFAULT_ENV",
175+
"INIT_CWD",
176+
]
177+
178+
for pixi_var in pixi_env_vars:
179+
if pixi_var in env:
180+
del env[pixi_var]
181+
182+
# Add json url to the environment. This will be used in the conf.py file
183+
env["JSON_URL"] = f"{self.baseurl}/{self.json_location}"
184+
185+
# Build the documentation of the branch
186+
print("Start sphinx-build.")
187+
_ = subprocess.run(
188+
["pixi", "run", "--frozen", "install"],
189+
cwd=self.work_dir,
190+
env=env,
191+
check=True,
192+
)
193+
_ = subprocess.run(
194+
[
195+
"pixi",
196+
"run",
197+
"--frozen",
198+
"sphinx-build",
199+
"-M",
200+
"html",
201+
local_source_dir,
202+
local_build_dir,
203+
],
204+
cwd=self.work_dir,
205+
env=env,
206+
check=True,
207+
)
208+
209+
# Collect the branch documentation and add it to the
210+
print("Move documentation to correct location.")
211+
branch_html_dir = local_build_dir / "html"
212+
shutil.rmtree(self.doc_dir / version, ignore_errors=True)
213+
shutil.copytree(branch_html_dir, self.doc_dir / version)
214+
215+
def list_versions(self):
216+
print(self._get_existing_versions())
217+
218+
def create_switcher(self):
219+
self._build_switcher()
220+
221+
def _build_switcher(self):
222+
switcher = SwitcherBuilder(self._get_existing_versions(), self.baseurl)
223+
version_info = switcher.build()
224+
225+
template = jinja2.Template("""{{ version_info | tojson(indent=4) }}""")
226+
rendered_document = template.render(version_info=version_info)
227+
228+
json_path = self.doc_dir / self.json_location
229+
os.makedirs(os.path.dirname(json_path), exist_ok=True)
230+
with open(json_path, "w") as fh:
231+
fh.write(rendered_document)
232+
233+
def _get_existing_versions(self):
234+
ignore = ["_static"]
235+
versions = [
236+
name
237+
for name in os.listdir(self.doc_dir)
238+
if os.path.isdir(self.doc_dir / name) and name not in ignore
239+
]
240+
return versions
241+
242+
243+
class GitWorktreeManager:
244+
def __init__(self, repo, work_dir, branch_or_tag):
245+
self.repo = repo
246+
self.work_dir = work_dir
247+
self.branch_or_tag = branch_or_tag
248+
249+
def __enter__(self):
250+
self.repo.git.execute(
251+
["git", "worktree", "add", f"{self.work_dir}", self.branch_or_tag]
252+
)
253+
254+
def __exit__(self, exc_type, exc_value, traceback):
255+
try:
256+
self.repo.git.execute(
257+
["git", "worktree", "remove", f"{self.work_dir}", "--force"]
258+
)
259+
except Exception:
260+
print("Warning: could not remove the worktree")
261+
262+
263+
class SwitcherBuilder:
264+
def __init__(self, versions, baseurl):
265+
self._versions = versions
266+
self._versions.sort(reverse=True)
267+
self.baseurl = baseurl
268+
269+
@property
270+
def latest_stable_version(self):
271+
dev_branch = ["master"]
272+
filtered_versions = [
273+
version
274+
for version in self._versions
275+
if version not in dev_branch
276+
and not packaging.version.Version(version).is_prerelease
277+
]
278+
latest_version = (
279+
max(filtered_versions, key=packaging.version.parse)
280+
if filtered_versions
281+
else None
282+
)
283+
284+
return latest_version
285+
286+
@property
287+
def versions(self):
288+
return self._versions
289+
290+
def build(self):
291+
version_info = []
292+
for version in self.versions:
293+
version_info += [
294+
{
295+
"name": self._version_to_name(version),
296+
"version": version,
297+
"url": f"{self.baseurl}/{version}",
298+
"preferred": version == self.latest_stable_version,
299+
}
300+
]
301+
302+
return version_info
303+
304+
def _version_to_name(self, version):
305+
name_postfix = ""
306+
if version == "master":
307+
name_postfix = "(latest)"
308+
if version == self.latest_stable_version:
309+
name_postfix = "(stable)"
310+
311+
name = " ".join([version, name_postfix])
312+
return name
313+
314+
315+
if __name__ == "__main__":
316+
MultiDoc()

0 commit comments

Comments
 (0)