1"""Calling module for the SkySim package."""
2
3# License: GPLv3+ (see COPYING); Copyright (C) 2025 Tai Withers
4
5import argparse
6import sys
7from pathlib import Path
8
9# skysim. is required to run file as python <file>, but not for poetry install
10# TODO: check requirement for pip install
11from skysim.plot import create_plot
12from skysim.populate import create_image_matrix
13from skysim.query import get_body_locations, get_planet_table, get_star_table
14from skysim.settings import (
15 PlotSettings,
16 load_from_toml,
17)
18from skysim.utils import get_tempfile_path, read_pyproject
19
20# Methods
21
22
23## Top-Level Methods
24
25
[docs]
26def main( # pylint: disable=inconsistent-return-statements
27 args: list[str] | None = None,
28) -> Path | None:
29 """Entrypoint for the SkySim package.
30
31 Calls the following functions directly:
32
33 - :doc:`main`
34 - `parse_args`
35 - `confirm_config_file`
36 - `handle_overwrite`
37 - :doc:`settings`
38 - `~skysim.settings.load_from_toml`
39 - :doc:`query`
40 - `~skysim.query.get_star_table`
41 - `~skysim.query.get_body_locations`
42 - `~skysim.query.get_planet_table`
43 - :doc:`populate`
44 - `~skysim.populate.create_image_matrix`
45 - :doc:`plot`
46 - `~skysim.plot.create_plot`
47
48 Parameters
49 ----------
50 args : list[str] | None , default None
51 When called via command line, this is None, and argparse captures the arguments
52 directly.
53 When running `main()` inside of python (e.g., with pytest), `args` holds a list
54 of arguments.
55
56 Returns
57 -------
58 pathlib.Path
59 Path object to the created file. Returned if `--debug` flag is set.
60 None
61 Returned if `--debug` flag is unset.
62
63 Raises
64 ------
65 ValueError, ConnectionError
66 Re-raised from their creation inside the SkySim code if and only if
67 the --debug flag is set.
68
69 SystemExit
70 Supercedes internal errors to produce a cleaner error message if the
71 --debug flag is not set.
72 """
73
74 options = parse_cli_args(args)
75 try:
76 config_path = confirm_config_file(options.config_file)
77
78 image_settings, plot_settings = load_from_toml(config_path)
79
80 handle_overwrite(plot_settings, options)
81
82 star_table = get_star_table(
83 image_settings.observation_radec,
84 image_settings.field_of_view,
85 image_settings.maximum_magnitude,
86 image_settings.object_colours,
87 )
88
89 body_locations = get_body_locations(
90 image_settings.observation_times, image_settings.earth_location
91 )
92 planet_tables = get_planet_table(body_locations)
93
94 image = create_image_matrix(
95 image_settings, # type: ignore[arg-type]
96 planet_tables,
97 star_table,
98 options.verbose,
99 )
100
101 create_plot(plot_settings, image, options.verbose) # type: ignore[arg-type]
102
103 # Optionally print simple error message instead of full traceback
104 except (ValueError, ConnectionError) as e:
105 if options.debug:
106 raise e
107
108 sys.stderr.write(f"error: {' '.join(e.args)}")
109 sys.exit(1)
110
111 if options.debug:
112 # note: returning a value means that an exit code of 1 is always returned
113 # therefore --debug should not be used in pipelines
114 return plot_settings.filename
115
116 return # type: ignore[return-value]
117
118
119## Helper Methods
120
121
[docs]
122class CustomArgumentParser(argparse.ArgumentParser):
123 """Override of the default ArgumentParser to change the error message."""
124
[docs]
125 def error(self, message):
126 self.print_usage()
127 self.exit(2, f"error: {message}")
128
129
[docs]
130def parse_cli_args(args: list[str] | None) -> argparse.Namespace:
131 """Parse command line arguments.
132
133 Parameters
134 ----------
135 args : list[str] | None
136 Arguments.
137
138 Returns
139 -------
140 argparse.Namespace
141 Parsed arguments.
142 """
143 pyproject = read_pyproject()
144 executable = list(pyproject["scripts"].keys())[0]
145 version_string = f"{pyproject['name']} {pyproject['version']}"
146 verbosity_options = {0: "silent", 1: "default", 2: "more output"}
147 default_verbosity = [k for k, v in verbosity_options.items() if v == "default"][0]
148
149 # instantiate the parser with project info
150 parser = CustomArgumentParser(
151 prog=executable,
152 description=pyproject["description"],
153 epilog="\n".join(
154 [
155 version_string,
156 "Repository: " + pyproject["urls"]["repository"],
157 "License: " + pyproject["license"],
158 ]
159 ),
160 formatter_class=argparse.RawTextHelpFormatter,
161 )
162
163 # add cli arguments/options
164 parser.add_argument("config_file", help="TOML configuration file")
165 parser.add_argument(
166 "--debug",
167 help="print full Python traceback, causes an exit code of 1, even on success",
168 action="store_true",
169 )
170 parser.add_argument(
171 "--overwrite", help="overwrite existing file(s)", action="store_true"
172 )
173 parser.add_argument(
174 "--verbose",
175 help=str(verbosity_options)[1:-1].replace("'", ""), # remove {}, ''
176 choices=verbosity_options.keys(),
177 type=int, # casts input to int before applying choices
178 default=default_verbosity,
179 )
180 parser.add_argument(
181 "--version",
182 version=version_string,
183 action="version",
184 )
185
186 return parser.parse_args(args)
187
188
[docs]
189def confirm_config_file(input_config_path: str) -> Path:
190 """Pre-validate the existence of a config file.
191
192 Parameters
193 ----------
194 input_config_path : str
195 Argument passed on command line.
196
197 Returns
198 -------
199 pathlib.Path
200 Given config file path (if any).
201
202 Raises
203 ------
204 ValueError
205 Raised if
206 - Path given does not exist.
207 - Path leads to a non-file object.
208 - Path does not have a ".toml" extension.
209 """
210
211 config_path = Path(input_config_path).resolve()
212
213 if not config_path.exists():
214 raise ValueError(f"{config_path} does not exist.")
215 if not config_path.is_file():
216 raise ValueError(f"{config_path} is not a file.")
217 if config_path.suffix != ".toml":
218 raise ValueError(f"{config_path} does not have a '.toml' extension.")
219
220 return config_path
221
222
[docs]
223def handle_overwrite(plot_settings: PlotSettings, options: argparse.Namespace) -> None:
224 """Check if files will be overwritten and take action based on cli flags (--verbose
225 and --overwrite).
226
227 Parameters
228 ----------
229 plot_settings : PlotSettings
230 Plotting configuration, passed to `check_for_overwrite`.
231 options : argparse.Namespace
232 Parsed CLI options.
233
234 Raises
235 ------
236 ValueError
237 Raised if an overwrite would happen but the --overwrite flag was not passed.
238 """
239 overwritten_paths = check_for_overwrite(plot_settings)
240
241 # noop if no overwrites
242 if len(overwritten_paths) == 0:
243 return
244
245 # if there's mutiple files, start the printout on a new line
246 if len(overwritten_paths) > 1:
247 overwritten_paths.insert(0, "")
248
249 # set the printout based on verbosity
250 if options.verbose > 1:
251 paths_string = "\n".join([str(p) for p in overwritten_paths])
252 paths_message = f"the following files: {paths_string}."
253 else:
254 paths_message = "one or more files."
255
256 # display message or error based on use of --overwrite
257 if options.overwrite:
258 print(f"Overwriting {paths_message}")
259 else:
260 raise ValueError(
261 f"Running SkySim would overwrite {paths_message}\nUse the --overwrite flag"
262 " or change/remove the output path to continue."
263 )
264
265
266### Secondary Helpers
267
268
[docs]
269def check_for_overwrite(plot_settings: PlotSettings) -> list[Path]:
270 """Check if SkySim will overwrite any existing files.
271
272 Parameters
273 ----------
274 plot_settings : PlotSettings
275 Configuration.
276
277 Returns
278 -------
279 pathlib.Path
280 Returns a list of paths that will be overwritten.
281 """
282 overwrites = []
283
284 if plot_settings.filename.exists():
285 overwrites.append(plot_settings.filename)
286
287 if plot_settings.frames > 1:
288 for i in range(plot_settings.frames):
289 tempfile_path = get_tempfile_path(plot_settings, i)
290 if tempfile_path.exists():
291 overwrites.append(tempfile_path)
292
293 return overwrites