Coverage for src/CSET/extract_workflow.py: 55%
101 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-08 11:00 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-08 11:00 +0000
1# © Crown copyright, Met Office (2022-2026) and CSET contributors.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
15"""Extract the CSET cylc workflow for use."""
17import importlib.metadata
18import importlib.resources
19import logging
20import os
21import re
22import shutil
23import stat
24import subprocess
25import tempfile
26from pathlib import Path
28import CSET.cset_workflow
30logger = logging.getLogger(__name__)
33def make_script_executable(p: Path):
34 """Make a script file (starting with a shebang) executable."""
35 if p.is_file():
36 try:
37 with open(p, "rb") as fd:
38 shebang = fd.read(14)
39 except PermissionError:
40 # Skip files that can't be read.
41 logger.debug("Unreadable file: %s", p)
42 return
43 # Assume the first 14 bytes of a script are #!/usr/bin/env
44 if shebang == b"#!/usr/bin/env":
45 logger.debug("Changing file mode to executable: %s", p)
46 mode = p.stat().st_mode
47 # User must be able to read if we read the file.
48 mode |= stat.S_IXUSR
49 # Make executable by group and/or others if they can read.
50 if mode & stat.S_IRGRP:
51 mode |= stat.S_IXGRP
52 if mode & stat.S_IROTH:
53 mode |= stat.S_IXOTH
54 p.chmod(mode)
57def install_workflow(location: Path) -> Path:
58 """Install the workflow's files and link the conda environment.
60 Arguments
61 ---------
62 location: Path
63 A directory where the workflow files are to be installed to. A
64 sub-directory named "cset-workflow-vX.Y.Z" will be created under here.
65 """
66 # Check location's parents exist.
67 if not location.is_dir():
68 raise OSError(f"{location} should exist and be a directory.")
69 workflow_dir = location / f"cset-workflow-v{importlib.metadata.version('CSET')}"
71 # Write workflow content into workflow_dir.
72 workflow_files = importlib.resources.files(CSET.cset_workflow)
73 with importlib.resources.as_file(workflow_files) as w:
74 logger.info("Copying workflow files into place.")
75 try:
76 shutil.copytree(w, workflow_dir)
77 except FileExistsError as err:
78 raise FileExistsError(f"Refusing to overwrite {workflow_dir}") from err
80 # Make scripts executable.
81 logger.info("Changing mode of scripts to be executable.")
82 for dirpath, _, filenames in os.walk(workflow_dir):
83 for filename in filenames:
84 make_script_executable(Path(dirpath) / filename)
86 # Create link to conda environment.
87 conda_prefix = os.getenv("CONDA_PREFIX")
88 if conda_prefix is not None:
89 logger.info("Linking workflow conda environment to %s", conda_prefix)
90 (workflow_dir / "conda-environment").symlink_to(Path(conda_prefix).resolve())
91 else:
92 logger.warning("CONDA_PREFIX not defined. Skipping linking environment.")
94 print(f"Workflow written to {workflow_dir}")
95 return workflow_dir
98def list_refs(url: str) -> list[str]:
99 """List release refs for the repository.
101 This serves both as an access check and to find an appropriate ref.
102 """
103 # Disable interactively asking for authentication.
104 env = os.environ.copy()
105 env["GIT_TERMINAL_PROMPT"] = "false"
107 cmd = ("git", "ls-remote", "--quiet", url, "releases/**", "v*")
108 logger.debug("Running %s", " ".join(cmd))
109 try:
110 p = subprocess.run(cmd, env=env, check=True, capture_output=True, text=True)
111 except subprocess.CalledProcessError as err:
112 raise ValueError(f"Cannot access Git repository at {url}") from err
114 return [line.split(maxsplit=1)[-1] for line in p.stdout.splitlines()]
117def clone_ref(ref: str, url: str, location: str):
118 """Clone the specified ref."""
119 # Disable interactively asking for authentication.
120 env = os.environ.copy()
121 env["GIT_TERMINAL_PROMPT"] = "false"
123 cmd = ("git", "clone", "--quiet", "--depth", "1", "--branch", ref, url, location)
124 logger.debug("Running %s", " ".join(cmd))
125 try:
126 subprocess.run(cmd, env=env, check=True, capture_output=True)
127 except subprocess.CalledProcessError as err:
128 raise ValueError(f"Cannot access Git repository at {url}") from err
131def install_restricted_files(workflow_dir: Path, alternative_url: str | None = None):
132 """Install restricted site-specific restricted files into CSET workflow.
134 Parameters
135 ----------
136 workflow_dir: str
137 The workflow directory into which the restricted files will be copied.
138 alternative_url: str, optional
139 Alternative Git URL to fetch the restricted files from. If omitted,
140 defaults to trying to clone first from 'localmirrors:', then from GitHub
141 via SSH and HTTPS.
143 Notes
144 -----
145 Requires Git to be installed to function.
146 """
147 # Basic check workflow_dir is correct.
148 if not (workflow_dir / "flow.cylc").is_file():
149 raise ValueError("workflow_dir should be a CSET workflow directory.")
151 # Determine target tag/branch from version.
152 version_tag = f"v{importlib.metadata.version('CSET')}"
153 logger.debug("Running for CSET %s", version_tag)
154 m = re.match(r"v\d+\.\d+", version_tag)
155 if m is None:
156 logger.error("Unknown CSET version: %s", version_tag)
157 m = [version_tag]
158 release_branch = f"releases/{m[0]}"
160 # Default URLs to try in order, or use alternative if provided.
161 urls = (
162 (
163 "localmirrors:MetOffice/CSET-restricted-files.git",
164 "git@github.com:MetOffice/CSET-restricted-files.git",
165 "https://github.com/MetOffice/CSET-restricted-files.git",
166 )
167 if alternative_url is None
168 else (alternative_url,)
169 )
171 # Find first working URL and list its refs.
172 for url in urls:
173 try:
174 refs = list_refs(url)
175 break
176 except ValueError:
177 continue
178 else:
179 raise ValueError(
180 "Could not read from restricted repository. Have you got access?"
181 )
183 # Use most specific ref, falling back to main.
184 logger.debug("Release refs: %s", refs)
185 if version_tag in refs:
186 ref = version_tag
187 elif release_branch in refs:
188 ref = release_branch
189 else:
190 ref = "main"
191 logger.info("Fetching restricted files from ref %s of %s", ref, url)
193 # Checkout git repository to temporary location.
194 with tempfile.TemporaryDirectory() as tempdir:
195 # Clone restrict file repository.
196 logger.debug("Cloning to %s", tempdir)
197 clone_ref(ref, url, tempdir)
199 # Delete unwanted top-level README.
200 os.unlink(f"{tempdir}/README.md")
202 # Copy remaining files, skipping hidden files.
203 shutil.copytree(
204 tempdir,
205 workflow_dir,
206 ignore=shutil.ignore_patterns(".*"),
207 symlinks=True,
208 dirs_exist_ok=True,
209 )
210 logger.info("Installation complete.")