prefpicker 2.17.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
prefpicker/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ # This Source Code Form is subject to the terms of the Mozilla Public
2
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ # file, You can obtain one at http://mozilla.org/MPL/2.0/.
4
+ """prefpicker module"""
5
+
6
+ from .prefpicker import PrefPicker, SourceDataError
7
+
8
+ __all__ = ("PrefPicker", "SourceDataError")
9
+ __author__ = "Tyson Smith"
prefpicker/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ # This Source Code Form is subject to the terms of the Mozilla Public
2
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ # file, You can obtain one at http://mozilla.org/MPL/2.0/.
4
+ """prefpicker module main"""
5
+
6
+ import sys
7
+
8
+ from .main import main
9
+
10
+ sys.exit(main())
prefpicker/main.py ADDED
@@ -0,0 +1,113 @@
1
+ # This Source Code Form is subject to the terms of the Mozilla Public
2
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ # file, You can obtain one at http://mozilla.org/MPL/2.0/.
4
+ """prefpicker module main"""
5
+
6
+ from __future__ import annotations
7
+
8
+ from argparse import ArgumentParser, Namespace
9
+ from logging import DEBUG, INFO, basicConfig, getLogger
10
+ from os import getenv
11
+ from pathlib import Path
12
+
13
+ from .prefpicker import PrefPicker, SourceDataError, __version__
14
+
15
+ __author__ = "Tyson Smith"
16
+ __credits__ = ["Tyson Smith"]
17
+
18
+ LOG = getLogger(__name__)
19
+
20
+
21
+ def parse_args(argv: list[str] | None = None) -> Namespace:
22
+ """Handle argument parsing.
23
+
24
+ Args:
25
+ argv: Arguments from the user.
26
+
27
+ Returns:
28
+ Parsed and sanitized arguments.
29
+ """
30
+ parser = ArgumentParser(
31
+ description="Manage & generate prefs.js files",
32
+ prog="prefpicker",
33
+ )
34
+ parser.add_argument(
35
+ "input",
36
+ type=Path,
37
+ help="Template containing definitions. This can be the path to a template"
38
+ " (YAML) file or the name of a built-in template. Built-in templates:"
39
+ f" {', '.join(x.name for x in PrefPicker.templates())}",
40
+ )
41
+ parser.add_argument("output", type=Path, help="Path of prefs.js file to create.")
42
+ parser.add_argument(
43
+ "--check", action="store_true", help="Display output of sanity checks."
44
+ )
45
+ parser.add_argument("--variant", default="default", help="Specify variant to use.")
46
+ parser.add_argument(
47
+ "--version",
48
+ "-V",
49
+ action="version",
50
+ version=f"%(prog)s {__version__}",
51
+ help="Show version number.",
52
+ )
53
+ args = parser.parse_args(argv)
54
+ # handle using built-in templates
55
+ builtin_template = PrefPicker.lookup_template(args.input.name)
56
+ if builtin_template:
57
+ args.input = builtin_template
58
+ elif not args.input.is_file():
59
+ parser.error(f"Cannot find input file '{args.input}'")
60
+ # sanity check output
61
+ if args.output.is_dir():
62
+ parser.error(f"Output '{args.output}' is a directory.")
63
+ if not args.output.parent.is_dir():
64
+ parser.error(f"Output '{args.output.parent}' directory does not exist.")
65
+ return args
66
+
67
+
68
+ def main(argv: list[str] | None = None) -> int:
69
+ """
70
+ PrefPicker main entry point
71
+
72
+ Run with --help for usage
73
+ """
74
+ if bool(getenv("DEBUG")): # pragma: no cover
75
+ log_fmt = "%(asctime)s %(levelname).1s %(name)s | %(message)s"
76
+ log_level = DEBUG
77
+ else:
78
+ log_fmt = "%(message)s"
79
+ log_level = INFO
80
+ basicConfig(format=log_fmt, level=log_level)
81
+
82
+ args = parse_args(argv)
83
+
84
+ LOG.info("Loading %r...", args.input.name)
85
+ try:
86
+ pick = PrefPicker.load_template(args.input)
87
+ except SourceDataError as exc:
88
+ LOG.error("Failed to load '%s': %s", args.input, exc)
89
+ return 1
90
+ LOG.info("Loaded %d prefs and %d variants", len(pick.prefs), len(pick.variants))
91
+ if args.check:
92
+ for combos in pick.check_combinations():
93
+ LOG.info(
94
+ "Check: %r variant has %r possible combination(s)", combos[0], combos[1]
95
+ )
96
+ for overwrites in pick.check_overwrites():
97
+ LOG.info(
98
+ "Check: %r variant %r redefines value %r (may be intentional)",
99
+ overwrites[0],
100
+ overwrites[1],
101
+ overwrites[2],
102
+ )
103
+ for dupes in pick.check_duplicates():
104
+ LOG.info(
105
+ "Check: %r variant %r contains duplicate values", dupes[0], dupes[1]
106
+ )
107
+ if args.variant not in pick.variants:
108
+ LOG.error("Error: Variant %r does not exist", args.variant)
109
+ return 1
110
+ LOG.info("Generating %r using variant %r...", args.output.name, args.variant)
111
+ pick.create_prefsjs(args.output, args.variant)
112
+ LOG.info("Done.")
113
+ return 0
@@ -0,0 +1,265 @@
1
+ # This Source Code Form is subject to the terms of the Mozilla Public
2
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ # file, You can obtain one at http://mozilla.org/MPL/2.0/.
4
+ """prefpicker module"""
5
+
6
+ from __future__ import annotations
7
+
8
+ from datetime import datetime, timezone
9
+ from importlib.metadata import PackageNotFoundError, version
10
+ from json import dumps
11
+ from pathlib import Path
12
+ from random import choice
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from yaml import safe_load
16
+ from yaml.parser import ParserError
17
+ from yaml.scanner import ScannerError
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Generator
21
+
22
+ __author__ = "Tyson Smith"
23
+ __credits__ = ["Tyson Smith"]
24
+ try:
25
+ __version__ = version("prefpicker")
26
+ except PackageNotFoundError: # pragma: no cover
27
+ # package is not installed
28
+ __version__ = "unknown"
29
+
30
+
31
+ class SourceDataError(Exception):
32
+ """This is raised when issues are found in the source data."""
33
+
34
+
35
+ PrefValue = bool | int | str | None
36
+ PrefVariant = dict[str, list[PrefValue]]
37
+
38
+
39
+ class PrefPicker: # pylint: disable=missing-docstring
40
+ __slots__ = ("prefs", "variants")
41
+
42
+ def __init__(self) -> None:
43
+ self.prefs: dict[str, dict[str, PrefVariant]] = {}
44
+ self.variants: set[str] = {"default"}
45
+
46
+ def check_combinations(self) -> Generator[tuple[str, int]]:
47
+ """Count the number of combinations for each variation. Only return
48
+ variants that have more than one combination.
49
+
50
+ Args:
51
+ None
52
+
53
+ Yields:
54
+ Variant and number of potential combinations.
55
+ """
56
+ combos = dict.fromkeys(self.variants, 1)
57
+ for variants in (x["variants"] for x in self.prefs.values()):
58
+ for variant in combos:
59
+ # use 'default' if pref does not have a matching variant entry
60
+ if variant not in variants:
61
+ combos[variant] *= len(variants["default"])
62
+ else:
63
+ combos[variant] *= len(variants[variant])
64
+ for variant, count in sorted(combos.items()):
65
+ if count > 1:
66
+ yield (variant, count)
67
+
68
+ def check_duplicates(self) -> Generator[tuple[str, str]]:
69
+ """Look for variants with values that appear more than once per variant.
70
+
71
+ Args:
72
+ None
73
+
74
+ Yields:
75
+ Pref name and the variant.
76
+ """
77
+ for pref, keys in sorted(self.prefs.items()):
78
+ variants = keys["variants"]
79
+ for variant in variants:
80
+ if len(variants[variant]) != len(set(variants[variant])):
81
+ yield (pref, variant)
82
+
83
+ def check_overwrites(self) -> Generator[tuple[str, str, PrefValue]]:
84
+ """Look for variants that overwrite the default with the same value.
85
+
86
+ Args:
87
+ None
88
+
89
+ Yields:
90
+ Pref, variant and value.
91
+ """
92
+ for pref, keys in sorted(self.prefs.items()):
93
+ variants = keys["variants"]
94
+ for variant in variants:
95
+ if variant == "default":
96
+ continue
97
+ for value in variants[variant]:
98
+ if value in variants["default"]:
99
+ yield (pref, variant, value)
100
+
101
+ def create_prefsjs(self, dest: Path, variant: str = "default") -> None:
102
+ """Write a `prefs.js` file based on the specified variant. The output file
103
+ will also include comments containing the variant and a timestamp.
104
+
105
+ Args:
106
+ dest: Path of file to create.
107
+ variant: Used to pick the values to output.
108
+
109
+ Returns:
110
+ None
111
+ """
112
+ with dest.open("w") as prefs_fp:
113
+ prefs_fp.write(f"// Generated with PrefPicker ({__version__}) @ ")
114
+ prefs_fp.write(datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z"))
115
+ prefs_fp.write(f"\n// Variant {variant!r}\n")
116
+ for pref, keys in sorted(self.prefs.items()):
117
+ variants = keys["variants"]
118
+ # choose values
119
+ if variant not in variants or variant == "default":
120
+ options = variants["default"]
121
+ default_variant = True
122
+ else:
123
+ options = variants[variant]
124
+ default_variant = False
125
+ value = choice(options)
126
+ if value is None:
127
+ if len(options) > 1:
128
+ prefs_fp.write(
129
+ f"// '{pref}' skipped, options {dumps(options)}\n"
130
+ )
131
+ # skipping pref
132
+ continue
133
+ if len(options) > 1:
134
+ prefs_fp.write(f"// '{pref}' options {dumps(options)}\n")
135
+ # sanitize value for writing
136
+ if isinstance(value, bool):
137
+ sanitized = "true" if value else "false"
138
+ elif isinstance(value, int):
139
+ sanitized = str(value)
140
+ elif isinstance(value, str):
141
+ sanitized = repr(value)
142
+ else:
143
+ prefs_fp.write(f"// Failed to sanitized {value!r} ({pref})\n")
144
+ raise SourceDataError(
145
+ f"Unsupported datatype {type(value).__name__!r}"
146
+ )
147
+ # write to prefs.js file
148
+ if not default_variant:
149
+ prefs_fp.write(f"// {pref!r} defined by variant {variant!r}\n")
150
+ prefs_fp.write(f'user_pref("{pref}", {sanitized});\n')
151
+
152
+ @classmethod
153
+ def lookup_template(cls, name: str) -> Path | None:
154
+ """Lookup built-in template Path.
155
+
156
+ Args:
157
+ name: Name of template.
158
+
159
+ Returns:
160
+ Template that matches 'name' or None.
161
+ """
162
+ path = Path(__file__).parent / "templates" / name
163
+ if path.is_file():
164
+ return path
165
+ return None
166
+
167
+ @classmethod
168
+ def load_template(cls, input_yml: Path) -> PrefPicker:
169
+ """Load data from a template YAML file.
170
+
171
+ Args:
172
+ input_yml: Input file.
173
+
174
+ Returns:
175
+ PrefPicker object.
176
+ """
177
+ try:
178
+ raw_prefs = safe_load(input_yml.read_bytes())
179
+ except (ScannerError, ParserError):
180
+ raise SourceDataError("invalid YAML") from None
181
+ cls.verify_data(raw_prefs)
182
+ picker = cls()
183
+ picker.variants = set(raw_prefs["variant"] + ["default"])
184
+ # only add relevant parts
185
+ for pref, parts in raw_prefs["pref"].items():
186
+ picker.prefs[pref] = {"variants": parts["variants"]}
187
+ return picker
188
+
189
+ @staticmethod
190
+ def templates() -> Generator[Path]:
191
+ """Available YAML template files.
192
+
193
+ Args:
194
+ None
195
+
196
+ Yields:
197
+ Template files.
198
+ """
199
+ path = Path(__file__).parent.resolve() / "templates"
200
+ if path.is_dir():
201
+ for template in path.iterdir():
202
+ if template.suffix.lower().endswith(".yml"):
203
+ yield template
204
+
205
+ @staticmethod
206
+ def verify_data(raw_data: Any) -> None:
207
+ """Perform strict sanity checks on raw_data. This exists to help prevent
208
+ the template file from breaking or becoming unmaintainable.
209
+
210
+ Args:
211
+ raw_data: Data to verify.
212
+
213
+ Returns:
214
+ None
215
+ """
216
+ if not isinstance(raw_data, dict):
217
+ raise SourceDataError("invalid template")
218
+ # check variant list
219
+ if "variant" not in raw_data:
220
+ raise SourceDataError("variant list is missing")
221
+ if not isinstance(raw_data["variant"], list):
222
+ raise SourceDataError("variant is not a list")
223
+ # check variant list entries
224
+ valid_variants = {"default"}
225
+ for variant in raw_data["variant"]:
226
+ if not isinstance(variant, str):
227
+ raise SourceDataError("variant definition must be a string")
228
+ valid_variants.add(variant)
229
+ # check prefs dict
230
+ if "pref" not in raw_data:
231
+ raise SourceDataError("pref group is missing")
232
+ if not isinstance(raw_data["pref"], dict):
233
+ raise SourceDataError("pref is not a dict")
234
+ # check entries in prefs dict
235
+ used_variants = set()
236
+ for pref, keys in raw_data["pref"].items():
237
+ if not isinstance(keys, dict):
238
+ raise SourceDataError(f"{pref!r} entry must contain a dict")
239
+ variants = keys.get("variants")
240
+ if not isinstance(variants, dict):
241
+ raise SourceDataError(f"{pref!r} is missing 'variants' dict")
242
+ if "default" not in variants:
243
+ raise SourceDataError(f"{pref!r} is missing 'default' variant")
244
+ # verify variants
245
+ for variant in variants:
246
+ if variant not in valid_variants:
247
+ raise SourceDataError(
248
+ f"{variant!r} in {pref!r} is not a defined variant"
249
+ )
250
+ if not isinstance(variants[variant], list):
251
+ raise SourceDataError(
252
+ f"variant {variant!r} in {pref!r} is not a list"
253
+ )
254
+ if not variants[variant]:
255
+ raise SourceDataError(f"{variant!r} in {pref!r} is empty")
256
+ for value in variants[variant]:
257
+ if value is not None and not isinstance(value, bool | int | str):
258
+ raise SourceDataError(
259
+ f"unsupported datatype {type(value).__name__!r} ({pref})"
260
+ )
261
+ used_variants.add(variant)
262
+ if valid_variants - used_variants:
263
+ raise SourceDataError(
264
+ f"Unused variants {' '.join(valid_variants - used_variants)!r}"
265
+ )
prefpicker/py.typed ADDED
File without changes