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

64 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-04 01:32 -0800

1"""Where each schema, table, column, index and constraint is in the site: page 

2paths and element IDs. 

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 logging 

29import os 

30import re 

31from collections.abc import Iterable 

32from pathlib import Path 

33 

34from felis.datamodel import BaseObject, Schema, Table 

35 

36from .schemas import _sorted_by_name 

37 

38logger = logging.getLogger(__name__) 

39 

40 

41def _slugify(value: str) -> str: 

42 """Return a URL-safe slug of lowercase letters, digits and hyphens 

43 ("schema" if empty). 

44 """ 

45 slug = re.sub(r"[^a-zA-Z0-9]+", "-", value.strip().lower()).strip("-") 

46 return slug or "schema" 

47 

48 

49def _relative_href(current_output_path: str, target_output_path: str) -> str: 

50 """Build a POSIX relative href from one output HTML path to another.""" 

51 current_dir = Path(current_output_path).parent 

52 return Path(os.path.relpath(target_output_path, start=current_dir)).as_posix() 

53 

54 

55class _UniqueNames: 

56 """Hand out unique names, appending ``-2``, ``-3``, ... to repeats.""" 

57 

58 def __init__(self) -> None: 

59 self._issued: set[str] = set() 

60 self._counts: dict[str, int] = {} 

61 

62 def claim(self, base: str) -> str: 

63 # base, or for a repeat base-N with the lowest N from 2 not already 

64 # handed out, so a repeat never takes a name another base got as 

65 # itself (such as "foo-2"). 

66 name = base 

67 count = self._counts.get(base, 1) 

68 while name in self._issued: 

69 count += 1 

70 name = f"{base}-{count}" 

71 self._counts[base] = count 

72 self._issued.add(name) 

73 return name 

74 

75 

76class SiteMap: 

77 """Output paths and element IDs of all objects shown in the site. 

78 

79 Objects are keyed by ``id()`` because the felis models are not hashable; 

80 the schemas must outlive the site map. 

81 

82 Parameters 

83 ---------- 

84 schemas 

85 The schemas in the site. Paths are assigned in this order, so the 

86 order decides which of two same-named schemas gets a ``-2`` suffix. 

87 """ 

88 

89 def __init__(self, schemas: Iterable[Schema]) -> None: 

90 self._paths: dict[int, str] = {} 

91 self._anchors: dict[int, str] = {} 

92 self._tables: dict[int, Table] = {} 

93 

94 schema_dirs = _UniqueNames() 

95 schemas = list(schemas) 

96 for schema in schemas: 

97 logger.debug("Mapping schema: %s", schema.name) 

98 schema_dir = f"schemas/{schema_dirs.claim(_slugify(schema.name))}" 

99 self._paths[id(schema)] = f"{schema_dir}/index.html" 

100 table_files = _UniqueNames() 

101 for table in schema.tables: 

102 table_file = _slugify(table.name) 

103 # The schema page is the directory's index.html. 

104 if table_file == "index": 

105 raise ValueError(f"Invalid table name '{table.name}' in schema '{schema.name}'") 

106 self._paths[id(table)] = f"{schema_dir}/{table_files.claim(table_file)}.html" 

107 self._anchors[id(table)] = f"table-{_slugify(table.name)}" 

108 self._add_rows(table, "column", table.columns) 

109 self._add_rows(table, "index", table.indexes) 

110 self._add_rows(table, "constraint", table.constraints) 

111 

112 self.schemas = _sorted_by_name(schemas) 

113 """The schemas in display order.""" 

114 

115 def _add_rows(self, table: Table, prefix: str, objects: Iterable[BaseObject]) -> None: 

116 """Record the table and a unique element ID of each row object on a 

117 table's page. 

118 """ 

119 names = _UniqueNames() 

120 for obj in objects: 

121 self._anchors[id(obj)] = names.claim(f"{prefix}-{_slugify(obj.name)}") 

122 self._tables[id(obj)] = table 

123 

124 def path(self, obj: BaseObject) -> str: 

125 """Return the location of ``obj`` within the site. 

126 

127 Parameters 

128 ---------- 

129 obj 

130 A schema or an object in one. 

131 

132 Returns 

133 ------- 

134 str 

135 The page of a schema or table, otherwise the object's anchor on 

136 its table's page, relative to the site root. 

137 """ 

138 if id(obj) in self._paths: 

139 return self._paths[id(obj)] 

140 return f"{self._paths[id(self.table_of(obj))]}#{self.anchor(obj)}" 

141 

142 def anchor(self, obj: BaseObject) -> str: 

143 """Return the element ID of ``obj`` on its table's page. 

144 

145 Parameters 

146 ---------- 

147 obj 

148 A schema or an object in one. 

149 

150 Returns 

151 ------- 

152 str 

153 The element ID. 

154 """ 

155 return self._anchors[id(obj)] 

156 

157 def table_of(self, obj: BaseObject) -> Table: 

158 """Return the table containing ``obj``. 

159 

160 Unlike `Schema.get_table_by_column`, this covers indexes and 

161 constraints as well as columns, and is a lookup rather than a search 

162 of the schema's tables. 

163 

164 Parameters 

165 ---------- 

166 obj 

167 A schema or an object in one. 

168 

169 Returns 

170 ------- 

171 `Table` 

172 The table whose page shows ``obj``. 

173 """ 

174 return self._tables[id(obj)]