split-dex 0.1.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.
split_dex/registry.py ADDED
@@ -0,0 +1,214 @@
1
+ """Find and create strategies: built-in, from installed plugins, or from a user's file or module.
2
+
3
+ A strategy is named by one of:
4
+
5
+ - the name of a built-in strategy, listed in `STRATEGIES`;
6
+ - the name of a plugin: an installed package that declares the strategy as an entry point in
7
+ the group `split_dex.strategies`;
8
+ - `MODULE:CLASS`, for a class in a module that Python can import;
9
+ - `FILE.py:CLASS`, for a class in a Python file.
10
+
11
+ To add a built-in strategy, write its module in `split_dex/strategies/` and add its class to
12
+ `STRATEGIES`. The keyword-only parameters of a strategy's constructor are its settings: each
13
+ `KEY=VALUE` of the command line is converted to the parameter's type (int, float, bool, str or
14
+ Path, optionally `| None`).
15
+
16
+ A strategy from a plugin, module or file is ordinary Python code that runs inside split-dex, so it
17
+ must be trusted.
18
+ """
19
+
20
+ import collections.abc
21
+ import hashlib
22
+ import importlib.metadata
23
+ import importlib.util
24
+ import inspect
25
+ import pathlib
26
+ import sys
27
+ import types
28
+ import typing
29
+ from typing import Any
30
+
31
+ from split_dex import strategy
32
+ from split_dex.strategies import original
33
+ from split_dex.strategies import package_affinity
34
+ from split_dex.strategies import package_order
35
+ from split_dex.strategies import single
36
+
37
+ STRATEGIES: collections.abc.Mapping[str, collections.abc.Callable[..., strategy.Strategy]] = {
38
+ "package-affinity": package_affinity.PackageAffinity,
39
+ "package-order": package_order.PackageOrder,
40
+ "original": original.Original,
41
+ "single": single.Single,
42
+ }
43
+
44
+ PLUGIN_GROUP = "split_dex.strategies"
45
+
46
+ _SCALAR_TYPES = (int, float, str, pathlib.Path)
47
+ _TRUE = ("true", "yes", "1")
48
+ _FALSE = ("false", "no", "0")
49
+
50
+
51
+ def create(spec: str, settings: collections.abc.Mapping[str, str]) -> strategy.Strategy:
52
+ """Create a strategy from its name and settings given as text.
53
+
54
+ Args:
55
+ spec: The strategy: a built-in or plugin name such as `package-affinity`,
56
+ `MODULE:CLASS`, or `FILE.py:CLASS`.
57
+ settings: The value of each setting, as text, by parameter name. A dash in a name counts
58
+ as an underscore, so `max-files` sets `max_files`.
59
+
60
+ Returns:
61
+ The strategy.
62
+
63
+ Raises:
64
+ StrategyError: If the strategy cannot be found or loaded, a setting does not exist, a
65
+ value is invalid, or the created object has no `plan` method.
66
+ """
67
+ factory = _find(spec)
68
+ parameters = inspect.signature(factory).parameters
69
+ arguments: dict[str, Any] = {}
70
+ for key, text in settings.items():
71
+ parameter = parameters.get(key.replace("-", "_"))
72
+ if parameter is None:
73
+ known = ", ".join(parameters) if len(parameters) > 0 else "none"
74
+ raise strategy.StrategyError(
75
+ f"strategy {spec} has no setting {key!r}; its settings: {known}"
76
+ )
77
+ arguments[parameter.name] = _convert(spec, parameter, text)
78
+ try:
79
+ created = factory(**arguments)
80
+ except ValueError as error:
81
+ raise strategy.StrategyError(f"strategy {spec}: {error}") from error
82
+ if isinstance(created, strategy.Strategy) is False:
83
+ raise strategy.StrategyError(f"strategy {spec} has no plan method")
84
+ return created
85
+
86
+
87
+ def describe() -> list[str]:
88
+ """Describe every built-in and plugin strategy: its name, what it does, and its settings.
89
+
90
+ Returns:
91
+ The lines to print, one per strategy and one per setting.
92
+
93
+ Raises:
94
+ StrategyError: If a plugin cannot be loaded.
95
+ """
96
+ lines: list[str] = []
97
+ for name, factory in STRATEGIES.items():
98
+ lines.extend(_describe(name, factory))
99
+ for name, entry_point in sorted(_plugins().items()):
100
+ source = "" if entry_point.dist is None else f" (plugin from {entry_point.dist.name})"
101
+ lines.extend(_describe(f"{name}{source}", _load(entry_point, name)))
102
+ return lines
103
+
104
+
105
+ def _find(spec: str) -> collections.abc.Callable[..., strategy.Strategy]:
106
+ built_in = STRATEGIES.get(spec)
107
+ if built_in is not None:
108
+ return built_in
109
+ location, separator, attribute = spec.rpartition(":")
110
+ if separator == "":
111
+ plugin = _plugins().get(spec)
112
+ if plugin is None:
113
+ known = ", ".join([*STRATEGIES, *sorted(_plugins())])
114
+ raise strategy.StrategyError(
115
+ f"unknown strategy {spec!r}; known: {known}; or give MODULE:CLASS or FILE.py:CLASS"
116
+ )
117
+ return _load(plugin, spec)
118
+ if location == "" or attribute == "":
119
+ raise strategy.StrategyError(f"strategy {spec}: expected MODULE:CLASS or FILE.py:CLASS")
120
+ if location.endswith(".py"):
121
+ location = _import_file(pathlib.Path(location))
122
+ entry_point = importlib.metadata.EntryPoint(
123
+ name=spec, value=f"{location}:{attribute}", group=PLUGIN_GROUP
124
+ )
125
+ return _load(entry_point, spec)
126
+
127
+
128
+ def _plugins() -> dict[str, importlib.metadata.EntryPoint]:
129
+ """The strategies of installed plugins, by name. A built-in strategy hides a plugin's."""
130
+ return {
131
+ entry_point.name: entry_point
132
+ for entry_point in importlib.metadata.entry_points(group=PLUGIN_GROUP)
133
+ if entry_point.name not in STRATEGIES
134
+ }
135
+
136
+
137
+ def _load(
138
+ entry_point: importlib.metadata.EntryPoint, spec: str
139
+ ) -> collections.abc.Callable[..., strategy.Strategy]:
140
+ try:
141
+ loaded = entry_point.load()
142
+ except (ImportError, AttributeError) as error:
143
+ raise strategy.StrategyError(f"cannot load strategy {spec}: {error}") from error
144
+ if callable(loaded) is False:
145
+ raise strategy.StrategyError(f"strategy {spec} is not a class")
146
+ factory: collections.abc.Callable[..., strategy.Strategy] = loaded
147
+ return factory
148
+
149
+
150
+ def _import_file(path: pathlib.Path) -> str:
151
+ """Import a Python file as a module, once, and return the module's name."""
152
+ if path.is_file() is False:
153
+ raise strategy.StrategyError(f"strategy file not found: {path}")
154
+ digest = hashlib.sha256(str(path.resolve()).encode()).hexdigest()[:16]
155
+ module_name = f"split_dex_user_strategy_{digest}"
156
+ if module_name in sys.modules:
157
+ return module_name
158
+ module_spec = importlib.util.spec_from_file_location(module_name, path)
159
+ if module_spec is None or module_spec.loader is None:
160
+ raise strategy.StrategyError(f"cannot load strategy file {path}")
161
+ module = importlib.util.module_from_spec(module_spec)
162
+ sys.modules[module_name] = module
163
+ try:
164
+ module_spec.loader.exec_module(module)
165
+ except (OSError, SyntaxError, ImportError) as error:
166
+ del sys.modules[module_name]
167
+ raise strategy.StrategyError(f"cannot load strategy file {path}: {error}") from error
168
+ return module_name
169
+
170
+
171
+ def _describe(title: str, factory: collections.abc.Callable[..., strategy.Strategy]) -> list[str]:
172
+ summary = (inspect.getdoc(factory) or "").split("\n", 1)[0]
173
+ lines = [f"{title}: {summary}"]
174
+ for parameter in inspect.signature(factory).parameters.values():
175
+ lines.append(
176
+ f" {parameter.name}: {_type_name(parameter.annotation)} = {parameter.default!r}"
177
+ )
178
+ return lines
179
+
180
+
181
+ def _convert(spec: str, parameter: inspect.Parameter, text: str) -> object:
182
+ target = _value_type(parameter.annotation)
183
+ if target is bool:
184
+ if text.lower() in _TRUE:
185
+ return True
186
+ if text.lower() in _FALSE:
187
+ return False
188
+ elif target in _SCALAR_TYPES:
189
+ try:
190
+ return target(text)
191
+ except ValueError:
192
+ pass
193
+ else:
194
+ raise strategy.StrategyError(
195
+ f"strategy {spec}: setting {parameter.name} cannot be given on the command line"
196
+ )
197
+ raise strategy.StrategyError(
198
+ f"strategy {spec}: {parameter.name} must be {_type_name(target)}, not {text!r}"
199
+ )
200
+
201
+
202
+ def _value_type(annotation: Any) -> Any:
203
+ """The type a value converts to: `X` for both `X` and `X | None`."""
204
+ if isinstance(annotation, types.UnionType):
205
+ members = [member for member in typing.get_args(annotation) if member is not type(None)]
206
+ if len(members) == 1:
207
+ return members[0]
208
+ return annotation
209
+
210
+
211
+ def _type_name(annotation: Any) -> str:
212
+ if isinstance(annotation, type):
213
+ return annotation.__name__
214
+ return str(annotation)