Coverage for python/felis/browser/render.py: 100%

86 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-30 08:42 +0000

1"""Browser rendering utilities for generating static HTML pages from 

2schemas. 

3""" 

4 

5# This file is part of felis. 

6# 

7# Developed for the LSST Data Management System. 

8# This product includes software developed by the LSST Project 

9# (https://www.lsst.org). 

10# See the COPYRIGHT file at the top-level directory of this distribution 

11# for details of code ownership. 

12# 

13# This program is free software: you can redistribute it and/or modify 

14# it under the terms of the GNU General Public License as published by 

15# the Free Software Foundation, either version 3 of the License, or 

16# (at your option) any later version. 

17# 

18# This program is distributed in the hope that it will be useful, 

19# but WITHOUT ANY WARRANTY; without even the implied warranty of 

20# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

21# GNU General Public License for more details. 

22# 

23# You should have received a copy of the GNU General Public License 

24# along with this program. If not, see <https://www.gnu.org/licenses/>. 

25 

26from __future__ import annotations 

27 

28import json 

29import shutil 

30from collections.abc import Iterable 

31from datetime import UTC, datetime 

32from importlib import resources 

33from pathlib import Path 

34from typing import Any 

35 

36from jinja2 import Environment, PackageLoader, pass_context 

37from jinja2.runtime import Context 

38from lsst.utils.iteration import ensure_iterable 

39 

40from felis.datamodel import BaseObject, Column, Schema 

41 

42from .columns import _column_field, _column_tags, _table_column_fields 

43from .config import SiteConfig 

44from .diagram import _RelationshipDiagram 

45from .navdata import _NavData 

46from .schemas import _resolve_column, _schema_version, _sorted_by_name, _version_order 

47from .sitemap import SiteMap, _relative_href 

48 

49_ASSETS = ("style.css", "sidebar.js", "page.js", "search.js") 

50"""Static files copied into the site's ``assets`` directory.""" 

51 

52 

53_NAV_DATA = "nav-data.js" 

54"""Generated script defining ``window.FELIS_NAV``, the sidebar navigation data 

55shared by all pages. 

56""" 

57 

58 

59_MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") 

60"""Month names for the build date in the footer, in English whatever the 

61locale, which ``%b`` would follow. 

62""" 

63 

64 

65class _TemplateFilters: 

66 """The template filters giving the pages access to the site map.""" 

67 

68 def __init__(self, site: SiteMap) -> None: 

69 self._site = site 

70 

71 @pass_context 

72 def href(self, context: Context, obj: BaseObject) -> str: 

73 return _relative_href(context["page_path"], self._site.path(obj)) 

74 

75 @pass_context 

76 def columns(self, context: Context, refs: str | list[str]) -> list[Column]: 

77 return [_resolve_column(context["schema"], ref) for ref in ensure_iterable(refs)] 

78 

79 def relationship_diagram(self, schema: Schema) -> str | None: 

80 return _RelationshipDiagram(schema).text() 

81 

82 def all(self) -> dict[str, Any]: 

83 return { 

84 "href": self.href, 

85 "path": self._site.path, 

86 "anchor": self._site.anchor, 

87 "table_of": self._site.table_of, 

88 "columns": self.columns, 

89 "relationship_diagram": self.relationship_diagram, 

90 "field": _column_field, 

91 "column_fields": _table_column_fields, 

92 "version": _schema_version, 

93 "version_order": _version_order, 

94 "sorted_by_name": _sorted_by_name, 

95 } 

96 

97 

98def _none_as_empty(value: object) -> object: 

99 """Show `None` in a template as nothing, rather than "None".""" 

100 return "" if value is None else value 

101 

102 

103def _environment(site: SiteMap) -> Environment: 

104 """Return the Jinja environment, with filters giving templates the site 

105 map. 

106 """ 

107 env = Environment( 

108 loader=PackageLoader("felis.browser", "templates"), 

109 # Every template is HTML, including page_macros.j2, whose macros 

110 # are compiled with its own setting, not their callers'. 

111 autoescape=True, 

112 trim_blocks=True, 

113 lstrip_blocks=True, 

114 finalize=_none_as_empty, 

115 ) 

116 env.filters.update(_TemplateFilters(site).all()) 

117 return env 

118 

119 

120def _write_page( 

121 env: Environment, 

122 output_dir: Path, 

123 template_name: str, 

124 page_path: str, 

125 **context: object, 

126) -> None: 

127 """Render a template and write it to ``page_path`` under ``output_dir``.""" 

128 target_path = output_dir / page_path 

129 target_path.parent.mkdir(parents=True, exist_ok=True) 

130 # The site root, relative to the page, such as "../.." for a table page. 

131 site_root = _relative_href(page_path, ".") 

132 html = env.get_template(template_name).render(page_path=page_path, site_root=site_root, **context) 

133 target_path.write_text(html, encoding="utf-8") 

134 

135 

136def _stylesheet_names(config: SiteConfig) -> dict[str, Path]: 

137 """Return the configured stylesheets by their numbered names in the site's 

138 assets. 

139 """ 

140 return {f"custom-{number}-{path.name}": path for number, path in enumerate(config.stylesheets, start=1)} 

141 

142 

143def _icon_name(config: SiteConfig) -> str | None: 

144 return f"site-icon-{config.icon.name}" if config.icon else None 

145 

146 

147def _favicon_name(config: SiteConfig) -> str | None: 

148 return f"favicon{config.favicon.suffix}" if config.favicon else None 

149 

150 

151def _page_globals(config: SiteConfig) -> dict[str, Any]: 

152 """Return the template variables shared by every page.""" 

153 today = datetime.now(UTC) 

154 return { 

155 "site_title": config.title, 

156 "site_description": config.description, 

157 "custom_stylesheets": list(_stylesheet_names(config)), 

158 "site_icon": _icon_name(config), 

159 "site_favicon": _favicon_name(config), 

160 "diagrams": config.diagrams, 

161 "show_empty_columns": config.show_empty_columns, 

162 "maintainer": config.maintainer, 

163 "build_date": f"{_MONTHS[today.month - 1]} {today.day}, {today.year}", 

164 } 

165 

166 

167def _write_assets(assets_dir: Path, site: SiteMap, config: SiteConfig) -> None: 

168 """Write the files shared by the pages into the site's ``assets`` 

169 directory. 

170 """ 

171 assets_dir.mkdir(parents=True, exist_ok=True) 

172 # The built-in files, from the package. 

173 for asset_name in _ASSETS: 

174 source = resources.files("felis.browser").joinpath(f"static/{asset_name}").read_text(encoding="utf-8") 

175 (assets_dir / asset_name).write_text(source, encoding="utf-8") 

176 # The files named in the site configuration, by their names in the site. 

177 config_files = _stylesheet_names(config) 

178 icon_name = _icon_name(config) 

179 if config.icon and icon_name: 

180 config_files[icon_name] = config.icon 

181 favicon_name = _favicon_name(config) 

182 if config.favicon and favicon_name: 

183 config_files[favicon_name] = config.favicon 

184 for asset_name, path in config_files.items(): 

185 shutil.copyfile(path, assets_dir / asset_name) 

186 nav_json = json.dumps(_NavData(site).data(), separators=(",", ":")) 

187 (assets_dir / _NAV_DATA).write_text(f"window.FELIS_NAV = {nav_json};\n", encoding="utf-8") 

188 

189 

190def render_static_site(schemas: Iterable[Schema], output_dir: Path, config: SiteConfig | None = None) -> None: 

191 """Render a static browser site for browsing schemas. 

192 

193 The generated pages include schema, table, and column navigation. 

194 

195 Parameters 

196 ---------- 

197 schemas 

198 Schemas to render. 

199 output_dir 

200 Directory where the rendered HTML files will be saved. 

201 config 

202 How the site looks; the defaults if not given. Its stylesheets are 

203 copied into the site's ``assets`` directory as ``custom-<N>-<name>``. 

204 """ 

205 config = config or SiteConfig() 

206 site = SiteMap(schemas) 

207 env = _environment(site) 

208 env.globals.update(_page_globals(config)) 

209 

210 _write_page(env, output_dir, "index.html.j2", "index.html", schemas=site.schemas) 

211 for schema in site.schemas: 

212 _write_page(env, output_dir, "schema.html.j2", site.path(schema), schema=schema) 

213 for table in schema.tables: 

214 tags = _column_tags(schema, table) 

215 _write_page( 

216 env, output_dir, "table.html.j2", site.path(table), schema=schema, table=table, tags=tags 

217 ) 

218 _write_assets(output_dir / "assets", site, config)