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
« 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"""
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/>.
26from __future__ import annotations
28import logging
29import os
30import re
31from collections.abc import Iterable
32from pathlib import Path
34from felis.datamodel import BaseObject, Schema, Table
36from .schemas import _sorted_by_name
38logger = logging.getLogger(__name__)
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"
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()
55class _UniqueNames:
56 """Hand out unique names, appending ``-2``, ``-3``, ... to repeats."""
58 def __init__(self) -> None:
59 self._issued: set[str] = set()
60 self._counts: dict[str, int] = {}
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
76class SiteMap:
77 """Output paths and element IDs of all objects shown in the site.
79 Objects are keyed by ``id()`` because the felis models are not hashable;
80 the schemas must outlive the site map.
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 """
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] = {}
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)
112 self.schemas = _sorted_by_name(schemas)
113 """The schemas in display order."""
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
124 def path(self, obj: BaseObject) -> str:
125 """Return the location of ``obj`` within the site.
127 Parameters
128 ----------
129 obj
130 A schema or an object in one.
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)}"
142 def anchor(self, obj: BaseObject) -> str:
143 """Return the element ID of ``obj`` on its table's page.
145 Parameters
146 ----------
147 obj
148 A schema or an object in one.
150 Returns
151 -------
152 str
153 The element ID.
154 """
155 return self._anchors[id(obj)]
157 def table_of(self, obj: BaseObject) -> Table:
158 """Return the table containing ``obj``.
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.
164 Parameters
165 ----------
166 obj
167 A schema or an object in one.
169 Returns
170 -------
171 `Table`
172 The table whose page shows ``obj``.
173 """
174 return self._tables[id(obj)]