3 # Copyright The SCons Foundation
5 # Permission is hereby granted, free of charge, to any person obtaining
6 # a copy of this software and associated documentation files (the
7 # "Software"), to deal in the Software without restriction, including
8 # without limitation the rights to use, copy, modify, merge, publish,
9 # distribute, sublicense, and/or sell copies of the Software, and to
10 # permit persons to whom the Software is furnished to do so, subject to
11 # the following conditions:
13 # The above copyright notice and this permission notice shall be included
14 # in all copies or substantial portions of the Software.
16 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
17 # KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
18 # WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19 # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20 # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21 # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22 # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE
24 """Build SCons documentation."""
36 Import('command_line', 'env', 'whereis', 'revaction')
38 # Check prerequisites and flags for building the documentation. There are
39 # several combinations in play. Conceptually there are five builds:
40 # - manpage and user guide output in html (and manpage in roff-style "source")
41 # - manpage and user guide output in pdf
42 # - API docs with Sphinx output in html
43 # - API docs with Sphinx output in pdf
44 # - Bundle up the built bits into the tarball for upload to the website.
46 # These are sometimes a bit in tension. For example, we shouldn't need any
47 # doc bits to build the wheel for testing or uploading, except that the
48 # manpages (.1 format) are built and dropped into the top directory for
49 # use by distribution packagers - even though that's not really a suitable
50 # place for them. And since we're often building the wheel to make a release,
51 # we actually may end up wanting the docs anyway.
53 # We want to be able to have some choice in combinations, so that for example
54 # there's a command to build just the manpages for distros without having
55 # to have the whole fop (for pdf) and Sphinx (for API docs) chains setup
56 # just to do a successful build, since those won't be part of those
59 skip_doc_build = False
60 skip_pdf_build = False
61 skip_api_build = False
63 # SKIP_DOC is a csv with various options. It doesn't seem necessary
64 # to do a very sophisticated decode of it, but could add that later.
65 skip_doc_args = ARGUMENTS.get('SKIP_DOC', 'none').split(',')
66 if 'none' not in skip_doc_args:
67 if 'all' in skip_doc_args:
68 skip_doc_build = skip_pdf_build = skip_api_build = True
69 if 'api' in skip_doc_args:
71 if 'pdf' in skip_doc_args:
74 if not skip_doc_build:
78 except ImportError as exc:
79 print("doc: SConsDoc failed to import, the error was:")
80 print(f" ImportError: {exc}")
81 print(" Please make sure the Python lxml package is installed.")
82 print(" Skipping documentation build.")
83 skip_doc_build = skip_pdf_build = skip_api_build = True
85 if not skip_pdf_build:
89 if not fop and not xep:
90 print("doc: No PDF renderer found (fop|xep)!")
91 print(" Skipping PDF generation.")
94 if not skip_api_build:
96 sphinx = ctx.CheckProg("sphinx-build")
98 print("doc: Configure did not find sphinx-build")
99 print(" Skipping API docs generation.")
100 skip_api_build = True
104 # --- Configure build
106 build = os.path.join(command_line.build_dir, 'doc')
108 lynx = whereis('lynx')
109 dist_doc_tar_gz = '$DISTDIR/scons-doc-${VERSION}.tar.gz'
114 env = orig_env.Clone(SCONS_PY=File('#/scripts/scons.py').rfile())
119 def writeVersionXml(verfile, date, ver, rev, copyright_years):
120 """Helper function: Write a version.xml file."""
124 pass # okay if the file didn't exist
125 dir, f = os.path.split(verfile)
126 os.makedirs(dir, exist_ok=True)
127 with open(verfile, "w") as vf:
130 THIS IS AN AUTOMATICALLY-GENERATED FILE. DO NOT EDIT.
132 <!ENTITY builddate "{date}">
133 <!ENTITY buildversion "{ver}">
134 <!ENTITY buildrevision "{rev}">
135 <!ENTITY copyright_years "{copyright_years}">
139 # The names of the target files for the MAN pages
140 man_page_list = ['scons.1', 'scons-time.1', 'sconsign.1']
142 # Template for the MAN page texts when we can't properly
143 # create them because the skip_doc_build flag is set (required
144 # modules/tools aren't installed in the current system)
145 man_replace_tpl = r""".TH "%(uctitle)s" "1" "%(today)s" "SCons %(version)s" "SCons %(version)s"
146 .ie \n(.g .ds Aq \(aq
151 %(title)s \- This is a replacement file, stemming from an incomplete
152 packaging process without the required doc modules installed. Please
153 update the system and try running the build again.
160 print("doc: ...skipping building User Guide.")
161 print(" ...creating fake MAN pages.")
163 # Since the top-level SConstruct requires the MAN
164 # pages to exist for the basic packaging, we create simple
165 # stub texts here as replacement...
166 scdir = os.path.join(build, 'man')
167 if not os.path.isdir(scdir):
170 today = time.strftime(
171 "%Y-%m-%d", time.gmtime(int(os.environ.get('SOURCE_DATE_EPOCH', time.time())))
173 version = env.subst('$VERSION')
174 for m in man_page_list:
175 man, _ = os.path.splitext(m)
176 # TODO: add these to Alias?
177 with open(os.path.join(scdir, m), "w") as fman:
181 'uctitle': man.upper().replace("-", "\\-"),
190 "doc: Warning, lynx is not installed. "
191 "Created release packages will not be complete!"
194 # Always create a version.xml file containing the version information
195 # for this run. Ignore it for dependency purposes so we don't
196 # rebuild all the docs every time just because the date changes.
197 # TODO: couldn't we use Textfile + Ignore?
198 date, ver, rev, copyright_years = env.Dictionary(
199 'DATE', 'VERSION', 'REVISION', 'COPYRIGHT_YEARS'
201 version_xml = File(os.path.join(build, "version.xml"))
202 writeVersionXml(str(version_xml), date, ver, rev, copyright_years)
204 def _glob_install_action(target, source, env):
205 """Builder for copying files to an Install dir.
207 Selection is based on globbing for filename extension.
209 if not SCons.Util.is_List(target):
211 if not SCons.Util.is_List(source):
213 for t, s in zip(target, source):
214 shutil.copy(str(s), str(t))
216 def _glob_install_emitter(target, source, env):
217 """Emitter for GlobInstall Builder."""
218 if not SCons.Util.is_List(target):
220 if not SCons.Util.is_List(source):
225 tdir = env.Dir(target[0])
226 for g in glob.glob(str(source[0])):
227 head, tail = os.path.split(g)
228 res.append(os.path.join(str(tdir), tail))
232 _glob_install_builder = SCons.Builder.Builder(
233 action=_glob_install_action, emitter=_glob_install_emitter
235 env['BUILDERS']['GlobInstall'] = _glob_install_builder
237 def _chunked_install_action(target, source, env):
238 """Builder for copying ChunkedHTML files to an Install dir."""
239 if not SCons.Util.is_List(target):
241 if not SCons.Util.is_List(source):
243 tdir, tail = os.path.split(str(target[0]))
244 spattern = os.path.join(os.path.split(str(source[0]))[0], '*.html')
245 for g in glob.glob(spattern):
248 def _chunked_install_emitter(target, source, env):
249 """Emitter for ChunkedInstall Builder."""
250 if not SCons.Util.is_List(target):
252 if not SCons.Util.is_List(source):
255 tdir = env.Dir(target[0])
256 head, tail = os.path.split(str(source[0]))
257 return os.path.join(str(tdir), tail), source
259 _chunked_install_builder = SCons.Builder.Builder(
260 action=_chunked_install_action, emitter=_chunked_install_emitter
262 env['BUILDERS']['ChunkedInstall'] = _chunked_install_builder
264 if not env.GetOption('clean'):
266 # Ensure that all XML files are valid against our XSD, and
267 # that all example names and example output suffixes are unique
269 print("Validating files against SCons XSD...")
270 if SConsDoc.validate_all_xml(['SCons'], xsdfile='xsd/scons.xsd'):
273 print("Validation failed! Please correct the errors above and try again.")
276 print("Checking whether all example names are unique...")
277 if SConsExamples.exampleNamesAreUnique(os.path.join('doc', 'user')):
281 "Not all example names and suffixes are unique! "
282 "Please correct the errors listed above and try again."
286 # List of prerequisite files in the build/doc folder
289 def copy_dbfiles(env, toolpath, paths, fpattern, use_builddir=True):
290 """Helper function, copies a bunch of files matching
291 the given fpattern to a target directory.
294 if not SCons.Util.is_List(toolpath):
295 toolpath = [toolpath]
296 if not SCons.Util.is_List(paths):
298 if not SCons.Util.is_List(fpattern):
299 fpattern = [fpattern]
302 target_dir = env.Dir(
303 os.path.join(command_line.build_dir, *(toolpath + paths))
307 target_dir, os.path.join('..', *(toolpath + paths + fpattern))
311 target_dir = env.Dir(os.path.join(*(toolpath + paths)))
313 env.GlobInstall(target_dir, os.path.join(*(paths + fpattern)))
317 # Copy generated files (.gen/.mod/.xml) to the build folder
319 copy_dbfiles(env, build, 'generated', '*.gen', False)
320 copy_dbfiles(env, build, 'generated', '*.mod', False)
321 copy_dbfiles(env, build, ['generated', 'examples'], '*', False)
324 # Copy XSLT files (.xslt) to the build folder
326 copy_dbfiles(env, build, 'xslt', '*.*', False)
329 # Copy DocBook stylesheets and Tool to the build folder
331 dbtoolpath = ['SCons', 'Tool', 'docbook']
332 copy_dbfiles(env, dbtoolpath, [], '__init__.py')
333 copy_dbfiles(env, dbtoolpath, 'utils', 'xmldepend.xsl')
334 dbpath = dbtoolpath + ['docbook-xsl-1.76.1']
335 copy_dbfiles(env, dbpath, [], 'VERSION')
336 copy_dbfiles(env, dbpath, ['common'], '*.*')
337 copy_dbfiles(env, dbpath, ['lib'], '*.*')
338 copy_dbfiles(env, dbpath, ['html'], '*.*')
339 copy_dbfiles(env, dbpath, ['fo'], '*.*')
340 copy_dbfiles(env, dbpath, ['manpages'], '*.*')
341 copy_dbfiles(env, dbpath, ['epub'], '*.xsl')
342 copy_dbfiles(env, dbpath, ['xhtml-1_1'], '*.*')
345 # Copy additional Tools (gs, zip)
347 toolpath = ['SCons', 'Tool']
348 copy_dbfiles(env, toolpath, [], 'gs.py')
349 copy_dbfiles(env, toolpath, [], 'zip.py')
351 # Each document will build in its own subdirectory of "build/doc/".
352 # The *docs* dictionary entries have the document (and thus directory)
353 # name as the key, and a tuple of lists as the value.
355 # The first list is the document formats enabled ("targets"). Note this
356 # isn't what gets built, but what gets installed into the build folder
357 # source/target lists.
359 # The second list ("depends") is for dependency information. Dependencies
360 # are extracted from each local "MANIFEST" and added to this list.
361 # This basically links the original sources to the respective build folder.
363 # The third list ("nodes") stores the created PDF and HTML files,
364 # so that we can then install them in the proper places for getting
365 # picked up by the archiving/packaging stages.
368 # 'design': (['chunked', 'pdf'], [], []),
369 # 'python10' : (['chunked','html','pdf'], [], []),
370 # 'reference': (['chunked', 'html', 'pdf'], [], []),
371 # 'developer' : (['chunked','html','pdf'], [], []),
372 'user': (['chunked', 'html', 'pdf', 'epub', 'text'], [], []),
373 'man': (['man', 'epub', 'text'], [], []),
377 # We have to tell SCons to scan the top-level XML files which
378 # get included by the document XML files in the subdirectories.
381 def _parse_manifest_lines(basedir, manifest) -> list:
383 Scans a MANIFEST file, and returns the list of source files.
385 Has basic support for recursive globs '**',
386 filename wildcards of the form '*.xml' and
387 comment lines, starting with a '#'.
390 basedir: base path to find files in. Note this does not
391 run in an SCons context so path must not need
392 further processing (e.g. no '#' signs)
393 manifest: path to manifest file
396 basewd = os.path.abspath(basedir)
397 with open(manifest) as m:
398 lines = m.readlines()
400 if l.startswith('#'):
405 # Glob all files recursively
406 globwd = os.path.dirname(os.path.join(basewd, l))
407 for path, dirs, files in os.walk(globwd):
409 fpath = os.path.join(globwd, path, f)
410 sources.append(os.path.relpath(fpath, basewd))
413 files = glob.glob(os.path.join(basewd, l))
415 sources.append(os.path.relpath(f, basewd))
422 manifest = File('MANIFEST').rstr()
423 src_files = _parse_manifest_lines('.', manifest)
427 base, ext = os.path.splitext(s)
428 if ext in ['.fig', '.jpg']:
430 env.Command(os.path.join(build, s), s, Copy("$TARGET", "$SOURCE"))
433 revaction([env.File(os.path.join(build, s))], [env.File(s)], env)
435 for doc, (targets, depends, nodes) in docs.items():
436 # Read MANIFEST file and copy the listed files to the build directory,
437 # while branding them with the SCons copyright and the current
439 if not os.path.exists(os.path.join(build, doc)):
440 env.Execute(Mkdir(os.path.join(build, doc)))
441 if not os.path.exists(os.path.join(build, doc, 'titlepage')):
442 env.Execute(Mkdir(os.path.join(build, doc, 'titlepage')))
443 manifest = File(os.path.join(doc, 'MANIFEST')).rstr()
444 src_files = _parse_manifest_lines(doc, manifest)
448 doc_s = os.path.join(doc, s)
449 build_s = os.path.join(build, doc, s)
450 base, ext = os.path.splitext(doc_s)
451 head, tail = os.path.split(s)
453 target_dir = os.path.join(build, doc, head)
455 target_dir = os.path.join(build, doc)
456 if ext in ['.fig', '.jpg', '.svg']:
458 env.Command(build_s, doc_s, Copy("$TARGET", "$SOURCE"))
461 btarget = env.File(build_s)
462 depends.append(btarget)
463 revaction([btarget], [env.File(doc_s)], env)
465 # For each document, add targets for each of the selected formats
466 for doc, (targets, depends, nodes) in docs.items():
467 # Call SCons in each local doc folder
469 if env.GetOption('clean'):
471 scdir = os.path.join(build, doc)
473 if 'html' in targets:
474 sctargets.append(env.File(os.path.join(scdir, 'index.html')))
475 if 'chunked' in targets:
477 env.File(os.path.join(scdir, f'scons-{doc}', 'index.html'))
479 if 'pdf' in targets and not skip_pdf_build:
480 sctargets.append(env.File(os.path.join(scdir, f'scons-{doc}.pdf')))
481 if 'epub' in targets:
482 sctargets.append(env.File(os.path.join(scdir, f'scons-{doc}.epub')))
485 for m in man_page_list:
486 # TODO: add targets to an alias?
487 sctargets.append(os.path.join(scdir, m))
488 man, _1 = os.path.splitext(m)
489 if not skip_pdf_build:
490 sctargets.append(os.path.join(scdir, f'scons-{man}.pdf'))
491 sctargets.append(os.path.join(scdir, f'scons-{man}.html'))
493 # pass on the information to skip PDF/EPUB when calling man/guide SConstruct
494 skip_str = "SKIP_PDF=1" if skip_pdf_build else ""
498 source=buildsuite + depends,
499 action="cd %s && $PYTHON ${SCONS_PY.abspath}%s %s" % (scdir, cleanopt, skip_str),
504 for doc, (targets, depends, nodes) in docs.items():
505 # Collect the output files for this subfolder
506 htmldir = os.path.join(build, 'HTML', f'scons-{doc}')
507 htmlindex = os.path.join(htmldir, 'index.html')
508 html = os.path.join(build, 'HTML', f'scons-{doc}.html')
509 pdf = os.path.join(build, 'PDF', f'scons-{doc}.pdf')
510 epub = os.path.join(build, 'EPUB', f'scons-{doc}.epub')
511 text = os.path.join(build, 'TEXT', f'scons-{doc}.txt')
512 if 'chunked' in targets:
513 installed_chtml = env.ChunkedInstall(
515 os.path.join(build, doc, f'scons-{doc}', 'index.html'),
517 installed_chtml_css = env.Install(
518 env.Dir(htmldir), os.path.join(build, doc, 'scons.css')
520 env.Depends(installed_chtml, nodes)
521 env.Depends(installed_chtml_css, nodes)
523 tar_deps.extend([htmlindex, installed_chtml_css])
524 tar_list.extend([htmldir])
526 env.Ignore(htmlindex, version_xml)
528 if 'html' in targets:
530 target=env.File(html),
531 source=env.File(os.path.join(build, doc, 'index.html')),
533 tar_deps.append(html)
534 tar_list.append(html)
536 env.Ignore(html, version_xml)
539 if 'pdf' in targets and not skip_pdf_build:
541 target=env.File(pdf),
542 source=env.File(os.path.join(build, doc, f'scons-{doc}.pdf')),
545 env.Ignore(pdf, version_xml)
550 if 'epub' in targets and not skip_pdf_build and gs:
552 target=env.File(epub),
553 source=env.File(os.path.join(build, doc, f'scons-{doc}.epub')),
556 env.Ignore(epub, version_xml)
558 tar_deps.append(epub)
559 tar_list.append(epub)
564 and ('html' in targets or doc == 'man')
566 texthtml = os.path.join(build, doc, 'index.html')
568 # Special handling for single MAN file
569 texthtml = os.path.join(build, doc, 'scons-scons.html')
573 source=env.File(texthtml),
574 action="lynx -dump ${SOURCE.abspath} > $TARGET",
578 env.Ignore(text, version_xml)
580 tar_deps.append(text)
581 tar_list.append(text)
584 for m in man_page_list:
585 man, _1 = os.path.splitext(m)
587 pdf = os.path.join(build, 'PDF', f'{man}-man.pdf')
588 html = os.path.join(build, 'HTML', f'{man}-man.html')
590 if not skip_pdf_build:
592 target=env.File(pdf),
593 source=env.File(os.path.join(build, 'man', f'scons-{man}.pdf')),
596 target=env.File(html),
597 source=env.File(os.path.join(build, 'man', f'scons-{man}.html')),
600 tar_deps.append(html)
601 tar_list.append(html)
602 if not skip_pdf_build:
606 # Install CSS file, common to all single HTMLs
608 css_file = os.path.join(build, 'HTML', 'scons.css')
610 target=env.File(css_file),
611 source=env.File(os.path.join(build, 'user', 'scons.css')),
613 tar_deps.append(css_file)
614 tar_list.append(css_file)
621 # TODO: Better specify dependencies on source files
622 if not skip_pdf_build:
623 pdf_file = env.Command(
624 target='#/build/doc/api/scons-api.pdf',
625 source=env.Glob('#/SCons/*'),
626 action=[Delete("#/build/doc/api"), "cd doc && make pdf"],
628 pdf_install = os.path.join(build, 'PDF', 'scons-api.pdf')
629 env.InstallAs(target=pdf_install, source=pdf_file)
630 tar_deps.append(pdf_install)
631 tar_list.append(pdf_install)
632 Alias('apidoc', pdf_file)
634 htmldir = os.path.join(build, 'HTML', 'scons-api')
635 html_files = env.Command(
636 target='#/build/doc/HTML/scons-api/index.html',
637 source=env.Glob('#/SCons/*'),
638 action="cd doc && make dirhtml BUILDDIR=${HTMLDIR}",
641 tar_deps.append(htmldir)
642 tar_list.append(htmldir)
643 Alias('apidoc', html_files)
646 # Now actually create the tar file of the documentation,
647 # for easy distribution to the web site.
650 tar_list = ' '.join([x.replace(build + '/', '') for x in tar_list])
652 target=dist_doc_tar_gz,
654 action="tar cf${TAR_HFLAG} - -C %s %s | gzip > $TARGET" % (build, tar_list),
656 AddPostAction(dist_doc_tar_gz, Chmod(dist_doc_tar_gz, 0o644))
660 Alias('doc', os.path.join(command_line.build_dir, 'doc'))