Annotated CHANGES/RELEASE with issue these changes fix
[scons.git] / doc / SConscript
blobcb92b41487250c09d2dd486e423ab0eb0095c1a0
1 # MIT License
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."""
26 import glob
27 import os.path
28 import re
29 import shutil
30 import sys
31 import time
33 import SCons.Builder
34 import SCons.Util
36 Import('command_line', 'env', 'whereis', 'revaction')
39 # -- Check prerequisites for building the documentation ---
41 skip_doc = False
43 try:
44     import SConsDoc
45     import SConsExamples
46 except ImportError as exc:
47     print("doc: SConsDoc failed to import, the error was:")
48     print(f"         ImportError: {exc}")
49     print("     Please make sure that python-lxml is installed.")
50     skip_doc = True
52 fop = whereis('fop')
53 xep = whereis('xep')
55 if not fop and not xep:
56     print("doc: No PDF renderer found (fop|xep)!")
57     skip_doc = True
59 skip_doc_arg = ARGUMENTS.get('SKIP_DOC')
60 if skip_doc_arg is not None:
61     skip_doc = skip_doc_arg in ['True', '1', 'true']
64 # --- Configure build
66 env = env.Clone()
68 build = os.path.join(command_line.build_dir, 'doc')
70 gs = whereis('gs')
71 lynx = whereis('lynx')
73 dist_doc_tar_gz = '$DISTDIR/scons-doc-${VERSION}.tar.gz'
75 tar_deps = []
76 tar_list = []
78 orig_env = env
79 env = orig_env.Clone(SCONS_PY=File('#/scripts/scons.py').rfile())
82 # --- Helpers ---
84 def writeVersionXml(verfile, date, ver, rev, copyright_years):
85     """Helper function: Write a version.xml file."""
86     try:
87         os.unlink(verfile)
88     except OSError:
89         pass  # okay if the file didn't exist
90     dir, f = os.path.split(verfile)
91     os.makedirs(dir, exist_ok=True)
92     with open(verfile, "w") as vf:
93         vf.write(f"""\
94 <!--
95 THIS IS AN AUTOMATICALLY-GENERATED FILE.  DO NOT EDIT.
96 -->
97 <!ENTITY builddate "{date}">
98 <!ENTITY buildversion "{ver}">
99 <!ENTITY buildrevision "{rev}">
100 <!ENTITY copyright_years "{copyright_years}">
101 """)
104 # The names of the target files for the MAN pages
105 man_page_list = ['scons.1', 'scons-time.1', 'sconsign.1']
107 # Template for the MAN page texts when we can't properly
108 # create them because the skip_doc flag is set (required
109 # modules/tools aren't installed in the current system)
110 man_replace_tpl = r""".TH "%(uctitle)s" "1" "%(today)s" "SCons %(version)s" "SCons %(version)s"
111 .ie \n(.g .ds Aq \(aq
112 .el       .ds Aq '
114 .ad l
115 .SH "NOTE"
116 %(title)s \- This is a replacement file, stemming from an incomplete
117 packaging process without the required doc modules installed. Please
118 update the system and try running the build again.
122 # --- Processing ---
124 if skip_doc:
125     print("doc: ...skipping building User Guide.")
126     print("     ...creating fake MAN pages.")
128     # Since the top-level SConstruct requires the MAN
129     # pages to exist for the basic packaging, we create simple
130     # stub texts here as replacement...
131     scdir = os.path.join(build, 'man')
132     if not os.path.isdir(scdir):
133         os.makedirs(scdir)
135     today = time.strftime(
136         "%Y-%m-%d", time.gmtime(int(os.environ.get('SOURCE_DATE_EPOCH', time.time())))
137     )
138     version = env.subst('$VERSION')
139     for m in man_page_list:
140         man, _ = os.path.splitext(m)
141         with open(os.path.join(scdir, m), "w") as fman:
142             fman.write(
143                 man_replace_tpl
144                 % {
145                     'uctitle': man.upper().replace("-", "\\-"),
146                     'today': today,
147                     'title': man,
148                     'version': version,
149                 }
150             )
151 else:
152     if not lynx:
153         print(
154             "doc: Warning, lynx is not installed. "
155             "Created release packages will not be complete!"
156         )
158     # Always create a version.xml file containing the version information
159     # for this run.  Ignore it for dependency purposes so we don't
160     # rebuild all the docs every time just because the date changes.
161     date, ver, rev, copyright_years = env.Dictionary(
162         'DATE', 'VERSION', 'REVISION', 'COPYRIGHT_YEARS'
163     )
164     version_xml = File(os.path.join(build, "version.xml"))
165     writeVersionXml(str(version_xml), date, ver, rev, copyright_years)
167     def _glob_install_action(target, source, env):
168         """Builder for copying files to an Install dir.
170         Selection is based on globbing for filename extension.
171         """
172         if not SCons.Util.is_List(target):
173             target = [target]
174         if not SCons.Util.is_List(source):
175             source = [source]
176         for t, s in zip(target, source):
177             shutil.copy(str(s), str(t))
179     def _glob_install_emitter(target, source, env):
180         """Emitter for GlobInstall Builder."""
181         if not SCons.Util.is_List(target):
182             target = [target]
183         if not SCons.Util.is_List(source):
184             source = [source]
186         res = []
187         res_src = []
188         tdir = env.Dir(target[0])
189         for g in glob.glob(str(source[0])):
190             head, tail = os.path.split(g)
191             res.append(os.path.join(str(tdir), tail))
192             res_src.append(g)
193         return res, res_src
195     _glob_install_builder = SCons.Builder.Builder(
196         action=_glob_install_action, emitter=_glob_install_emitter
197     )
198     env['BUILDERS']['GlobInstall'] = _glob_install_builder
200     def _chunked_install_action(target, source, env):
201         """Builder for copying ChunkedHTML files to an Install dir."""
202         if not SCons.Util.is_List(target):
203             target = [target]
204         if not SCons.Util.is_List(source):
205             source = [source]
206         tdir, tail = os.path.split(str(target[0]))
207         spattern = os.path.join(os.path.split(str(source[0]))[0], '*.html')
208         for g in glob.glob(spattern):
209             shutil.copy(g, tdir)
211     def _chunked_install_emitter(target, source, env):
212         """Emitter for ChunkedInstall Builder."""
213         if not SCons.Util.is_List(target):
214             target = [target]
215         if not SCons.Util.is_List(source):
216             source = [source]
218         tdir = env.Dir(target[0])
219         head, tail = os.path.split(str(source[0]))
220         return os.path.join(str(tdir), tail), source
222     _chunked_install_builder = SCons.Builder.Builder(
223         action=_chunked_install_action, emitter=_chunked_install_emitter
224     )
225     env['BUILDERS']['ChunkedInstall'] = _chunked_install_builder
227     if not env.GetOption('clean'):
228         #
229         # Ensure that all XML files are valid against our XSD, and
230         # that all example names and example output suffixes are unique
231         #
232         print("Validating files against SCons XSD...")
233         if SConsDoc.validate_all_xml(['SCons'], xsdfile='xsd/scons.xsd'):
234             print("OK")
235         else:
236             print("Validation failed! Please correct the errors above and try again.")
237             sys.exit(1)
239         print("Checking whether all example names are unique...")
240         if SConsExamples.exampleNamesAreUnique(os.path.join('doc', 'user')):
241             print("OK")
242         else:
243             print(
244                 "Not all example names and suffixes are unique! "
245                 "Please correct the errors listed above and try again."
246             )
247             sys.exit(1)
249     # List of prerequisite files in the build/doc folder
250     buildsuite = []
252     def copy_dbfiles(env, toolpath, paths, fpattern, use_builddir=True):
253         """Helper function, copies a bunch of files matching
254         the given fpattern to a target directory.
255         """
256         global buildsuite
257         if not SCons.Util.is_List(toolpath):
258             toolpath = [toolpath]
259         if not SCons.Util.is_List(paths):
260             paths = [paths]
261         if not SCons.Util.is_List(fpattern):
262             fpattern = [fpattern]
264         if use_builddir:
265             target_dir = env.Dir(
266                 os.path.join(command_line.build_dir, *(toolpath + paths))
267             )
268             buildsuite.extend(
269                 env.GlobInstall(
270                     target_dir, os.path.join('..', *(toolpath + paths + fpattern))
271                 )
272             )
273         else:
274             target_dir = env.Dir(os.path.join(*(toolpath + paths)))
275             buildsuite.extend(
276                 env.GlobInstall(target_dir, os.path.join(*(paths + fpattern)))
277             )
279     #
280     # Copy generated files (.gen/.mod/.xml) to the build folder
281     #
282     copy_dbfiles(env, build, 'generated', '*.gen', False)
283     copy_dbfiles(env, build, 'generated', '*.mod', False)
284     copy_dbfiles(env, build, ['generated', 'examples'], '*', False)
286     #
287     # Copy XSLT files (.xslt) to the build folder
288     #
289     copy_dbfiles(env, build, 'xslt', '*.*', False)
291     #
292     # Copy DocBook stylesheets and Tool to the build folder
293     #
294     dbtoolpath = ['SCons', 'Tool', 'docbook']
295     copy_dbfiles(env, dbtoolpath, [], '__init__.py')
296     copy_dbfiles(env, dbtoolpath, 'utils', 'xmldepend.xsl')
297     dbpath = dbtoolpath + ['docbook-xsl-1.76.1']
298     copy_dbfiles(env, dbpath, [], 'VERSION')
299     copy_dbfiles(env, dbpath, ['common'], '*.*')
300     copy_dbfiles(env, dbpath, ['lib'], '*.*')
301     copy_dbfiles(env, dbpath, ['html'], '*.*')
302     copy_dbfiles(env, dbpath, ['fo'], '*.*')
303     copy_dbfiles(env, dbpath, ['manpages'], '*.*')
304     copy_dbfiles(env, dbpath, ['epub'], '*.xsl')
305     copy_dbfiles(env, dbpath, ['xhtml-1_1'], '*.*')
307     #
308     # Copy additional Tools (gs, zip)
309     #
310     toolpath = ['SCons', 'Tool']
311     copy_dbfiles(env, toolpath, [], 'gs.py')
312     copy_dbfiles(env, toolpath, [], 'zip.py')
314     # Each document will live in its own subdirectory "build/doc/xxx".
315     # List them here by their subfolder names. Note how the specifiers
316     # for each subdir (=DOCTARGETS) have nothing to do with which
317     # formats get created, but which of the outputs get installed
318     # to the build folder and added to the different source and binary
319     # packages in the end.
320     #
321     # In addition to the list of target formats (DOCTARGETS), we also
322     # store some dependency information in this dict. The DOCDEPENDS
323     # list contains all files from each local "MANIFEST", after
324     # installing/copying them to the build directory. It basically
325     # links the original sources to the respective build folder,
326     # such that a simple 'python bootstrap.py' rebuilds the
327     # documentation when a file, like 'doc/user/depends.xml'
328     # for example, changes.
329     #
330     # Finally, in DOCNODES we store the created PDF and HTML files,
331     # such that we can then install them in the proper places for
332     # getting picked up by the archiving/packaging stages.
333     DOCTARGETS = 0
334     DOCDEPENDS = 1
335     DOCNODES = 2
336     docs = {
337         # 'design': (['chunked', 'pdf'], [], []),
338         # 'python10' : (['chunked','html','pdf'], [], []),
339         # 'reference': (['chunked', 'html', 'pdf'], [], []),
340         # 'developer' : (['chunked','html','pdf'], [], []),
341         'user': (['chunked', 'html', 'pdf', 'epub', 'text'], [], []),
342         'man': (['man', 'epub', 'text'], [], []),
343     }
345     #
346     # We have to tell SCons to scan the top-level XML files which
347     # get included by the document XML files in the subdirectories.
348     #
350     def _parse_manifest_lines(basedir, manifest) -> list:
351         """
352         Scans a MANIFEST file, and returns the list of source files.
354         Has basic support for recursive globs '**',
355         filename wildcards of the form '*.xml' and
356         comment lines, starting with a '#'.
358         Args:
359            basedir: base path to find files in. Note this does not
360               run in an SCons context so path must not need
361               further processing (e.g. no '#' signs)
362            manifest: path to manifest file
363         """
364         sources = []
365         basewd = os.path.abspath(basedir)
366         with open(manifest) as m:
367             lines = m.readlines()
368         for l in lines:
369             if l.startswith('#'):
370                 # Skip comments
371                 continue
372             l = l.rstrip('\n')
373             if l.endswith('**'):
374                 # Glob all files recursively
375                 globwd = os.path.dirname(os.path.join(basewd, l))
376                 for path, dirs, files in os.walk(globwd):
377                     for f in files:
378                         fpath = os.path.join(globwd, path, f)
379                         sources.append(os.path.relpath(fpath, basewd))
380             elif '*' in l:
381                 # Glob file pattern
382                 files = glob.glob(os.path.join(basewd, l))
383                 for f in files:
384                     sources.append(os.path.relpath(f, basewd))
385             else:
386                 sources.append(l)
388         return sources
391     manifest = File('MANIFEST').rstr()
392     src_files = _parse_manifest_lines('.', manifest)
393     for s in src_files:
394         if not s:
395             continue
396         base, ext = os.path.splitext(s)
397         if ext in ['.fig', '.jpg']:
398             buildsuite.extend(
399                 env.Command(os.path.join(build, s), s, Copy("$TARGET", "$SOURCE"))
400             )
401         else:
402             revaction([env.File(os.path.join(build, s))], [env.File(s)], env)
404     for doc in docs:
405         # Read MANIFEST file and copy the listed files to the build directory,
406         # while branding them with the SCons copyright and the current
407         # revision number...
408         if not os.path.exists(os.path.join(build, doc)):
409             env.Execute(Mkdir(os.path.join(build, doc)))
410         if not os.path.exists(os.path.join(build, doc, 'titlepage')):
411             env.Execute(Mkdir(os.path.join(build, doc, 'titlepage')))
412         manifest = File(os.path.join(doc, 'MANIFEST')).rstr()
413         src_files = _parse_manifest_lines(doc, manifest)
414         for s in src_files:
415             if not s:
416                 continue
417             doc_s = os.path.join(doc, s)
418             build_s = os.path.join(build, doc, s)
419             base, ext = os.path.splitext(doc_s)
420             head, tail = os.path.split(s)
421             if head:
422                 target_dir = os.path.join(build, doc, head)
423             else:
424                 target_dir = os.path.join(build, doc)
425             if ext in ['.fig', '.jpg', '.svg']:
426                 docs[doc][DOCDEPENDS].extend(
427                     env.Command(build_s, doc_s, Copy("$TARGET", "$SOURCE"))
428                 )
429             else:
430                 btarget = env.File(build_s)
431                 docs[doc][DOCDEPENDS].append(btarget)
432                 revaction([btarget], [env.File(doc_s)], env)
434     #
435     # For each document, build the document itself in HTML,
436     # and PDF formats.
437     #
438     for doc in docs:
440         #
441         # Call SCons in each local doc folder
442         #
443         cleanopt = ''
444         if env.GetOption('clean'):
445             cleanopt = ' -c'
446         scdir = os.path.join(build, doc)
447         sctargets = []
448         if 'html' in docs[doc][DOCTARGETS]:
449             sctargets.append(env.File(os.path.join(scdir, 'index.html')))
450         if 'chunked' in docs[doc][DOCTARGETS]:
451             sctargets.append(
452                 env.File(os.path.join(scdir, f'scons-{doc}', 'index.html'))
453             )
454         if 'pdf' in docs[doc][DOCTARGETS]:
455             sctargets.append(env.File(os.path.join(scdir, f'scons-{doc}.pdf')))
456         if 'epub' in docs[doc][DOCTARGETS]:
457             sctargets.append(env.File(os.path.join(scdir, f'scons-{doc}.epub')))
459         if 'man' in docs[doc][DOCTARGETS]:
460             for m in man_page_list:
461                 sctargets.append(os.path.join(scdir, m))
462                 man, _1 = os.path.splitext(m)
464                 sctargets.append(os.path.join(scdir, f'scons-{man}.pdf'))
465                 sctargets.append(os.path.join(scdir, f'scons-{man}.html'))
467         docs[doc][DOCNODES].extend(
468             env.Command(
469                 target=sctargets,
470                 source=buildsuite + docs[doc][DOCDEPENDS],
471                 action="cd %s && $PYTHON ${SCONS_PY.abspath}%s" % (scdir, cleanopt),
472             )
473         )
475     install_css = False
476     for doc in docs:
478         # Collect the output files for this subfolder
479         htmldir = os.path.join(build, 'HTML', f'scons-{doc}')
480         htmlindex = os.path.join(htmldir, 'index.html')
481         html = os.path.join(build, 'HTML', f'scons-{doc}.html')
482         pdf = os.path.join(build, 'PDF', f'scons-{doc}.pdf')
483         epub = os.path.join(build, 'EPUB', f'scons-{doc}.epub')
484         text = os.path.join(build, 'TEXT', f'scons-{doc}.txt')
485         if 'chunked' in docs[doc][DOCTARGETS]:
486             installed_chtml = env.ChunkedInstall(
487                 env.Dir(htmldir),
488                 os.path.join(build, doc, f'scons-{doc}', 'index.html'),
489             )
490             installed_chtml_css = env.Install(
491                 env.Dir(htmldir), os.path.join(build, doc, 'scons.css')
492             )
493             env.Depends(installed_chtml, docs[doc][DOCNODES])
494             env.Depends(installed_chtml_css, docs[doc][DOCNODES])
496             tar_deps.extend([htmlindex, installed_chtml_css])
497             tar_list.extend([htmldir])
498             Local(htmlindex)
499             env.Ignore(htmlindex, version_xml)
501         if 'html' in docs[doc][DOCTARGETS]:
502             env.InstallAs(
503                 target=env.File(html),
504                 source=env.File(os.path.join(build, doc, 'index.html')),
505             )
506             tar_deps.extend([html])
507             tar_list.extend([html])
508             Local(html)
509             env.Ignore(html, version_xml)
510             install_css = True
512         if 'pdf' in docs[doc][DOCTARGETS]:
513             env.InstallAs(
514                 target=env.File(pdf),
515                 source=env.File(os.path.join(build, doc, f'scons-{doc}.pdf')),
516             )
517             Local(pdf)
518             env.Ignore(pdf, version_xml)
520             tar_deps.append(pdf)
521             tar_list.append(pdf)
523         if 'epub' in docs[doc][DOCTARGETS] and gs:
524             env.InstallAs(
525                 target=env.File(epub),
526                 source=env.File(os.path.join(build, doc, f'scons-{doc}.epub')),
527             )
528             Local(epub)
529             env.Ignore(epub, version_xml)
531             tar_deps.append(epub)
532             tar_list.append(epub)
534         if (
535             'text' in docs[doc][DOCTARGETS]
536             and lynx
537             and (('html' in docs[doc][DOCTARGETS]) or (doc == 'man'))
538         ):
539             texthtml = os.path.join(build, doc, 'index.html')
540             if doc == 'man':
541                 # Special handling for single MAN file
542                 texthtml = os.path.join(build, doc, 'scons-scons.html')
544             env.Command(
545                 target=text,
546                 source=env.File(texthtml),
547                 action="lynx -dump ${SOURCE.abspath} > $TARGET",
548             )
549             Local(text)
551             env.Ignore(text, version_xml)
553             tar_deps.append(text)
554             tar_list.append(text)
556         if 'man' in docs[doc][DOCTARGETS]:
557             for m in man_page_list:
558                 man, _1 = os.path.splitext(m)
560                 pdf = os.path.join(build, 'PDF', f'{man}-man.pdf')
561                 html = os.path.join(build, 'HTML', f'{man}-man.html')
563                 env.InstallAs(
564                     target=env.File(pdf),
565                     source=env.File(os.path.join(build, 'man', f'scons-{man}.pdf')),
566                 )
567                 env.InstallAs(
568                     target=env.File(html),
569                     source=env.File(os.path.join(build, 'man', f'scons-{man}.html')),
570                 )
572                 tar_deps.extend([pdf, html])
573                 tar_list.extend([pdf, html])
575     # Install CSS file, common to all single HTMLs
576     if install_css:
577         css_file = os.path.join(build, 'HTML', 'scons.css')
578         env.InstallAs(
579             target=env.File(css_file),
580             source=env.File(os.path.join(build, 'user', 'scons.css')),
581         )
582         tar_deps.extend([css_file])
583         tar_list.extend([css_file])
584         Local(css_file)
586 if not skip_doc:
587     # Build API DOCS
588     # TODO: Better specify dependencies on source files
589     pdf_file = env.Command(
590         target='#/build/doc/api/scons-api.pdf',
591         source=env.Glob('#/SCons/*'),
592         action=[Delete("#/build/doc/api"), "cd doc && make pdf"],
593     )
594     pdf_install = os.path.join(build, 'PDF', 'scons-api.pdf')
595     env.InstallAs(target=pdf_install, source=pdf_file)
596     tar_deps.append(pdf_install)
597     tar_list.append(pdf_install)
599     htmldir = os.path.join(build, 'HTML', 'scons-api')
600     html_files = env.Command(
601         target='#/build/doc/HTML/scons-api/index.html',
602         source=env.Glob('#/SCons/*'),
603         action="cd doc && make dirhtml BUILDDIR=${HTMLDIR}",
604         HTMLDIR=htmldir,
605     )
606     tar_deps.append(htmldir)
607     tar_list.append(htmldir)
610 # Now actually create the tar file of the documentation,
611 # for easy distribution to the web site.
613 if tar_deps:
614     tar_list = ' '.join([x.replace(build + '/', '') for x in tar_list])
615     t = env.Command(
616         target=dist_doc_tar_gz,
617         source=tar_deps,
618         action="tar cf${TAR_HFLAG} - -C %s %s | gzip > $TARGET" % (build, tar_list),
619     )
620     AddPostAction(dist_doc_tar_gz, Chmod(dist_doc_tar_gz, 0o644))
621     Local(t)
622     Alias('doc', t)
623 else:
624     Alias('doc', os.path.join(command_line.build_dir, 'doc'))