diff --git a/docs/user-guide/cli.rst b/docs/user-guide/cli.rst index 753fbadd..e6fd250b 100644 --- a/docs/user-guide/cli.rst +++ b/docs/user-guide/cli.rst @@ -4,3 +4,19 @@ Command Line Interface .. click:: felis.cli:cli :prog: felis :nested: full + +Browser prototype +================ + +The ``browser`` command generates a static HTML site for browsing schema +structure (schemas, tables, and columns): + +.. code-block:: bash + + felis browser --output-dir ./site tests/data/test.yml tests/data/sales.yaml + +You can use shell expansion to provide file lists: + +.. code-block:: bash + + felis browser --output-dir ./site ../sdm_schemas/yml/*.yaml diff --git a/pyproject.toml b/pyproject.toml index 770e2c86..12ae0727 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ "astropy", "click", "deepdiff", + "jinja2", "lsst-resources", "lsst-utils", "numpy", @@ -43,6 +44,7 @@ Source = "https://github.com/lsst/felis" [project.optional-dependencies] test = [ + "beautifulsoup4>=4.12", "pytest >= 3.2" ] dev = [ @@ -61,7 +63,14 @@ where = ["python"] zip-safe = true [tool.setuptools.package-data] -"felis" = ["py.typed", "config/tap_schema/*.yaml", "config/tap_schema/*.csv"] +"felis" = [ + "py.typed", + "config/tap_schema/*.yaml", + "config/tap_schema/*.csv", + "browser/templates/*.j2", + "browser/static/*.css", + "browser/static/*.js", +] [tool.setuptools.dynamic] version = { attr = "lsst_versions.get_lsst_version" } diff --git a/python/felis/browser/__init__.py b/python/felis/browser/__init__.py new file mode 100644 index 00000000..25c09990 --- /dev/null +++ b/python/felis/browser/__init__.py @@ -0,0 +1,5 @@ +"""Static schema browser generator.""" + +from .render import render_static_site + +__all__ = ["render_static_site"] diff --git a/python/felis/browser/render.py b/python/felis/browser/render.py new file mode 100644 index 00000000..99f44ca4 --- /dev/null +++ b/python/felis/browser/render.py @@ -0,0 +1,331 @@ +from __future__ import annotations + +import logging +import re +from importlib import resources +from pathlib import Path + +from jinja2 import Environment, PackageLoader, select_autoescape + +from felis.datamodel import Schema + +logger = logging.getLogger(__name__) +_GRAPHIC_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".ico"} + + +def _slugify(value: str) -> str: + slug = re.sub(r"[^a-zA-Z0-9]+", "-", value.strip().lower()).strip("-") + return slug or "schema" + + +def _schema_name(schema: Schema, source_path: str) -> str: + if schema.name: + return schema.name + return Path(source_path).stem + + +def _column_label(column_id: str) -> str: + return column_id.rsplit(".", 1)[-1].lstrip("#") + + +def _table_label(column_id: str) -> str: + return column_id.lstrip("#").split(".", 1)[0] + + +def _format_field_value(value: object) -> str: + if value is None: + return "" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, list): + return ", ".join(str(item) for item in value) + if isinstance(value, dict): + return str(value) + return str(value) + + +def _validate_site_icon(site_icon: Path) -> None: + if site_icon.suffix.lower() not in _GRAPHIC_SUFFIXES: + raise ValueError(f"Site icon must be a graphics file: {site_icon}") + + +def render_static_site( + schemas: list[Schema], + source_paths: list[str], + output_dir: Path, + include_column_details: bool = True, + site_icon: Path | None = None, + icon_link_url: str | None = None, +) -> None: + """Render a minimal static browser site for browsing schemas. + + The generated pages include schema, table, and column navigation. + + Parameters + ---------- + schemas + List of schemas to render. + source_paths + List of source paths corresponding to the schemas, used for display + purposes. + output_dir + Directory where the rendered HTML files will be saved. + include_column_details + If True, include expandable per-column details in table pages. + site_icon + Optional path to a graphics file copied into assets and shown in the + page header. + icon_link_url + Optional URL to open when the site icon is clicked. + """ + output_dir.mkdir(parents=True, exist_ok=True) + (output_dir / "assets").mkdir(parents=True, exist_ok=True) + + site_icon_href: str | None = None + if site_icon is not None: + _validate_site_icon(site_icon) + site_icon_ext = site_icon.suffix.lower() + icon_output_path = output_dir / "assets" / f"site-icon{site_icon_ext}" + icon_output_path.write_bytes(site_icon.read_bytes()) + site_icon_href = f"assets/{icon_output_path.name}" + elif icon_link_url: + logger.warning("Icon link URL provided but no site icon was configured; ignoring icon link") + + env = Environment( + loader=PackageLoader("felis.browser", "templates"), + autoescape=select_autoescape(["html", "xml"]), + trim_blocks=True, + lstrip_blocks=True, + ) + + schema_pages: list[dict[str, object]] = [] + filename_counts: dict[str, int] = {} + table_filename_counts: dict[str, int] = {} + + for schema, source_path in zip(schemas, source_paths, strict=True): + name = _schema_name(schema, source_path) + logger.debug("Building schema: %s", name) + schema_slug = _slugify(name) + count = filename_counts.get(schema_slug, 0) + 1 + filename_counts[schema_slug] = count + filename = f"{schema_slug}.html" if count == 1 else f"{schema_slug}-{count}.html" + + prepared_tables: list[dict[str, object]] = [] + for table in schema.tables: + logger.debug("Building table: %s.%s", name, table.name) + table_anchor = f"table-{_slugify(table.name)}" + table_base = f"{schema_slug}--{_slugify(table.name)}" + table_count = table_filename_counts.get(table_base, 0) + 1 + table_filename_counts[table_base] = table_count + table_filename = f"{table_base}.html" if table_count == 1 else f"{table_base}-{table_count}.html" + + columns: list[dict[str, object]] = [] + for column in table.columns: + logger.debug("Building column: %s.%s.%s", name, table.name, column.name) + col_slug = _slugify(column.name) + details_fields: list[dict[str, str]] = [] + if include_column_details: + model = column.__class__ + defaults_by_alias = { + (field_info.alias or field_name): field_info.default + for field_name, field_info in model.model_fields.items() + } + + column_data = column.model_dump(by_alias=True, exclude_none=False, exclude_defaults=False) + for key, value in column_data.items(): + if key == "@id": + continue + if value is None: + continue + if key in defaults_by_alias and value == defaults_by_alias[key]: + continue + details_fields.append({"key": key, "display_value": _format_field_value(value)}) + + columns.append( + { + "id": column.id, + "name": column.name, + "datatype": str(column.datatype), + "ucd": column.ivoa_ucd or "", + "description": column.description or "", + "unit": column.ivoa_unit or column.fits_tunit or "", + "anchor": f"{table_anchor}-col-{col_slug}", + "details_anchor": f"{table_anchor}-col-{col_slug}-details" + if include_column_details + else "", + "details_fields": details_fields, + } + ) + + prepared_tables.append( + { + "table": table, + "anchor": table_anchor, + "page_filename": table_filename, + "columns": columns, + } + ) + + schema_column_targets: dict[str, dict[str, str]] = {} + for prepared in prepared_tables: + table_name = str(getattr(prepared["table"], "name")) + table_page_filename = str(prepared["page_filename"]) + for column in prepared["columns"]: + column_id = str(column["id"]) + schema_column_targets[column_id] = { + "id": column_id, + "name": str(column["name"]), + "anchor": str(column["anchor"]), + "table_name": table_name, + "table_page_filename": table_page_filename, + } + + table_entries: list[dict[str, object]] = [] + for prepared in prepared_tables: + table = prepared["table"] + table_anchor = str(prepared["anchor"]) + table_filename = str(prepared["page_filename"]) + columns: list[dict[str, object]] = prepared["columns"] + column_anchors = {str(column["id"]): str(column["anchor"]) for column in columns} + + def _column_entry(column_id: str) -> dict[str, str]: + if column_id in schema_column_targets: + return dict(schema_column_targets[column_id]) + return { + "id": column_id, + "name": _column_label(column_id), + "anchor": column_anchors.get(column_id, ""), + "table_name": _table_label(column_id), + "table_page_filename": "", + } + + primary_key_entries: list[dict[str, str]] = [] + if table.primary_key is not None: + primary_key_columns = ( + table.primary_key if isinstance(table.primary_key, list) else [table.primary_key] + ) + for column_id in primary_key_columns: + column_id_str = str(column_id) + primary_key_entries.append(_column_entry(column_id_str)) + + index_entries: list[dict[str, object]] = [] + index_filename_counts: dict[str, int] = {} + for index in table.indexes: + index_base = f"{table_anchor}-index-{_slugify(index.name)}" + index_count = index_filename_counts.get(index_base, 0) + 1 + index_filename_counts[index_base] = index_count + index_anchor = f"{index_base}" if index_count == 1 else f"{index_base}-{index_count}" + if index.columns is not None: + definition_kind = "columns" + definition_values = [_column_entry(str(column_id)) for column_id in index.columns] + else: + definition_kind = "expressions" + definition_values = list(index.expressions or []) + index_entries.append( + { + "name": index.name, + "description": index.description or "", + "anchor": index_anchor, + "definition_kind": definition_kind, + "definition_values": definition_values, + } + ) + + constraint_entries: list[dict[str, object]] = [] + constraint_filename_counts: dict[str, int] = {} + for constraint in table.constraints: + constraint_base = f"{table_anchor}-constraint-{_slugify(constraint.name)}" + constraint_count = constraint_filename_counts.get(constraint_base, 0) + 1 + constraint_filename_counts[constraint_base] = constraint_count + constraint_anchor = ( + f"{constraint_base}" if constraint_count == 1 else f"{constraint_base}-{constraint_count}" + ) + constraint_type = str(getattr(constraint, "type", "")) + entry: dict[str, object] = { + "name": constraint.name, + "type": constraint_type, + "description": constraint.description or "", + "anchor": constraint_anchor, + "deferrable": constraint.deferrable, + "initially": constraint.initially or "", + } + + if constraint_type == "Check": + entry["expression"] = str(getattr(constraint, "expression", "")) + elif constraint_type == "Unique": + unique_columns = [str(col) for col in getattr(constraint, "columns", [])] + entry["columns"] = [_column_entry(column_id) for column_id in unique_columns] + elif constraint_type == "ForeignKey": + fk_columns = [str(col) for col in getattr(constraint, "columns", [])] + ref_columns = [str(col) for col in getattr(constraint, "referenced_columns", [])] + entry["columns"] = [_column_entry(column_id) for column_id in fk_columns] + entry["referenced_columns"] = [_column_entry(column_id) for column_id in ref_columns] + entry["on_delete"] = str(getattr(constraint, "on_delete", "") or "") + entry["on_update"] = str(getattr(constraint, "on_update", "") or "") + + constraint_entries.append(entry) + + table_entries.append( + { + "name": table.name, + "description": table.description or "", + "anchor": table_anchor, + "page_filename": table_filename, + "primary_key": primary_key_entries, + "include_column_details": include_column_details, + "columns": columns, + "indexes": index_entries, + "constraints": constraint_entries, + } + ) + + schema_pages.append( + { + "name": name, + "description": schema.description or "", + "source_path": source_path, + "filename": filename, + "tables": table_entries, + } + ) + + schema_pages.sort(key=lambda page: str(page["name"]).lower()) + + index_template = env.get_template("index.html.j2") + schema_template = env.get_template("schema.html.j2") + table_template = env.get_template("table.html.j2") + + (output_dir / "index.html").write_text( + index_template.render( + schemas=schema_pages, site_icon_href=site_icon_href, icon_link_url=icon_link_url + ), + encoding="utf-8", + ) + + for page in schema_pages: + (output_dir / str(page["filename"])).write_text( + schema_template.render( + current_schema=page, + schemas=schema_pages, + site_icon_href=site_icon_href, + icon_link_url=icon_link_url, + ), + encoding="utf-8", + ) + + for table in page["tables"]: + (output_dir / str(table["page_filename"])).write_text( + table_template.render( + current_schema=page, + current_table=table, + schemas=schema_pages, + site_icon_href=site_icon_href, + icon_link_url=icon_link_url, + ), + encoding="utf-8", + ) + + css_source = resources.files("felis.browser").joinpath("static/style.css").read_text(encoding="utf-8") + (output_dir / "assets" / "style.css").write_text(css_source, encoding="utf-8") + js_source = resources.files("felis.browser").joinpath("static/sidebar.js").read_text(encoding="utf-8") + (output_dir / "assets" / "sidebar.js").write_text(js_source, encoding="utf-8") diff --git a/python/felis/browser/static/sidebar.js b/python/felis/browser/static/sidebar.js new file mode 100644 index 00000000..24bfb269 --- /dev/null +++ b/python/felis/browser/static/sidebar.js @@ -0,0 +1,193 @@ +(function () { + function initSidebarResize() { + var sidebar = document.querySelector(".sidebar"); + var resizer = document.getElementById("sidebar-resizer"); + if (!sidebar || !resizer) { + return; + } + + var isDragging = false; + + function onMouseMove(event) { + if (!isDragging) { + return; + } + var minWidth = 220; + var maxWidth = Math.floor(window.innerWidth * 0.6); + var newWidth = Math.min(Math.max(event.clientX, minWidth), maxWidth); + sidebar.style.width = String(newWidth) + "px"; + } + + function onMouseUp() { + isDragging = false; + document.body.style.userSelect = ""; + } + + resizer.addEventListener("mousedown", function () { + if (window.innerWidth <= 900) { + return; + } + isDragging = true; + document.body.style.userSelect = "none"; + }); + + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + } + + function setCollapsed(collapsed) { + document.body.classList.toggle("sidebar-collapsed", collapsed); + var button = document.getElementById("sidebar-toggle"); + if (button) { + button.setAttribute("aria-expanded", String(!collapsed)); + button.textContent = collapsed ? "Show Menu" : "Hide Menu"; + } + } + + function initSidebarToggle() { + var button = document.getElementById("sidebar-toggle"); + if (!button) { + return; + } + + button.addEventListener("click", function () { + var collapsed = !document.body.classList.contains("sidebar-collapsed"); + setCollapsed(collapsed); + }); + + setCollapsed(false); + } + + function initTreeSectionSummaries() { + var summaries = document.querySelectorAll("details.tree-section > summary"); + summaries.forEach(function (summary) { + summary.addEventListener("click", function (event) { + var link = event.target.closest("a"); + if (link && summary.contains(link)) { + event.preventDefault(); + event.stopPropagation(); + window.location.href = link.getAttribute("href"); + return; + } + + // Allow toggling only when clicking near the native disclosure marker. + var markerWidth = 20; + var bounds = summary.getBoundingClientRect(); + if (event.clientX > bounds.left + markerWidth) { + event.preventDefault(); + } + }); + }); + } + + function setDetailsOpen(root, isOpen) { + var detailsNodes = root.querySelectorAll("details"); + detailsNodes.forEach(function (node) { + node.open = isOpen; + }); + } + + function initTreeControls() { + var sidebars = document.querySelectorAll(".sidebar"); + sidebars.forEach(function (sidebar) { + sidebar.addEventListener("click", function (event) { + var button = event.target.closest("button[data-tree-action]"); + if (!button || !sidebar.contains(button)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + var action = button.getAttribute("data-tree-action"); + if (action === "expand-all") { + setDetailsOpen(sidebar, true); + return; + } + + if (action === "collapse-all") { + setDetailsOpen(sidebar, false); + return; + } + + var detailsNode = button.closest("details"); + if (!detailsNode) { + return; + } + + if (action === "expand-node") { + detailsNode.open = true; + setDetailsOpen(detailsNode, true); + return; + } + + if (action === "collapse-node") { + setDetailsOpen(detailsNode, false); + detailsNode.open = false; + } + }); + }); + } + + function setColumnDetailsExpanded(toggleButton, expanded) { + var targetId = toggleButton.getAttribute("data-column-details-target"); + if (!targetId) { + return; + } + + var detailsRow = document.getElementById(targetId); + if (!detailsRow) { + return; + } + + detailsRow.hidden = !expanded; + toggleButton.setAttribute("aria-expanded", String(expanded)); + } + + function initColumnDetailsControls() { + document.addEventListener("click", function (event) { + var toggleButton = event.target.closest("button[data-column-details-target]"); + if (toggleButton) { + var expanded = toggleButton.getAttribute("aria-expanded") === "true"; + setColumnDetailsExpanded(toggleButton, !expanded); + return; + } + + var actionButton = event.target.closest("button[data-column-details-action]"); + if (!actionButton) { + return; + } + + var action = actionButton.getAttribute("data-column-details-action"); + var panel = actionButton.closest(".panel"); + if (!panel) { + return; + } + + var toggleButtons = panel.querySelectorAll("button[data-column-details-target]"); + toggleButtons.forEach(function (button) { + if (action === "expand-all") { + setColumnDetailsExpanded(button, true); + } else if (action === "collapse-all") { + setColumnDetailsExpanded(button, false); + } + }); + }); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", function () { + initSidebarToggle(); + initSidebarResize(); + initTreeSectionSummaries(); + initTreeControls(); + initColumnDetailsControls(); + }); + } else { + initSidebarToggle(); + initSidebarResize(); + initTreeSectionSummaries(); + initTreeControls(); + initColumnDetailsControls(); + } +})(); diff --git a/python/felis/browser/static/style.css b/python/felis/browser/static/style.css new file mode 100644 index 00000000..37a942df --- /dev/null +++ b/python/felis/browser/static/style.css @@ -0,0 +1,359 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Source Sans 3", "Noto Sans", sans-serif; + line-height: 1.4; + background: #f4f6f9; + color: #1e293b; +} + +.header { + background: #0f766e; + color: #ffffff; + padding: 1rem; + position: relative; +} + +.header-site-icon { + max-height: 4.2rem; + max-width: 28rem; + width: auto; + height: auto; + object-fit: contain; + display: block; +} + +.header-site-icon-link { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + line-height: 0; +} + +.breadcrumb { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem; + margin: 0; + font-size: 1.4rem; + font-weight: 700; +} + +.breadcrumb a { + color: #ffffff; + text-decoration: none; +} + +.breadcrumb a:hover { + text-decoration: underline; +} + +.breadcrumb-kind { + color: #d1fae5; + font-size: 0.9rem; + font-weight: 600; +} + +.source-file { + margin: 0.4rem 0 0; +} + +.source-file-label { + font-weight: 600; +} + +.sidebar-toggle { + margin-top: 0.6rem; + border: 1px solid #e2e8f0; + background: #ffffff; + color: #0f766e; + border-radius: 6px; + padding: 0.35rem 0.6rem; + font-weight: 600; + cursor: pointer; +} + +.sidebar-toggle:hover { + background: #f8fafc; +} + +.layout { + display: flex; + min-height: calc(100vh - 80px); +} + +.sidebar { + background: #ffffff; + border-right: 1px solid #dbe1ea; + padding: 1rem; + width: 300px; + min-width: 220px; + max-width: 60vw; + max-height: calc(100vh - 80px); + overflow-y: auto; + overflow-x: auto; + flex: 0 0 auto; +} + +.sidebar-resizer { + width: 8px; + cursor: col-resize; + background: linear-gradient(to right, #e2e8f0, #f8fafc); + border-right: 1px solid #dbe1ea; + flex: 0 0 8px; +} + +.sidebar-resizer:hover { + background: linear-gradient(to right, #cbd5e1, #e2e8f0); +} + +.content { + flex: 1; + padding: 1rem; +} + +.panel { + background: #ffffff; + border: 1px solid #dbe1ea; + border-radius: 8px; + padding: 1rem; + margin-bottom: 1rem; +} + +.panel-plain { + background: transparent; + border: 0; + border-radius: 0; + padding: 0; + margin-bottom: 0; +} + +.tree, +.tree ul, +.schema-list { + list-style: none; + margin: 0; + padding-left: 1rem; +} + +.tree > li, +.schema-list > li { + margin-bottom: 0.5rem; +} + +.tree-node { + margin: 0.25rem 0; +} + +.sidebar-actions { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-bottom: 0.75rem; +} + +.tree-action-button { + border: 1px solid #cbd5e1; + background: #f8fafc; + color: #1e293b; + border-radius: 6px; + font-size: 0.78rem; + padding: 0.15rem 0.4rem; + cursor: pointer; +} + +.tree-action-button:hover { + background: #eef2ff; +} + +.tree-action-icon { + width: 1.1rem; + height: 1.1rem; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.tree-action-icon .icon { + width: 0.72rem; + height: 0.72rem; + display: block; +} + +.tree-node > summary { + cursor: pointer; + font-weight: 600; + color: #0f172a; + padding: 0.1rem 0; + white-space: nowrap; +} + +.tree-node-actions { + display: inline-flex; + gap: 0.25rem; + margin-left: 0.6rem; + vertical-align: middle; +} + +.tree-node > summary:hover { + color: #0f766e; +} + +.tree-node ul { + margin-top: 0.2rem; +} + +.tree-columns, +.tree-indexes, +.tree-constraints { + margin-left: 1rem; +} + +.tree-columns > summary, +.tree-indexes > summary, +.tree-constraints > summary { + font-weight: 500; + color: #334155; +} + +.tree a { + white-space: nowrap; +} + +body.sidebar-collapsed .sidebar { + width: 0; + min-width: 0; + max-width: 0; + padding: 0; + border: 0; + overflow: hidden; + resize: none; +} + +body.sidebar-collapsed .sidebar-resizer { + width: 0; + flex-basis: 0; + border: 0; +} + +a { + color: #0f766e; + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + border: 1px solid #dbe1ea; + text-align: left; + padding: 0.5rem; + vertical-align: top; +} + +th { + background: #f8fafc; +} + +.muted { + color: #475569; + font-size: 0.9rem; +} + +.column-details-actions { + display: flex; + gap: 0.35rem; + margin: 0.5rem 0 0.7rem; +} + +.column-details-toggle { + border: 1px solid #cbd5e1; + background: #f8fafc; + color: #1e293b; + border-radius: 4px; + font-size: 0.72rem; + width: 1.4rem; + height: 1.4rem; + padding: 0; + margin-right: 0.35rem; + cursor: pointer; + vertical-align: middle; +} + +.column-details-toggle:hover { + background: #eef2ff; +} + +.column-details-toggle .toggle-expanded { + display: none; +} + +.column-details-toggle[aria-expanded="true"] .toggle-expanded { + display: inline; +} + +.column-details-toggle[aria-expanded="true"] .toggle-collapsed { + display: none; +} + +.column-details-row > td { + background: #f8fafc; + padding: 0.45rem; +} + +.column-details-table { + width: 100%; + border-collapse: collapse; +} + +.column-details-table .field-key { + width: 30%; + background: #eef2f7; + font-weight: 600; +} + +.column-details-table .field-value { + width: 70%; +} + +@media (max-width: 900px) { + .layout { + flex-direction: column; + } + + .sidebar { + width: 100%; + min-width: 0; + max-width: none; + resize: none; + border-right: 0; + border-bottom: 1px solid #dbe1ea; + max-height: 35vh; + } + + .sidebar-resizer { + display: none; + } + + body.sidebar-collapsed .sidebar { + max-height: 0; + } + + .header-site-icon { + max-height: 3rem; + max-width: 18rem; + } +} diff --git a/python/felis/browser/templates/index.html.j2 b/python/felis/browser/templates/index.html.j2 new file mode 100644 index 00000000..67d543ea --- /dev/null +++ b/python/felis/browser/templates/index.html.j2 @@ -0,0 +1,137 @@ + + + + + + Home + + + +
+ + {% if site_icon_href %} + {% if icon_link_url %} + + Site icon + + {% else %} + Site icon + {% endif %} + {% endif %} + +
+ +
+ + + +
+
+

Select a schema to browse its tables and columns.

+
    + {% for schema in schemas %} +
  • + {{ schema.name }} + {% set table_count = schema.tables | length %} + ({{ table_count }} {{ "table" if table_count == 1 else "tables" }}) + {% if schema.description %} +
    {{ schema.description }}
    + {% endif %} +
  • + {% endfor %} +
+
+
+
+ + + diff --git a/python/felis/browser/templates/schema.html.j2 b/python/felis/browser/templates/schema.html.j2 new file mode 100644 index 00000000..17f12808 --- /dev/null +++ b/python/felis/browser/templates/schema.html.j2 @@ -0,0 +1,138 @@ + + + + + + {{ current_schema.name }} - Felis Browser + + + +
+ + {% if site_icon_href %} + {% if icon_link_url %} + + Site icon + + {% else %} + Site icon + {% endif %} + {% endif %} + +
+ +
+ + + +
+
+

Source File. {{ current_schema.source_path }}

+

Select a table to view its columns and details.

+
    + {% for table in current_schema.tables %} +
  • + {{ table.name }} + {% set column_count = table.columns | length %} + ({{ column_count }} {{ "column" if column_count == 1 else "columns" }}) + {% if table.description %} +
    {{ table.description }}
    + {% endif %} +
  • + {% endfor %} +
+
+
+
+ + + diff --git a/python/felis/browser/templates/table.html.j2 b/python/felis/browser/templates/table.html.j2 new file mode 100644 index 00000000..39247349 --- /dev/null +++ b/python/felis/browser/templates/table.html.j2 @@ -0,0 +1,308 @@ + + + + + + {{ current_table.name }} - {{ current_schema.name }} - Felis Browser + + + +
+ + {% if site_icon_href %} + {% if icon_link_url %} + + Site icon + + {% else %} + Site icon + {% endif %} + {% endif %} + +
+ +
+ + + +
+
+ {% if current_table.description %} +

{{ current_table.description }}

+ {% endif %} + {% if current_table.primary_key %} +

Primary Key

+

+ {% for key_column in current_table.primary_key -%} + {%- if not loop.first %}, {% endif -%} + {%- if key_column.anchor -%} + {{ key_column.name }} + {%- else -%} + {{ key_column.name }} + {%- endif -%} + {%- endfor %} +

+ {% endif %} +

Columns

+ {% if current_table.include_column_details %} +
+ + +
+ {% endif %} + + + + + + + + + + + + {% for column in current_table.columns %} + + + + + + + + {% if current_table.include_column_details %} + + + + {% endif %} + {% endfor %} + +
ColumnDatatypeUCDUnitDescription
+ {% if current_table.include_column_details %} + + {% endif %} + {{ column.name }} + {{ column.datatype }}{{ column.ucd }}{{ column.unit }}{{ column.description }}
+ {% if current_table.indexes %} +

Indexes

+ + + + + + + + + + + {% for index in current_table.indexes %} + + + + + + + {% endfor %} + +
IndexTypeDefinitionDescription
{{ index.name }}{{ index.definition_kind }} + {% if index.definition_kind == "columns" %} + {% for col in index.definition_values %} + {% if not loop.first %}, {% endif %} + {% if col.anchor %} + {{ col.name }} + {% else %} + {{ col.name }} + {% endif %} + {% endfor %} + {% else %} + {{ index.definition_values | join(', ') }} + {% endif %} + {{ index.description }}
+ {% endif %} + {% if current_table.constraints %} +

Constraints

+ + + + + + + + + + + {% for constraint in current_table.constraints %} + + + + + + + {% endfor %} + +
ConstraintTypeDefinitionDescription
{{ constraint.name }}{{ constraint.type }} + {% if constraint.type == "Check" %} + {{ constraint.expression }} + {% elif constraint.type == "Unique" %} + {% for col in constraint.columns %} + {% if not loop.first %}, {% endif %} + {% if col.anchor %} + {{ col.name }} + {% else %} + {{ col.name }} + {% endif %} + {% endfor %} + {% elif constraint.type == "ForeignKey" %} +
+ Columns: + {% for col in constraint.columns %} + {% if not loop.first %}, {% endif %} + {% if col.anchor %} + {{ col.name }} + {% else %} + {{ col.name }} + {% endif %} + {% endfor %} +
+
+ References: + {% for ref_col in constraint.referenced_columns %} + {% if not loop.first %}, {% endif %} + {% if ref_col.table_page_filename %} + {{ ref_col.table_name }}.{% if ref_col.anchor %}{{ ref_col.name }}{% else %}{{ ref_col.name }}{% endif %} + {% else %} + {{ ref_col.id }} + {% endif %} + {% endfor %} +
+ {% if constraint.on_delete %} +
On Delete: {{ constraint.on_delete }}
+ {% endif %} + {% if constraint.on_update %} +
On Update: {{ constraint.on_update }}
+ {% endif %} + {% endif %} + {% if constraint.deferrable %} +
Deferrable: true
+ {% if constraint.initially %} +
Initially: {{ constraint.initially }}
+ {% endif %} + {% endif %} +
{{ constraint.description }}
+ {% endif %} +
+
+
+ + + diff --git a/python/felis/cli.py b/python/felis/cli.py index 9e4355fa..599a3092 100644 --- a/python/felis/cli.py +++ b/python/felis/cli.py @@ -25,12 +25,14 @@ import logging from collections.abc import Iterable +from pathlib import Path from typing import IO import click from pydantic import ValidationError from . import __version__ +from .browser import render_static_site from .datamodel import Schema from .db.database_context import create_database_context from .diff import DatabaseDiff, FormattedSchemaDiff, SchemaDiff @@ -646,5 +648,96 @@ def dump( logger.info("Dumped '%s' to '%s'", uris[0], uris[1]) +@cli.command("browser", help="Generate a static schema browser site from YAML files") +@click.option( + "--output-dir", + type=click.Path(file_okay=False, dir_okay=True, path_type=Path), + required=True, + help="Output directory for generated static HTML", +) +@click.option( + "--disable-column-details", + is_flag=True, + help="Do not include expandable column details in table pages.", +) +@click.option( + "--site-icon", + type=click.Path(exists=True, dir_okay=False, path_type=Path), + default=None, + help="Path to a site icon image to center in the page header.", +) +@click.option( + "--icon-link-url", + type=str, + default=None, + help="URL to open when clicking the site icon.", +) +@click.argument( + "files", + nargs=-1, + type=click.Path(exists=True, dir_okay=False, path_type=Path), + required=True, +) +@click.pass_context +def browser( + ctx: click.Context, + output_dir: Path, + disable_column_details: bool, + site_icon: Path | None, + icon_link_url: str | None, + files: tuple[Path, ...], +) -> None: + """Generate a static HTML browser for one or more YAML schema files.""" + paths = sorted(files) + + for path in paths: + if path.suffix.lower() not in {".yaml", ".yml"}: + raise click.ClickException(f"Input file must be .yaml or .yml: {path}") + + if site_icon is not None and site_icon.suffix.lower() not in { + ".png", + ".jpg", + ".jpeg", + ".gif", + ".svg", + ".webp", + ".ico", + }: + raise click.ClickException(f"Site icon must be a graphics file: {site_icon}") + + schemas: list[Schema] = [] + source_paths: list[str] = [] + for path in paths: + try: + schema = Schema.from_uri( + str(path), + context={ + "id_generation": ctx.obj["id_generation"], + "column_ref_index_increment": ctx.obj["column_ref_index_increment"], + }, + ) + except ValidationError as e: + raise click.ClickException(f"Schema validation failed for '{path}': {e}") from e + except Exception as e: + raise click.ClickException(f"Failed loading schema '{path}': {e}") from e + schemas.append(schema) + source_paths.append(str(path)) + + try: + render_static_site( + schemas=schemas, + source_paths=source_paths, + output_dir=output_dir, + include_column_details=not disable_column_details, + site_icon=site_icon, + icon_link_url=icon_link_url, + ) + except Exception as e: + raise click.ClickException(f"Failed rendering browser site: {e}") from e + + click.echo(f"Generated browser site at: {output_dir}") + click.echo(f"Loaded {len(paths)} file(s), rendered {len(schemas)} schema page(s)") + + if __name__ == "__main__": cli() diff --git a/requirements.txt b/requirements.txt index 0edf4293..deec7a51 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,9 @@ alembic astropy +beautifulsoup4>=4.12 click deepdiff +jinja2 lsst-resources @ git+https://github.com/lsst/resources@main lsst-utils @ git+https://github.com/lsst/utils@main numpy diff --git a/tests/test_browser.py b/tests/test_browser.py new file mode 100644 index 00000000..19ce89c0 --- /dev/null +++ b/tests/test_browser.py @@ -0,0 +1,604 @@ +# This file is part of felis. +# +# Developed for the LSST Data Management System. +# This product includes software developed by the LSST Project +# (https://www.lsst.org). +# See the COPYRIGHT file at the top-level directory of this distribution +# for details of code ownership. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +import os +import tempfile +import unittest +from pathlib import Path + +from bs4 import BeautifulSoup +from bs4.element import Tag + +from felis.browser.render import render_static_site +from felis.datamodel import Schema + +TEST_DIR = os.path.abspath(os.path.dirname(__file__)) + + +def render_browser_site( + output_dir: str, + files: list[str], + include_column_details: bool = True, + site_icon: str | None = None, + icon_link_url: str | None = None, +) -> None: + """Render browser pages from schema files using renderer directly.""" + paths = sorted(files) + for path in paths: + if os.path.splitext(path)[1].lower() not in {".yaml", ".yml"}: + raise ValueError(f"Input file must be .yaml or .yml: {path}") + + schemas: list[Schema] = [] + source_paths: list[str] = [] + for path in paths: + schemas.append( + Schema.from_uri(path, context={"id_generation": True, "column_ref_index_increment": None}) + ) + source_paths.append(path) + + render_static_site( + schemas=schemas, + source_paths=source_paths, + output_dir=Path(output_dir), + include_column_details=include_column_details, + site_icon=Path(site_icon) if site_icon is not None else None, + icon_link_url=icon_link_url, + ) + + +def parse_html(content: str) -> BeautifulSoup: + """Parse rendered HTML for structural assertions in tests.""" + return BeautifulSoup(content, "html.parser") + + +def get_page_html_by_title(output_dir: str, title: str) -> str: + """Return rendered page HTML whose title exactly matches ``title``.""" + for name in os.listdir(output_dir): + if not name.endswith(".html"): + continue + page_path = os.path.join(output_dir, name) + with open(page_path, encoding="utf-8") as f: + page_html = f.read() + page_soup = parse_html(page_html) + page_title = page_soup.find("title") + if page_title is not None and page_title.get_text(strip=True) == title: + return page_html + raise AssertionError(f"Rendered page with title '{title}' was not found") + + +def get_page_soup_by_title(output_dir: str, title: str) -> BeautifulSoup: + """Return a parsed HTML tree for a rendered page title.""" + return parse_html(get_page_html_by_title(output_dir, title)) + + +def find_link(container: BeautifulSoup | Tag, href: str, title: str | None = None) -> Tag | None: + """Find a link by href and optional title.""" + attrs: dict[str, str] = {"href": href} + if title is not None: + attrs["title"] = title + return container.find("a", attrs=attrs) + + +def find_details_section(soup: BeautifulSoup, section_class: str) -> Tag | None: + """Find a details section by its class name.""" + return soup.find("details", class_=section_class) + + +def find_summary_link(section: Tag | None, href: str) -> Tag | None: + """Find the summary link in a details section by href.""" + if section is None: + return None + summary = section.find("summary") + if summary is None: + return None + return find_link(summary, href) + + +def find_section_summary_link(soup: BeautifulSoup, section_class: str, href: str) -> Tag | None: + """Find a summary link within a named details section.""" + for section in soup.find_all("details", class_=section_class): + link = find_summary_link(section, href) + if link is not None: + return link + return None + + +def find_section_by_summary_href(soup: BeautifulSoup, section_class: str, href: str) -> Tag | None: + """Find a details section by class and summary-link href.""" + for section in soup.find_all("details", class_=section_class): + if find_summary_link(section, href) is not None: + return section + return None + + +class BrowserTestCase(unittest.TestCase): + """Tests for the static browser command.""" + + def test_browser_renders_table_features(self) -> None: + """Render table/browser features and validate key structure/content. + + This test covers navigation controls, section wiring, details rows, + and index/constraint rendering (including FK references). + """ + schema_yaml = """ +name: demo +"@id": "#demo" +tables: + - name: ParentTable + "@id": "#ParentTable" + description: Parent rows. + columns: + - name: id + "@id": "#ParentTable.id" + datatype: int + description: Parent key. + primaryKey: "#ParentTable.id" + + - name: ChildTable + "@id": "#ChildTable" + description: Child rows. + columns: + - name: id + "@id": "#ChildTable.id" + datatype: int + description: Child key. + - name: code + "@id": "#ChildTable.code" + datatype: int + "ivoa:ucd": "meta.code" + "tap:std": 0 + description: Business code. + - name: parent_id + "@id": "#ChildTable.parent_id" + datatype: int + description: Parent link. + - name: amount + "@id": "#ChildTable.amount" + datatype: int + description: Positive amount. + primaryKey: "#ChildTable.id" + indexes: + - name: IDX_ChildTable_code + "@id": "#IDX_ChildTable_code" + description: Index on code. + columns: + - "#ChildTable.code" + constraints: + - name: CK_ChildTable_amount_positive + "@id": "#CK_ChildTable_amount_positive" + "@type": Check + description: Amount must be positive. + expression: "amount > 0" + - name: UQ_ChildTable_code_parent + "@id": "#UQ_ChildTable_code_parent" + "@type": Unique + description: Unique code per parent. + columns: + - "#ChildTable.code" + - "#ChildTable.parent_id" + - name: FK_ChildTable_ParentTable + "@id": "#FK_ChildTable_ParentTable" + "@type": ForeignKey + description: Child must reference a parent. + columns: + - "#ChildTable.parent_id" + referencedColumns: + - "#ParentTable.id" + on_delete: CASCADE + on_update: NO ACTION +""".strip() + + with tempfile.TemporaryDirectory(dir=TEST_DIR) as tmpdir: + schema_path = os.path.join(tmpdir, "constraints.yaml") + output_dir = os.path.join(tmpdir, "browser") + with open(schema_path, "w", encoding="utf-8") as f: + f.write(schema_yaml) + + render_browser_site(output_dir=output_dir, files=[schema_path]) + + self.assertTrue(os.path.exists(os.path.join(output_dir, "index.html"))) + schema_soup = get_page_soup_by_title(output_dir, "demo - Felis Browser") + child_table_soup = get_page_soup_by_title(output_dir, "ChildTable - demo - Felis Browser") + + # Validate base breadcrumb navigation and global tree controls. + self.assertIsNotNone(find_link(schema_soup, "index.html")) + self.assertIsNotNone(find_link(child_table_soup, "index.html")) + self.assertIsNotNone(schema_soup.find(attrs={"data-tree-action": "expand-all"})) + self.assertIsNotNone(schema_soup.find(attrs={"data-tree-action": "collapse-all"})) + self.assertIsNotNone(child_table_soup.find(attrs={"data-tree-action": "expand-node"})) + self.assertIsNotNone(child_table_soup.find(attrs={"data-tree-action": "collapse-node"})) + self.assertIsNotNone(child_table_soup.find("button", attrs={"aria-label": "Expand schema"})) + self.assertIsNotNone(child_table_soup.find("button", attrs={"aria-label": "Collapse table"})) + self.assertIsNotNone(child_table_soup.find("svg", class_="icon-chevron-right")) + self.assertIsNotNone(child_table_soup.find("svg", class_="icon-chevron-down")) + + # Validate tooltip-bearing links in sidebar/tree context. + self.assertIsNotNone(child_table_soup.find(attrs={"title": "Parent rows."})) + self.assertIsNotNone(child_table_soup.find(attrs={"title": "Child rows."})) + self.assertIsNotNone(child_table_soup.find(attrs={"title": "Business code."})) + self.assertIsNotNone(child_table_soup.find(attrs={"title": "Index on code."})) + self.assertIsNotNone(child_table_soup.find(attrs={"title": "Unique code per parent."})) + self.assertIsNotNone( + find_link( + child_table_soup, + "demo--childtable.html#table-childtable-index-idx-childtable-code", + title="Index on code.", + ) + ) + + # Validate section summary linkss. + columns_summary = find_section_summary_link( + child_table_soup, + section_class="tree-columns", + href="demo--childtable.html#table-childtable-columns", + ) + indexes_summary = find_section_summary_link( + child_table_soup, + section_class="tree-indexes", + href="demo--childtable.html#table-childtable-indexes", + ) + constraints_summary = find_section_summary_link( + child_table_soup, + section_class="tree-constraints", + href="demo--childtable.html#table-childtable-constraints", + ) + self.assertIsNotNone(columns_summary) + self.assertIsNotNone(indexes_summary) + self.assertIsNotNone(constraints_summary) + if columns_summary is not None: + self.assertFalse(columns_summary.has_attr("title")) + if indexes_summary is not None: + self.assertFalse(indexes_summary.has_attr("title")) + if constraints_summary is not None: + self.assertFalse(constraints_summary.has_attr("title")) + + # Validate section collapsed/open state. + columns_section = find_section_by_summary_href( + child_table_soup, + section_class="tree-columns", + href="demo--childtable.html#table-childtable-columns", + ) + indexes_section = find_section_by_summary_href( + child_table_soup, + section_class="tree-indexes", + href="demo--childtable.html#table-childtable-indexes", + ) + constraints_section = find_section_by_summary_href( + child_table_soup, + section_class="tree-constraints", + href="demo--childtable.html#table-childtable-constraints", + ) + self.assertIsNotNone(columns_section) + self.assertIsNotNone(indexes_section) + self.assertIsNotNone(constraints_section) + if columns_section is not None: + self.assertFalse(columns_section.has_attr("open")) + if indexes_section is not None: + self.assertFalse(indexes_section.has_attr("open")) + if constraints_section is not None: + self.assertFalse(constraints_section.has_attr("open")) + + # Validate columns/details table content and detail-toggle wiring. + self.assertIsNotNone(child_table_soup.find(id="table-childtable-primary-key")) + self.assertIsNotNone(child_table_soup.find(string="IDX_ChildTable_code")) + self.assertIsNotNone(child_table_soup.find("th", string="UCD")) + self.assertIsNotNone(child_table_soup.find("td", string="meta.code")) + self.assertIsNotNone(child_table_soup.find(attrs={"data-column-details-action": "expand-all"})) + self.assertIsNotNone(child_table_soup.find(attrs={"data-column-details-action": "collapse-all"})) + self.assertIsNotNone( + child_table_soup.find( + attrs={"data-column-details-target": "table-childtable-col-code-details"} + ) + ) + + # Validate content of column details row and that default values + # are omitted. + code_details_row = child_table_soup.find(id="table-childtable-col-code-details") + self.assertIsNotNone(code_details_row) + if code_details_row is not None: + self.assertTrue(code_details_row.has_attr("hidden")) + self.assertIsNotNone(code_details_row.find("th", class_="field-key", string="datatype")) + self.assertIsNotNone(code_details_row.find("td", class_="field-value", string="int")) + self.assertIsNone(code_details_row.find("th", class_="field-key", string="@id")) + self.assertIsNone(code_details_row.find("th", class_="field-key", string="tap:std")) + + self.assertIsNotNone(find_link(child_table_soup, "#table-childtable-col-code")) + self.assertIsNotNone(find_link(child_table_soup, "#table-childtable-col-parent-id")) + self.assertIsNone(child_table_soup.find(string="#ChildTable.code")) + + # Validate constraints section and cross-table FK references. + constraints_anchor = find_section_summary_link( + child_table_soup, + section_class="tree-constraints", + href="demo--childtable.html#table-childtable-constraints", + ) + self.assertIsNotNone(constraints_anchor) + if constraints_anchor is not None: + self.assertEqual(constraints_anchor.get_text(strip=True), "Constraints") + + self.assertIsNotNone(child_table_soup.find(id="table-childtable-constraints")) + self.assertIsNotNone(child_table_soup.find(string="CK_ChildTable_amount_positive")) + self.assertIsNotNone(child_table_soup.find("code", string="amount > 0")) + self.assertIsNotNone(child_table_soup.find(string="UQ_ChildTable_code_parent")) + self.assertIsNotNone( + find_link( + child_table_soup, + "demo--childtable.html#table-childtable-constraint-fk-childtable-parenttable", + ) + ) + self.assertIsNotNone(find_link(child_table_soup, "demo--parenttable.html")) + self.assertIsNotNone( + find_link( + child_table_soup, + "demo--parenttable.html#table-parenttable-col-id", + ) + ) + + fk_row = child_table_soup.find(id="table-childtable-constraint-fk-childtable-parenttable") + self.assertIsNotNone(fk_row) + if fk_row is not None: + fk_text = fk_row.get_text(" ", strip=True) + self.assertIn("On Delete:", fk_text) + self.assertIn("CASCADE", fk_text) + self.assertIn("On Update:", fk_text) + self.assertIn("NO ACTION", fk_text) + + # Validate schema-page singular/plural column count grammar. + schema_list = schema_soup.find("ul", class_="schema-list") + self.assertIsNotNone(schema_list) + if schema_list is not None: + count_labels = [ + element.get_text(strip=True) + for element in schema_list.find_all(class_="muted") + if element.get_text(strip=True).startswith("(") + ] + self.assertIn("(1 column)", count_labels) + self.assertIn("(4 columns)", count_labels) + + def test_browser_file_list(self) -> None: + """Generate browser output from multiple schema files. + + Validate homepage links and singular/plural table count labels. + """ + schema_a = """ +name: alpha +"@id": "#alpha" +description: Alpha schema. +tables: + - name: A + "@id": "#A" + columns: + - name: id + "@id": "#A.id" + datatype: int + description: key +""".strip() + schema_b = """ +name: beta +"@id": "#beta" +description: Beta schema. +tables: + - name: B + "@id": "#B" + columns: + - name: id + "@id": "#B.id" + datatype: int + description: key + - name: B2 + "@id": "#B2" + columns: + - name: id + "@id": "#B2.id" + datatype: int + description: key +""".strip() + + with tempfile.TemporaryDirectory(dir=TEST_DIR) as tmpdir: + file_a = os.path.join(tmpdir, "a.yaml") + file_b = os.path.join(tmpdir, "b.yaml") + output_dir = os.path.join(tmpdir, "browser-list") + with open(file_a, "w", encoding="utf-8") as f: + f.write(schema_a) + with open(file_b, "w", encoding="utf-8") as f: + f.write(schema_b) + + render_browser_site(output_dir=output_dir, files=[file_a, file_b]) + + self.assertTrue(os.path.exists(os.path.join(output_dir, "index.html"))) + index_soup = get_page_soup_by_title(output_dir, "Home") + + # Validate top-level page shell links. + self.assertEqual(index_soup.title.get_text(strip=True), "Home") + self.assertIsNotNone(index_soup.find("nav", attrs={"aria-label": "Breadcrumb"})) + self.assertIsNotNone(index_soup.find("nav", class_="breadcrumb")) + self.assertIsNotNone(find_link(index_soup, "alpha.html")) + self.assertIsNotNone(find_link(index_soup, "beta.html")) + self.assertIsNotNone(index_soup.find(string="Alpha schema.")) + self.assertIsNotNone(index_soup.find(string="Beta schema.")) + + # Validate singular/plural table count grammar per schema. + schema_list = index_soup.find("ul", class_="schema-list") + self.assertIsNotNone(schema_list) + if schema_list is not None: + count_labels = [ + element.get_text(strip=True) + for element in schema_list.find_all(class_="muted") + if element.get_text(strip=True).startswith("(") + ] + self.assertIn("(1 table)", count_labels) + self.assertIn("(2 tables)", count_labels) + + def test_browser_compound_primary_key_spacing(self) -> None: + """Render compound primary keys without whitespace before commas.""" + schema_yaml = """ +name: demo +"@id": "#demo" +tables: + - name: CompoundKeyTable + "@id": "#CompoundKeyTable" + columns: + - name: first_id + "@id": "#CompoundKeyTable.first_id" + datatype: int + - name: second_id + "@id": "#CompoundKeyTable.second_id" + datatype: int + primaryKey: + - "#CompoundKeyTable.first_id" + - "#CompoundKeyTable.second_id" +""".strip() + + with tempfile.TemporaryDirectory(dir=TEST_DIR) as tmpdir: + schema_path = os.path.join(tmpdir, "compound-key.yaml") + output_dir = os.path.join(tmpdir, "browser") + with open(schema_path, "w", encoding="utf-8") as f: + f.write(schema_yaml) + + render_browser_site(output_dir=output_dir, files=[schema_path]) + + table_soup = get_page_soup_by_title(output_dir, "CompoundKeyTable - demo - Felis Browser") + + # Validate both primary key links are present and comma spacing is + # normalized. + primary_key_header = table_soup.find(id="table-compoundkeytable-primary-key") + self.assertIsNotNone(primary_key_header) + if primary_key_header is not None: + primary_key_value = primary_key_header.find_next("p") + self.assertIsNotNone(primary_key_value) + if primary_key_value is not None: + self.assertEqual( + primary_key_value.get_text(" ", strip=True).replace(" ,", ","), + "first_id, second_id", + ) + self.assertIsNotNone(find_link(primary_key_value, "#table-compoundkeytable-col-first-id")) + self.assertIsNotNone( + find_link(primary_key_value, "#table-compoundkeytable-col-second-id") + ) + + def test_browser_non_yaml_file_error(self) -> None: + """Fail when input file is not YAML.""" + with tempfile.TemporaryDirectory(dir=TEST_DIR) as tmpdir: + output_dir = os.path.join(tmpdir, "browser-invalid") + bad_file = os.path.join(tmpdir, "not_yaml.txt") + with open(bad_file, "w", encoding="utf-8") as f: + f.write("hello") + + with self.assertRaisesRegex(ValueError, "Input file must be .yaml or .yml"): + render_browser_site(output_dir=output_dir, files=[bad_file]) + + def test_browser_disable_column_details(self) -> None: + """Do not render expandable column details when disabled.""" + schema_yaml = """ +name: demo +"@id": "#demo" +tables: + - name: ChildTable + "@id": "#ChildTable" + columns: + - name: code + "@id": "#ChildTable.code" + datatype: int + description: Business code. +""".strip() + + with tempfile.TemporaryDirectory(dir=TEST_DIR) as tmpdir: + schema_path = os.path.join(tmpdir, "disable-details.yaml") + output_dir = os.path.join(tmpdir, "browser") + with open(schema_path, "w", encoding="utf-8") as f: + f.write(schema_yaml) + + render_browser_site(output_dir=output_dir, files=[schema_path], include_column_details=False) + + table_soup = get_page_soup_by_title(output_dir, "ChildTable - demo - Felis Browser") + + # Validate details controls/rows are fully absent when disabled. + self.assertIsNone(table_soup.find(attrs={"data-column-details-action": "expand-all"})) + self.assertIsNone(table_soup.find(attrs={"data-column-details-action": "collapse-all"})) + self.assertIsNone( + table_soup.find(attrs={"data-column-details-target": "table-childtable-col-code-details"}) + ) + self.assertEqual(len(table_soup.find_all(class_="column-details-row")), 0) + self.assertEqual(len(table_soup.find_all(class_="field-key")), 0) + self.assertEqual(len(table_soup.find_all(class_="field-value")), 0) + + def test_browser_site_icon(self) -> None: + """Render a centered header icon and optional link when configured.""" + schema_yaml = """ +name: demo +"@id": "#demo" +tables: + - name: ChildTable + "@id": "#ChildTable" + columns: + - name: code + "@id": "#ChildTable.code" + datatype: int +""".strip() + + with tempfile.TemporaryDirectory(dir=TEST_DIR) as tmpdir: + schema_path = os.path.join(tmpdir, "icon.yaml") + output_dir = os.path.join(tmpdir, "browser") + icon_path = os.path.join(tmpdir, "icon.png") + with open(schema_path, "w", encoding="utf-8") as f: + f.write(schema_yaml) + with open(icon_path, "wb") as f: + f.write(b"\x89PNG\r\n\x1a\n") + + render_browser_site( + output_dir=output_dir, + files=[schema_path], + site_icon=icon_path, + icon_link_url="https://www.lsst.org", + ) + + self.assertTrue(os.path.exists(os.path.join(output_dir, "assets", "site-icon.png"))) + index_soup = get_page_soup_by_title(output_dir, "Home") + + # Validate icon link wrapper and image source in rendered home + # page. + self.assertIsNotNone( + index_soup.find("a", class_="header-site-icon-link", href="https://www.lsst.org") + ) + self.assertIsNotNone( + index_soup.find("img", class_="header-site-icon", src="assets/site-icon.png") + ) + + def test_browser_icon_link_warning_without_icon(self) -> None: + """Warn when icon_link_url is set but site_icon is not configured.""" + schema_yaml = """ +name: demo +"@id": "#demo" +tables: + - name: ChildTable + "@id": "#ChildTable" + columns: + - name: code + "@id": "#ChildTable.code" + datatype: int +""".strip() + + with tempfile.TemporaryDirectory(dir=TEST_DIR) as tmpdir: + schema_path = os.path.join(tmpdir, "icon-warning.yaml") + output_dir = os.path.join(tmpdir, "browser") + with open(schema_path, "w", encoding="utf-8") as f: + f.write(schema_yaml) + + with self.assertLogs("felis.browser.render", level="WARNING") as captured: + render_browser_site( + output_dir=output_dir, + files=[schema_path], + icon_link_url="https://www.lsst.org", + ) + + self.assertTrue(any("ignoring icon link" in message for message in captured.output))