Coverage for rust2rpm/conf/toml.py: 85%
295 statements
« prev ^ index » next coverage.py v7.6.7, created at 2024-11-26 13:52 +0100
« prev ^ index » next coverage.py v7.6.7, created at 2024-11-26 13:52 +0100
1"""Module containing functionality for the TOML-based configuration format."""
3import json
4import sys
5import textwrap
6import tomllib
7from collections import defaultdict
8from dataclasses import dataclass, field
9from importlib import resources
10from pathlib import Path
12import jsonschema
14from rust2rpm import log
16from .common import ConfError
18TOML_SCHEMA = json.loads(
19 resources.files("rust2rpm").joinpath("rust2rpm.schema.json").read_text(),
20)
23def conf_comments_to_spec_comments(comments: list[str] | None) -> list[str]:
24 """Format list of strings as pretty spec file comments.
26 Any string in the list of comments that contains a newline character
27 is treated as pre-formatted and no line-wrapping is applied to it.
29 Arguments:
30 comments: Comments as list of strings.
32 Returns:
33 Pre-formatted comments as list of lines.
35 """
36 if not comments:
37 return []
39 lines = []
40 for comment in comments:
41 # single-line comments: wrap to 80 columns
42 if "\n" not in comment.strip():
43 lines.extend(
44 textwrap.wrap(
45 comment,
46 width=80,
47 initial_indent="# * ",
48 subsequent_indent="# ",
49 break_long_words=False,
50 break_on_hyphens=False,
51 ),
52 )
54 # multi-line comments: assume pre-formatted
55 else:
56 first, *rest = (line.strip() for line in comment.splitlines())
57 lines.append(f"# * {first}")
58 lines.extend([f"# {line}" for line in rest])
60 return lines
63@dataclass(frozen=True)
64class Source:
65 """Properties of extra Source files."""
67 file: str
68 number: int
69 comments: list[str] = field(default_factory=list)
71 @staticmethod
72 def from_data(data: dict) -> "Source":
73 """Initialize from TOML data directly."""
74 return Source(**data)
76 @property
77 def comment_lines(self) -> list[str]:
78 """Format comments as spec comments."""
79 return conf_comments_to_spec_comments(self.comments)
81 @property
82 def whitespace(self) -> str:
83 """Whitespace between "SourceX:" tag and the "file name"."""
84 return " " * (16 - (len("Source") + len(str(self.number)) + 1))
87@dataclass(frozen=True)
88class Patch:
89 """Properties of extra Patch files."""
91 file: str
92 number: int
93 comments: list[str] = field(default_factory=list)
95 @staticmethod
96 def from_data(data: dict) -> "Patch":
97 """Initialize from TOML data directly."""
98 return Patch(**data)
100 @property
101 def comment_lines(self) -> list[str]:
102 """Format comments as spec comments."""
103 return conf_comments_to_spec_comments(self.comments)
105 @property
106 def whitespace(self) -> str:
107 """Whitespace between "PatchX:" tag and the "file name"."""
108 return " " * (16 - (len("Patch") + len(str(self.number)) + 1))
111@dataclass(frozen=True)
112class FileInEx:
113 """File inclusion and exclusion rules."""
115 include: list[str] | None = None
116 exclude: list[str] | None = None
119@dataclass(frozen=True)
120class Package:
121 """Collection of package-specific settings."""
123 summary: str | None = None
124 """Override for the generated RPM Summary tag."""
126 description: str | None = None
127 """Override for the generated RPM %description."""
129 url: str | None = None
130 """Override for the generated RPM URL tag."""
132 source_url: str | None = None
133 """Override for the RPM Source tag."""
135 supported_arches: list[str] | None = None
136 """List of architectures supported by the package.
138 For crates, this is used to conditionally run `%build` and `%check` scriptlets.
139 For other projects, it is used as the value of an `ExclusiveArch` tag.
140 """
142 suppress_cdylib_install_fixme: bool | None = None
143 """Flag that controls injection of the "FIXME" comment for packages that contain "cdylib" targets."""
145 bin_package_name: str | None = None
146 """Override for the generated binary subpackage name."""
148 cargo_install_bin: bool | None = None
149 """Flag that controls injection of the `%cargo_install_bin` macro and its value."""
151 cargo_install_lib: bool | None = None
152 """Flag that controls injection of the `%cargo_install_lib` macro and its value."""
154 debuginfo_level: int | None = None
155 """Flag that controls injection of the `%rustflags_debuginfo` macro and its value."""
157 cargo_toml_patch_comments: list[str] = field(default_factory=list)
158 """List of comments associated with manually applied changes to Cargo.toml."""
160 license_files: list[str] | FileInEx = field(default_factory=FileInEx)
161 """Settings for overriding the results of crawling the project sources for license files."""
163 doc_files: list[str] | FileInEx = field(default_factory=FileInEx)
164 """Settings for overriding the results of crawling the project sources for documentation files."""
166 extra_sources: list[Source] = field(default_factory=list)
167 """Settings for including additional `Source` files."""
169 extra_patches: list[Patch] = field(default_factory=list)
170 """Settings for including additional `Patch` files."""
172 extra_files: list[str] = field(default_factory=list)
173 """Setting for including additional files in the `%files` list of the built binary subpackage."""
175 exclude_crate_files: list[str] = field(default_factory=list)
176 """Setting for excluding files from the `%files` list of the subpackage that contains the crate sources."""
178 bin_renames: dict[str, str] = field(default_factory=dict)
179 """Setting for renaming executables, usually to avoid file conflicts with other packages."""
181 @staticmethod
182 def from_data(data: dict) -> "Package":
183 """Initialize from TOML data directly."""
184 args = {key.replace("-", "_"): value for key, value in data.items()}
186 if extra_sources := data.get("extra-sources"):
187 args["extra_sources"] = [Source.from_data(source) for source in extra_sources]
189 if extra_patches := data.get("extra-patches"):
190 args["extra_patches"] = [Patch.from_data(patch) for patch in extra_patches]
192 if lf_obj := data.get("license-files"):
193 args["license_files"] = FileInEx(**lf_obj) if isinstance(lf_obj, dict) else lf_obj
195 if df_obj := data.get("doc-files"):
196 args["doc_files"] = FileInEx(**df_obj) if isinstance(df_obj, dict) else df_obj
198 return Package(**args)
200 @property
201 def cargo_toml_patch_comment_lines(self) -> list[str]:
202 """Format comments as spec comments."""
203 return conf_comments_to_spec_comments(self.cargo_toml_patch_comments)
206@dataclass(frozen=True)
207class PrePostScripts:
208 """Collection of extra commands for an RPM package scriptlet."""
210 pre: list[str] = field(default_factory=list)
211 """List of additional commands that are injected before the `%cargo_*` macro in this scriptlet."""
213 post: list[str] = field(default_factory=list)
214 """List of additional commands that are injected after the `%cargo_*` macro in this scriptlet."""
217@dataclass(frozen=True)
218class Scripts:
219 """Collection of extra commands for RPM package scriptlets."""
221 prep: PrePostScripts = field(default_factory=PrePostScripts)
222 """Additional commands for the `%prep` scriptlet."""
224 build: PrePostScripts = field(default_factory=PrePostScripts)
225 """Additional commands for the `%build` scriptlet."""
227 install: PrePostScripts = field(default_factory=PrePostScripts)
228 """Additional commands for the `%install` scriptlet."""
230 check: PrePostScripts = field(default_factory=PrePostScripts)
231 """Additional commands for the `%check` scriptlet."""
233 @staticmethod
234 def from_data(data: dict) -> "Scripts":
235 """Initialize from TOML data directly."""
236 prep = PrePostScripts(**value) if (value := data.get("prep")) else PrePostScripts()
237 build = PrePostScripts(**value) if (value := data.get("build")) else PrePostScripts()
238 install = PrePostScripts(**value) if (value := data.get("install")) else PrePostScripts()
239 check = PrePostScripts(**value) if (value := data.get("check")) else PrePostScripts()
241 return Scripts(prep, build, install, check)
244@dataclass(frozen=True)
245class TestsSkip:
246 """Collections of names that are included as `--skip` arguments in `%cargo_test` macro arguments."""
248 lib: list[str] = field(default_factory=list)
249 bin: list[str] = field(default_factory=list)
250 doc: list[str] = field(default_factory=list)
251 bins: list[str] = field(default_factory=list)
252 tests: list[str] = field(default_factory=list)
255@dataclass(frozen=True)
256class TestsSkipExact:
257 """Collection of flags that control the inclusion of the `--exact` flag in `%cargo_test` macro arguments."""
259 lib: bool = False
260 bin: bool = False
261 doc: bool = False
262 bins: bool = False
263 tests: bool = False
266@dataclass(frozen=True)
267class TestsComments:
268 """Collections of strings that are included as pre-formatted comments for `%cargo_test` macro invocations."""
270 lib: list[str] = field(default_factory=list)
271 bin: list[str] = field(default_factory=list)
272 doc: list[str] = field(default_factory=list)
273 bins: list[str] = field(default_factory=list)
274 tests: list[str] = field(default_factory=list)
277@dataclass(frozen=True)
278class Tests:
279 """Collection of test-specific settings."""
281 run: str | list[str] = field(default_factory=list)
282 """Setting that controls which kinds of cargo tests are run."""
284 skip: list[str] | TestsSkip = field(default_factory=list)
285 """Setting that controls which tests are skipped."""
287 skip_exact: bool | TestsSkipExact = False
288 """Setting that controls whether exact matches or substring matches are used for determining skipped tests."""
290 comments: list[str] | TestsComments = field(default_factory=list)
291 """Collections of strings that are included as pre-formatted comments."""
293 @staticmethod
294 def from_data(data: dict) -> "Tests":
295 """Initialize from TOML data directly."""
296 args = {key.replace("-", "_"): value for key, value in data.items()}
298 # validate the "tests.run" setting:
299 # both "all" and "none" cannot be combimed with other values
300 if (tests_run := data.get("run")) and isinstance(tests_run, list):
301 if ("all" in tests_run or "none" in tests_run) and len(tests_run) != 1:
302 msg = f"Invalid set of tests to run: {tests_run!r}"
303 raise ConfError(msg)
304 else:
305 tests_run = []
307 if (tests_skip := data.get("skip")) and isinstance(tests_skip, dict): 307 ↛ 308line 307 didn't jump to line 308 because the condition on line 307 was never true
308 args["skip"] = TestsSkip(**tests_skip)
310 # validate the "tests.skip" setting:
311 # skipping tests for targets that are not separately run has no effect
312 for key in tests_skip:
313 if key not in tests_run:
314 log.warn(
315 f"Skipping tests for {key!r} targets only has no effect if they are not run separately.",
316 )
318 if (tests_skip_exact := data.get("skip-exact")) and isinstance(tests_skip_exact, dict): 318 ↛ 319line 318 didn't jump to line 319 because the condition on line 318 was never true
319 args["skip_exact"] = TestsSkipExact(**tests_skip_exact)
321 # validate the "tests.skip-exact" setting:
322 # exact test name matching for targets that are not separately run has no effect
323 for key in tests_skip_exact:
324 if key not in tests_run:
325 log.warn(
326 f"Using exact test name matches for {key!r} targets only has no effect "
327 "if they are not run separately.",
328 )
330 if (tests_comments := data.get("comments")) and isinstance(tests_comments, dict): 330 ↛ 331line 330 didn't jump to line 331 because the condition on line 330 was never true
331 args["comments"] = TestsComments(**tests_comments)
333 # validate the "tests.comments" setting:
334 # specifying comments for test targets that are not separately run has no effect
335 for key in tests_comments:
336 if key not in tests_run:
337 log.warn(
338 f"Specifying comments for {key!r} target tests only has no effect "
339 "if they are not run separately.",
340 )
342 return Tests(**args)
345@dataclass(frozen=True)
346class Features:
347 """Collection of feature-specific settings."""
349 enable_all: bool = False
350 """Flag that controls whether the `--all-features` flag is passed to cargo."""
352 enable: list[str] = field(default_factory=list)
353 """List of features that are enabled and passed with `--features` to cargo."""
355 disable_default: bool = False
356 """Flag that controls whether the `--no-default-features` flag is passed to cargo."""
358 hide: list[str] = field(default_factory=list)
359 """List of features for which the generation of the corresponding subpackage is suppressed."""
361 @staticmethod
362 def from_data(data: dict, validate_features: set[str] | None = None) -> "Features":
363 """Initialize from TOML data directly."""
364 # validate the "features.enable-all" and "features.disable-default" settings:
365 # both cannot be enabled at the same time
366 if data.get("enable-all") is True and data.get("disable-default") is True:
367 msg = "Conflicting settings for features: 'enable-all' and 'disable-default'"
368 raise ConfError(msg)
370 if validate_features is not None:
371 # validate the "features.enable" setting:
372 # list elements must be valid feature names
373 if enable_list := data.get("enable"):
374 for enabled in enable_list:
375 if enabled not in validate_features:
376 msg = f"Unrecognized enabled feature: {enabled}"
377 raise ConfError(msg)
379 # validate the "features.hide" setting:
380 # list elements must be valid feature names
381 if hide_list := data.get("hide"):
382 for hidden in hide_list:
383 if hidden not in validate_features:
384 msg = f"Unrecognized hidden feature: {hidden}"
385 raise ConfError(msg)
387 # warn when conflicting settings are used
388 if (data.get("enable-all") is True) and (data.get("hide") is not None and len(data["hide"]) > 0):
389 log.warn(
390 "Conflicting settings for features: "
391 "All features are enabled for the build but some feature subpackages are hidden. "
392 "This is likely an error.",
393 )
395 args = {key.replace("-", "_"): value for key, value in data.items()}
396 return Features(**args)
399@dataclass(frozen=True)
400class Requires:
401 """Collection of settings related to additional Requires and BuildRequires."""
403 build: list[str] = field(default_factory=list)
404 """List of additional `BuildRequires`."""
406 test: list[str] = field(default_factory=list)
407 """List of additional `BuildRequires` that only apply when building the package with tests."""
409 lib: list[str] = field(default_factory=list)
410 """List of additional `Requires` for the subpackage that contains the crate sources."""
412 bin: list[str] = field(default_factory=list)
413 """List of additional `Requires` for the built binary subpackage."""
415 features: defaultdict[str, list[str]] = field(default_factory=lambda: defaultdict(list))
416 """List of additional `Requires` for built subpackages associated with crate features."""
418 @staticmethod
419 def from_data(data: dict, validate_features: set[str] | None = None) -> "Requires":
420 """Initialize from TOML data directly."""
421 args = {key.replace("-", "_"): value for key, value in data.items()}
423 # validate the "requires.features" setting
424 # dictionary keys must be valid feature names
425 if validate_features is not None and (feature_requires := data.get("features")):
426 for feature in feature_requires:
427 if feature not in validate_features:
428 msg = f"Unrecognized Requires for feature: {feature}"
429 raise ConfError(msg)
431 return Requires(**args)
434@dataclass(frozen=True)
435class TomlConf:
436 """rust2rpm configuration loaded from the rust2rpm.toml file format."""
438 package: Package = field(default_factory=Package)
439 """Settings from the [package] table."""
441 scripts: Scripts = field(default_factory=Scripts)
442 """Settings from the [scripts] table."""
444 tests: Tests = field(default_factory=Tests)
445 """Settings from the [tests] table."""
447 features: Features = field(default_factory=Features)
448 """Settings from the [features] table."""
450 requires: Requires = field(default_factory=Requires)
451 """Settings from the [requires] table."""
453 @staticmethod
454 def from_data(data: dict, validate_features: set[str] | None) -> "TomlConf":
455 """Initialize from TOML data directly."""
456 jsonschema.validate(data, TOML_SCHEMA)
458 # the "default" feature is always implicitly defined
459 if validate_features is not None and "default" not in validate_features:
460 validate_features.add("default")
462 package = Package.from_data(data.get("package") or {})
463 scripts = Scripts.from_data(data.get("scripts") or {})
464 tests = Tests.from_data(data.get("tests") or {})
466 features = Features.from_data(data.get("features") or {}, validate_features)
467 requires = Requires.from_data(data.get("requires") or {}, validate_features)
469 return TomlConf(package, scripts, tests, features, requires)
471 @staticmethod
472 def from_str(value: str, validate_features: set[str] | None) -> "TomlConf":
473 """Load configuration from a TOML string."""
474 toml = tomllib.loads(value)
475 return TomlConf.from_data(toml, validate_features)
477 @staticmethod
478 def load(path: Path, validate_features: set[str] | None) -> "TomlConf":
479 """Load configuration from a file path."""
480 with path.open() as file:
481 contents = file.read()
483 return TomlConf.from_str(contents, validate_features)
486def load_config_toml(path: Path, validate_features: set[str] | None = None) -> TomlConf | None:
487 """Load rust2rpm configuration from the rust2rpm.toml file format.
489 Arguments:
490 path: Expected file path of the rust2rpm.toml file.
491 validate_features: Optional set of valid feature names.
492 Used to validate some settings.
494 Returns:
495 Configuration loaded from a file in TOML format, or `None` if no file was found.
497 Raises:
498 `SystemExit` on fatal errors.
500 """
501 try:
502 tomlconf = TomlConf.load(path, validate_features)
504 except FileNotFoundError:
505 return None
507 except tomllib.TOMLDecodeError as exc:
508 log.error("Cannot read rust2rpm.toml file (TOML syntax error):")
509 log.error(str(exc))
510 sys.exit(1)
512 except jsonschema.ValidationError as exc:
513 if not exc.path:
514 log.error("Invalid rust2rpm.toml file (unknown setting or table):")
515 log.error(exc.message)
516 else:
517 err_path = ""
518 for elem in exc.path:
519 if isinstance(elem, int):
520 err_path += f"[{elem}]"
521 else:
522 err_path += f".{elem}"
523 log.error(f"Invalid rust2rpm.toml file (invalid setting at {err_path!r}):")
524 log.error(exc.message)
525 sys.exit(1)
527 except ConfError as exc:
528 log.error("Invalid rust2rpm.toml file:")
529 log.error(str(exc))
530 sys.exit(1)
532 else:
533 return tomlconf