Coverage for src/CSET/cset_workflow/app/finish_website/bin/finish_website.py: 95%
79 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-14 13:38 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-14 13:38 +0000
1#!/usr/bin/env python3
2# © Crown copyright, Met Office (2022-2025) and CSET contributors.
3#
4# Licensed under the Apache License, Version 2.0 (the "License");
5# you may not use this file except in compliance with the License.
6# You may obtain a copy of the License at
7#
8# http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS,
12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13# See the License for the specific language governing permissions and
14# limitations under the License.
16"""
17Create the CSET diagnostic viewing website.
19Copies the static files that make up the web interface, constructs the plot
20index, and updates the workflow status on the front page of the
21web interface.
22"""
24import argparse
25import json
26import logging
27import os
28import re
29import shutil
30import sys
31import time
32from collections.abc import Iterable
33from importlib.metadata import version
34from pathlib import Path
36logging.basicConfig(
37 level=os.getenv("LOGLEVEL", "INFO"),
38 format="%(asctime)s %(levelname)s %(message)s",
39 stream=sys.stdout,
40)
41logger = logging.getLogger(__name__)
44def human_sorted(iterable: Iterable, reverse: bool = False) -> list:
45 """Sort such numbers within strings are sorted correctly."""
46 # Adapted from https://nedbatchelder.com/blog/200712/human_sorting.html
48 def alphanum_key(s):
49 """Turn a string into a list of string and number chunks.
51 >>> alphanum_key("z23a")
52 ["z", 23, "a"]
53 """
54 try:
55 return [int(c) if c.isdecimal() else c for c in re.split(r"(\d+)", s)]
56 except TypeError:
57 return s
59 return sorted(iterable, key=alphanum_key, reverse=reverse)
62def sort_dict(d: dict) -> dict:
63 """Recursively sort a dictionary."""
64 # Thank you to https://stackoverflow.com/a/47882384
65 return {
66 k: sort_dict(v) if isinstance(v, dict) else v
67 for k, v in human_sorted(d.items())
68 }
71def install_website_skeleton(
72 www_root_link: Path | None, www_content: Path, skeleton_dir: Path
73):
74 """Copy static website files and create symlink from web document root."""
75 logger.info("Installing website files to %s.", www_content)
76 # Create directory for web content.
77 www_content.mkdir(parents=True, exist_ok=True)
78 # Copy static HTML/CSS/JS.
79 shutil.copytree(skeleton_dir, www_content, dirs_exist_ok=True)
80 # Setup symbolic link from web root.
81 if www_root_link is not None: 81 ↛ exitline 81 didn't return from function 'install_website_skeleton' because the condition on line 81 was always true
82 logger.info("Linking %s to web content.", www_root_link)
83 # Remove existing link to output ahead of creating new symlink.
84 www_root_link.unlink(missing_ok=True)
85 # Ensure parent directories of WEB_DIR exist.
86 www_root_link.parent.mkdir(parents=True, exist_ok=True)
87 # Create symbolic link to web directory.
88 # NOTE: While good for space, it means `cylc clean` removes output.
89 www_root_link.symlink_to(www_content)
92def construct_index(www_content: Path):
93 """Construct the plot index."""
94 with open(www_content / "index.jsonl", "wt", encoding="UTF-8") as index_fp:
95 # Loop over all diagnostics and append to index. The glob is sorted to
96 # ensure a consistent ordering.
97 for metadata_file in sorted(www_content.glob("**/*.json")):
98 try:
99 with open(metadata_file, "rt", encoding="UTF-8") as plot_fp:
100 plot_metadata = json.load(plot_fp)
101 plot_metadata["path"] = str(
102 metadata_file.parent.relative_to(www_content)
103 )
104 # Remove keys that are not useful for the index.
105 removed_index_keys = [
106 "description",
107 "plot_resolution",
108 "plots",
109 "skip_write",
110 "COORD_LIST",
111 "ONE_TO_ONE",
112 "PERCENTILES",
113 "SUBAREA_EXTENT",
114 "SUBAREA_TYPE",
115 ]
116 for key in removed_index_keys:
117 plot_metadata.pop(key, None)
118 # Sort plot metadata for consistency.
119 plot_metadata = sort_dict(plot_metadata)
120 # Write metadata into website index.
121 json.dump(plot_metadata, index_fp, separators=(",", ":"))
122 index_fp.write("\n")
123 except (json.JSONDecodeError, KeyError, TypeError) as err:
124 logging.error("%s is invalid, skipping.\n%s", metadata_file, err)
125 continue
128def bust_cache(www_content: Path):
129 """Add a unique query string to static requests to avoid stale caches.
131 We only need to do this for static resources referenced from the index page,
132 as each plot already uses a unique filename based on the recipe.
133 """
134 # Search and replace the string "CACHEBUSTER" with a time-based cache key.
135 cache_key = str(int(time.time()))
136 with open(www_content / "index.html", "r+t") as fp:
137 content = fp.read()
138 new_content = content.replace("CACHEBUSTER", cache_key)
139 fp.seek(0)
140 fp.truncate()
141 fp.write(new_content)
144def update_workflow_status(www_content: Path):
145 """Update the workflow status on the front page of the web interface."""
146 with open(www_content / "placeholder.html", "r+t") as fp:
147 content = fp.read()
148 finish_time = time.strftime("%Y-%m-%d %H:%M", time.localtime())
149 status = f"Completed at {finish_time} using CSET v{version('CSET')}"
150 new_content = content.replace(
151 '<p id="workflow-status">Unknown</p>',
152 f'<p id="workflow-status">{status}</p>',
153 )
154 fp.seek(0)
155 fp.truncate()
156 fp.write(new_content)
159def copy_rose_config(www_content: Path):
160 """Copy the rose-suite.conf file to add to output web directory."""
161 cylc_run_dir = os.getenv("CYLC_WORKFLOW_RUN_DIR", None)
162 if cylc_run_dir: 162 ↛ exitline 162 didn't return from function 'copy_rose_config' because the condition on line 162 was always true
163 rose_suite_conf = Path(cylc_run_dir) / "rose-suite.conf"
164 web_conf_file = www_content / "rose-suite.conf"
165 try:
166 shutil.copyfile(rose_suite_conf, web_conf_file)
167 except FileNotFoundError:
168 logger.warning("No rose-suite.conf file found for cylc workflow.")
171def parse_args(args: list[str] | None = None) -> argparse.Namespace:
172 """Parse command line arguments."""
173 parser = argparse.ArgumentParser(
174 description="Create a navigation interface for a set of diagnostic webpages.",
175 )
176 parser.add_argument(
177 "web_content",
178 type=Path,
179 help="Where to save the HTML content and find the diagnostic webpages.",
180 )
181 parser.add_argument(
182 "--web-root-link",
183 type=Path,
184 help="Where to create a symlink to the content, optional.",
185 )
186 parser.add_argument(
187 "--skeleton",
188 type=Path,
189 help="Directory containing static website files. Defaults to $PWD/html",
190 default=Path.cwd() / "html",
191 )
192 return parser.parse_args(args)
195def run(): # pragma: no cover
196 """Do the final steps to finish the website."""
197 args = parse_args()
198 logger.debug("Arguments: %s", args)
200 install_website_skeleton(args.web_root_link, args.web_content, args.skeleton)
201 copy_rose_config(args.web_content)
202 construct_index(args.web_content)
203 bust_cache(args.web_content)
204 update_workflow_status(args.web_content)
207if __name__ == "__main__": # pragma: no cover
208 run()