Coverage for lode / builder.py: 87%

146 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-10-05 16:02 +0000

1# lode/builder.py 

2import re 

3import shutil 

4from pathlib import Path 

5from urllib.parse import quote 

6from jinja2 import Environment, FileSystemLoader, select_autoescape 

7import importlib.resources 

8from rdflib import URIRef 

9from rdflib.namespace import split_uri 

10 

11# internal modules 

12from lode.viewer.base_viewer import SERIALIZATION_FORMATS 

13from lode.helpers.html_utils import minify as _minify 

14from lode.models.model import Model 

15 

16 

17_SER = [(f["fmt"], f["ext"]) for f in SERIALIZATION_FORMATS] 

18 

19def _get_template_env(static_path: str = "static") -> Environment: 

20 try: 

21 pkg_path = importlib.resources.files("lode").joinpath("templates") 

22 template_dir = str(pkg_path) 

23 except Exception: 

24 template_dir = str(Path(__file__).parent / "templates") 

25 

26 env = Environment( 

27 loader=FileSystemLoader(template_dir), 

28 autoescape=select_autoescape(["html"]), 

29 ) 

30 

31 def url_for(name: str, path: str = "") -> str: 

32 if name == "static": 

33 return f"{static_path}/{path}" 

34 return "/" 

35 

36 env.globals["url_for"] = url_for 

37 return env 

38 

39def _prefix_map(graph): 

40 return {str(ns): (p or 'default') for p, ns in graph.namespaces()} 

41 

42def _safe_segment(seg: str) -> str: 

43 """Restrict a slug path segment to a filesystem-safe whitelist. 

44 

45 Prefix and local name come from IRIs in the loaded ontology, i.e. from 

46 user-controlled input: path separators, '..', NUL etc. must never reach 

47 the filesystem (path injection). An empty result means "no usable local 

48 part" and is treated by callers as a namespace IRI (page skipped). 

49 """ 

50 seg = re.sub(r"[^A-Za-z0-9._-]", "-", seg).strip("-.") 

51 if not seg or set(seg) == {"."}: 

52 return "" 

53 return seg[:100] 

54 

55 

56def _rel_slug(uri, graph): 

57 prefix, ns, local = graph.namespace_manager.compute_qname(URIRef(str(uri)), generate=True) 

58 prefix = _safe_segment(prefix or "default") or "default" 

59 return f"{prefix}/{_safe_segment(local)}" 

60 

61 

62def _local_name(uri: str) -> str: 

63 return uri.split('#', 1)[1] if '#' in uri else uri.rstrip('/').rsplit('/', 1)[-1] 

64 

65def _copy_static(out_dir: Path) -> None: 

66 static_src = Path(__file__).parent / "static" 

67 static_dst = out_dir / "static" 

68 if static_src.exists() and not static_dst.exists(): 

69 shutil.copytree(static_src, static_dst) 

70 

71def build_html(viewer, out_dir: Path, lang: str = "en", reader=None) -> None: 

72 from collections import defaultdict 

73 

74 out_dir.mkdir(parents=True, exist_ok=True) 

75 _copy_static(out_dir) 

76 

77 ontology_ns = viewer._get_ontology_ns() 

78 graph = reader._graph if reader else viewer.reader._graph 

79 

80 grouped = defaultdict(list) 

81 for inst in viewer.get_all_instances(): 

82 grouped[type(inst).__name__].append(inst) 

83 

84 toc_config = viewer.get_toc_config() if hasattr(viewer, "get_toc_config") else [] 

85 

86 # URI that will actually get a file (same criterion as the loop below) 

87 materialized = set() 

88 for class_key, _sid, _t in toc_config: 

89 for inst in grouped.get(class_key, []): 

90 u = inst.get_has_identifier() 

91 if not u: 

92 continue 

93 if not (u.startswith(ontology_ns) or viewer._has_local_triples(u)): 

94 continue 

95 if _rel_slug(u, graph).rsplit("/", 1)[-1] == "": # namespace IRI, no local 

96 continue 

97 materialized.add(str(u)) 

98 

99 def _keep(uri): 

100 return str(uri) in materialized 

101 

102 def resource_url_resource(uri, section=None): 

103 if not _keep(uri): 

104 return uri 

105 rel = _rel_slug(uri, graph) 

106 if rel.rsplit("/", 1)[-1] == "": # namespace IRI 

107 return "../../index.html" 

108 return f"../{rel}.html" 

109 

110 def resource_url_index(uri, section=None): 

111 if not _keep(uri): 

112 return uri 

113 rel = _rel_slug(uri, graph) 

114 if rel.rsplit("/", 1)[-1] == "": 

115 return "index.html" 

116 return f"resources/{rel}.html" 

117 

118 env_index = _get_template_env(static_path="static") 

119 env_resource = _get_template_env(static_path="../../static") 

120 

121 if reader is not None: 

122 for fmt, ext in _SER: 

123 out = reader._graph.serialize(format=fmt) 

124 if isinstance(out, bytes): 

125 out = out.decode("utf-8") 

126 (out_dir / f"ontology.{ext}").write_text(out, encoding="utf-8") 

127 

128 template_index = env_index.get_template("viewer.html") 

129 template_resource = env_resource.get_template("viewer.html") 

130 

131 toc_config = viewer.get_toc_config() if hasattr(viewer, "get_toc_config") else [] 

132 

133 # --- index.html --- 

134 data = viewer.get_view_data(language=lang) 

135 data["request"] = _FakeRequest("/") 

136 data["is_static"] = True 

137 data["resource_url"] = resource_url_index 

138 data["export_base"] = "ontology" 

139 (out_dir / "index.html").write_text(_minify(template_index.render(**data)), encoding="utf-8") 

140 print(" [build] index.html") 

141 

142 if not toc_config: 

143 return 

144 

145 res_dir = out_dir / "resources" 

146 res_dir.mkdir(parents=True, exist_ok=True) 

147 

148 count = 0 

149 for class_key, section_id, _ in toc_config: 

150 for inst in grouped.get(class_key, []): 

151 uri = inst.get_has_identifier() 

152 if not uri: 

153 continue 

154 native = uri.startswith(ontology_ns) 

155 if not (native or viewer._has_local_triples(uri)): 

156 continue 

157 

158 rel = _rel_slug(uri, graph) 

159 if rel.rsplit("/", 1)[-1] == "": # namespace IRI, no local part 

160 continue 

161 

162 page = res_dir / f"{rel}.html" 

163 # Defense in depth: even if a slug ever slipped through the 

164 # whitelist, never write outside the resources directory. 

165 if not page.resolve().is_relative_to(res_dir.resolve()): 

166 continue 

167 page.parent.mkdir(parents=True, exist_ok=True) 

168 

169 for fmt, ext in _SER: 

170 out = viewer.export_resource(uri, fmt) 

171 if isinstance(out, bytes): 

172 out = out.decode("utf-8") 

173 (res_dir / f"{rel}.{ext}").write_text(out, encoding="utf-8") 

174 

175 data_r = viewer.get_view_data(resource_uri=uri, language=lang) 

176 data_r["request"] = _FakeRequest(f"resources/{rel}.html") 

177 data_r["is_static"] = True 

178 data_r["resource_url"] = resource_url_resource 

179 data_r["export_base"] = _local_name(uri) 

180 page.write_text(_minify(template_resource.render(**data_r)), encoding="utf-8") 

181 count += 1 

182 

183class _FakeRequest: 

184 def __init__(self, path: str): 

185 self.url = _FakeURL(path) 

186 self.method = "GET" 

187 self.query_params = _FakeQueryParams() 

188 self.is_static = True 

189 

190class _FakeQueryParams: 

191 def get(self, key: str, default: str = "") -> str: 

192 return default 

193 

194 # Accesso diretto tipo request.query_params.read_as 

195 def __getattr__(self, key: str) -> str: 

196 return "" 

197 

198class _FakeURL: 

199 def __init__(self, path: str): 

200 self.path = path 

201 

202 def __str__(self) -> str: 

203 return self.path