Coverage for python/felis/browser/columns.py: 96%

68 statements  

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

1"""The columns table of a table's page: the fields shown and the tags after 

2a column's name. 

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 

28from typing import NamedTuple 

29 

30from felis.datamodel import Column, Schema, Table 

31 

32from .schemas import _foreign_keys, _primary_key_columns, _resolve_column 

33 

34_COLUMN_FIELDS = ( 

35 "name", 

36 "datatype", 

37 "description", 

38 "unit", 

39 "nullable", 

40 "ivoa:ucd", 

41 "tap:column_index", 

42 "tap:principal", 

43 "tap:std", 

44 "votable:arraysize", 

45 "votable:datatype", 

46 "votable:xtype", 

47 "votable:utype", 

48 "value", 

49 "length", 

50 "precision", 

51 "autoincrement", 

52) 

53"""Column fields shown in the columns table, in order, by their names in the 

54schema YAML, except ``unit``: a column's ``ivoa:unit`` or ``fits:tunit``, 

55which are never both set. 

56""" 

57 

58 

59_REQUIRED_COLUMN_FIELDS = frozenset({"name", "datatype", "description", "unit", "nullable"}) 

60"""Column fields always shown in the columns table, even if empty.""" 

61 

62 

63_COLUMN_ATTRIBUTES = {field.alias or name: name for name, field in Column.model_fields.items()} 

64"""Column model attribute names, keyed by schema YAML field name.""" 

65 

66 

67def _format_field_value(value: object) -> str: 

68 """Format a field value for display: "" for `None`, "true"/"false", lists 

69 comma-separated. 

70 """ 

71 if value is None: 

72 return "" 

73 if isinstance(value, bool): 

74 return "true" if value else "false" 

75 if isinstance(value, list): 75 ↛ 76line 75 didn't jump to line 76 because the condition on line 75 was never true

76 return ", ".join(str(item) for item in value) 

77 return str(value) 

78 

79 

80def _column_value(column: Column, field: str) -> object: 

81 """Return a column's value for a YAML field name, or for "unit" its 

82 ``ivoa:unit`` or ``fits:tunit``. 

83 """ 

84 if field == "unit": 

85 return column.ivoa_unit or column.fits_tunit 

86 return getattr(column, _COLUMN_ATTRIBUTES[field]) 

87 

88 

89def _field_default(field: str) -> object: 

90 """Return a field's default: `None` for most, 0 for ``tap:principal`` and 

91 ``tap:std``. 

92 """ 

93 if field == "unit": 93 ↛ 94line 93 didn't jump to line 94 because the condition on line 93 was never true

94 return None 

95 return Column.model_fields[_COLUMN_ATTRIBUTES[field]].get_default(call_default_factory=True) 

96 

97 

98def _column_field(column: Column, field: str) -> str: 

99 return _format_field_value(_column_value(column, field)) 

100 

101 

102def _column_sets_field(column: Column, field: str) -> bool: 

103 """Return whether a column sets a field to a non-empty value other than its 

104 default. 

105 """ 

106 value = _column_value(column, field) 

107 return _format_field_value(value) != "" and value != _field_default(field) 

108 

109 

110def _table_column_fields(table: Table, show_empty: bool) -> list[str]: 

111 """Return the fields to show in a table's columns table, in 

112 `_COLUMN_FIELDS` order. 

113 

114 Unless ``show_empty`` is true, a field is left out if no column of the 

115 table sets it (see `_column_sets_field`), so the table isn't filled with 

116 empty columns; the fields in `_REQUIRED_COLUMN_FIELDS` are always shown so 

117 every table starts the same. 

118 """ 

119 return [ 

120 field 

121 for field in _COLUMN_FIELDS 

122 if show_empty 

123 or field in _REQUIRED_COLUMN_FIELDS 

124 or any(_column_sets_field(column, field) for column in table.columns) 

125 ] 

126 

127 

128class ColumnTag(NamedTuple): 

129 """A tag shown after a column's name in the columns table.""" 

130 

131 kind: str 

132 """Kind of tag, used in its CSS class: ``primary-key``, ``foreign-key`` 

133 or ``index``. 

134 """ 

135 

136 label: str 

137 """Text of the tag.""" 

138 

139 title: str 

140 """Hover text explaining the tag.""" 

141 

142 

143_TAG_KINDS = ("primary-key", "foreign-key", "index") 

144"""Kinds of column tag, in the order they are shown.""" 

145 

146 

147def _column_tag(kind: str, details: list[str]) -> ColumnTag: 

148 if kind == "primary-key": 

149 return ColumnTag(kind, "primary key", "Part of the table's primary key") 

150 if kind == "foreign-key": 

151 return ColumnTag(kind, "foreign key", "References " + ", ".join(details)) 

152 prefix = "In indexes " if len(details) > 1 else "In index " 

153 return ColumnTag(kind, "indexed", prefix + ", ".join(details)) 

154 

155 

156def _column_tags(schema: Schema, table: Table) -> dict[str, list[ColumnTag]]: 

157 """Return the tags of a table's columns, by column name, in `_TAG_KINDS` 

158 order. 

159 """ 

160 # Each column tagged, with the kind of tag and what it is tagged by. 

161 found: list[tuple[Column, str, str]] = [] 

162 for column in _primary_key_columns(schema, table): 

163 found.append((column, "primary-key", "")) 

164 for constraint in _foreign_keys(table): 

165 for ref, referenced_ref in zip(constraint.columns, constraint.referenced_columns, strict=True): 

166 referenced = _resolve_column(schema, referenced_ref) 

167 target = f"{schema.get_table_by_column(referenced).name}.{referenced.name}" 

168 found.append((_resolve_column(schema, ref), "foreign-key", target)) 

169 for index in table.indexes: 

170 for ref in index.columns or []: 

171 found.append((_resolve_column(schema, ref), "index", index.name)) 

172 

173 # Gather what each column is tagged by, by kind. 

174 details: dict[str, dict[str, list[str]]] = {} 

175 for column, kind, detail in found: 

176 details.setdefault(column.name, {}).setdefault(kind, []).append(detail) 

177 

178 tags: dict[str, list[ColumnTag]] = {} 

179 for name, details_by_kind in details.items(): 

180 tags[name] = [ 

181 _column_tag(kind, details_by_kind[kind]) for kind in _TAG_KINDS if kind in details_by_kind 

182 ] 

183 return tags