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/__init__.py +5 -0
- split_dex/class_sets.py +135 -0
- split_dex/cli.py +227 -0
- split_dex/partition.py +229 -0
- split_dex/py.typed +0 -0
- split_dex/registry.py +214 -0
- split_dex/splitter.py +584 -0
- split_dex/strategies/__init__.py +1 -0
- split_dex/strategies/original.py +59 -0
- split_dex/strategies/package_affinity.py +40 -0
- split_dex/strategies/package_order.py +56 -0
- split_dex/strategies/single.py +23 -0
- split_dex/strategy.py +233 -0
- split_dex-0.1.0.dist-info/METADATA +253 -0
- split_dex-0.1.0.dist-info/RECORD +18 -0
- split_dex-0.1.0.dist-info/WHEEL +4 -0
- split_dex-0.1.0.dist-info/entry_points.txt +2 -0
- split_dex-0.1.0.dist-info/licenses/LICENSE +201 -0
split_dex/__init__.py
ADDED
split_dex/class_sets.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Sort the classes of an APK into named sets, by patterns on their names.
|
|
2
|
+
|
|
3
|
+
Each set is grouped by its own strategy, and two sets never share a DEX file.
|
|
4
|
+
|
|
5
|
+
Patterns match binary class names, such as `com.myapp.Main` or `com.myapp.Outer$Inner`. In a
|
|
6
|
+
pattern, `*` matches any characters except a dot, and `**` matches any characters, dots
|
|
7
|
+
included. So `com.myapp.**` matches every class under `com.myapp`, `com.myapp.*` matches the
|
|
8
|
+
classes directly in `com.myapp`, and `**` matches every class.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import collections.abc
|
|
12
|
+
import dataclasses
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
import split_dex
|
|
16
|
+
from split_dex import strategy
|
|
17
|
+
|
|
18
|
+
# How many unmatched classes an error message names.
|
|
19
|
+
_EXAMPLES = 3
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AssignmentError(split_dex.Error):
|
|
23
|
+
"""Raised when the classes cannot be sorted into the sets."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclasses.dataclass(frozen=True)
|
|
27
|
+
class ClassSet:
|
|
28
|
+
"""A named set of classes, and the strategy that groups them.
|
|
29
|
+
|
|
30
|
+
Attributes:
|
|
31
|
+
name: The set's name, written to sets.csv next to each of its DEX files.
|
|
32
|
+
strategy: How the classes of the set are grouped.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
name: str
|
|
36
|
+
strategy: strategy.Strategy
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclasses.dataclass(frozen=True)
|
|
40
|
+
class Assignment:
|
|
41
|
+
"""Sends the classes whose names match `pattern` to the set named `set_name`.
|
|
42
|
+
|
|
43
|
+
Attributes:
|
|
44
|
+
pattern: A pattern on binary class names, such as `com.myapp.**`.
|
|
45
|
+
set_name: The name of a set.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
pattern: str
|
|
49
|
+
set_name: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def binary_name(descriptor: str) -> str:
|
|
53
|
+
"""The binary name of a class: `Lcom/a/B$C;` gives `com.a.B$C`.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
descriptor: A class descriptor.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
The binary name.
|
|
60
|
+
"""
|
|
61
|
+
return descriptor[1:-1].replace("/", ".")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def assign(
|
|
65
|
+
descriptors: collections.abc.Sequence[str],
|
|
66
|
+
sets: collections.abc.Sequence[ClassSet],
|
|
67
|
+
assignments: collections.abc.Sequence[Assignment],
|
|
68
|
+
) -> dict[str, list[int]]:
|
|
69
|
+
"""Sort classes into sets.
|
|
70
|
+
|
|
71
|
+
Each class goes to the set of the first assignment whose pattern matches its name. With no
|
|
72
|
+
assignments and a single set, every class goes to that set.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
descriptors: The descriptor of every class, by class id.
|
|
76
|
+
sets: The sets.
|
|
77
|
+
assignments: The assignments, in the order they are tried.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
The ids of the classes of each set, in class id order, by set name. A set that no class
|
|
81
|
+
matches has an empty list.
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
AssignmentError: If two sets have the same name, an assignment names an unknown set,
|
|
85
|
+
there are several sets but no assignments, or a class matches no pattern.
|
|
86
|
+
"""
|
|
87
|
+
members: dict[str, list[int]] = {}
|
|
88
|
+
for class_set in sets:
|
|
89
|
+
if class_set.name in members:
|
|
90
|
+
raise AssignmentError(f"two sets are named {class_set.name!r}")
|
|
91
|
+
members[class_set.name] = []
|
|
92
|
+
if len(assignments) == 0:
|
|
93
|
+
if len(sets) != 1:
|
|
94
|
+
raise AssignmentError("with several sets, assignments must say which classes go where")
|
|
95
|
+
members[sets[0].name] = list(range(len(descriptors)))
|
|
96
|
+
return members
|
|
97
|
+
rules = [(_compile(rule.pattern), _members_of(members, rule.set_name)) for rule in assignments]
|
|
98
|
+
unmatched: list[str] = []
|
|
99
|
+
for class_id, descriptor in enumerate(descriptors):
|
|
100
|
+
name = binary_name(descriptor)
|
|
101
|
+
target = next((ids for pattern, ids in rules if pattern.fullmatch(name) is not None), None)
|
|
102
|
+
if target is None:
|
|
103
|
+
unmatched.append(name)
|
|
104
|
+
else:
|
|
105
|
+
target.append(class_id)
|
|
106
|
+
if len(unmatched) > 0:
|
|
107
|
+
raise AssignmentError(
|
|
108
|
+
f"{len(unmatched)} classes match no pattern, such as "
|
|
109
|
+
f"{', '.join(unmatched[:_EXAMPLES])}; add a last pattern such as '**' to catch them"
|
|
110
|
+
)
|
|
111
|
+
return members
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _members_of(members: dict[str, list[int]], set_name: str) -> list[int]:
|
|
115
|
+
ids = members.get(set_name)
|
|
116
|
+
if ids is None:
|
|
117
|
+
raise AssignmentError(f"no set is named {set_name!r}")
|
|
118
|
+
return ids
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _compile(pattern: str) -> re.Pattern[str]:
|
|
122
|
+
"""The regular expression of a pattern: `**` is any text, `*` any text without a dot."""
|
|
123
|
+
parts: list[str] = []
|
|
124
|
+
index = 0
|
|
125
|
+
while index < len(pattern):
|
|
126
|
+
if pattern.startswith("**", index):
|
|
127
|
+
parts.append(".*")
|
|
128
|
+
index += 2
|
|
129
|
+
elif pattern[index] == "*":
|
|
130
|
+
parts.append("[^.]*")
|
|
131
|
+
index += 1
|
|
132
|
+
else:
|
|
133
|
+
parts.append(re.escape(pattern[index]))
|
|
134
|
+
index += 1
|
|
135
|
+
return re.compile("".join(parts))
|
split_dex/cli.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""The split-dex command line."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import collections.abc
|
|
5
|
+
import importlib.metadata
|
|
6
|
+
import pathlib
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import split_dex
|
|
12
|
+
from split_dex import class_sets
|
|
13
|
+
from split_dex import registry
|
|
14
|
+
from split_dex import splitter
|
|
15
|
+
from split_dex import strategy
|
|
16
|
+
|
|
17
|
+
_EPILOG = """\
|
|
18
|
+
sets:
|
|
19
|
+
Each class goes to the set of the first --assign pattern that matches its
|
|
20
|
+
name, and each set is split by its own strategy. Two sets never share a DEX
|
|
21
|
+
file. Without --set, every class is in one set, "all", split by
|
|
22
|
+
package-affinity. Patterns match class names such as com.myapp.Main: "*"
|
|
23
|
+
matches any characters except a dot, "**" any characters. "@FILE" reads
|
|
24
|
+
patterns from a file, one per line.
|
|
25
|
+
|
|
26
|
+
strategies:
|
|
27
|
+
STRATEGY is a built-in or plugin name (see --list-strategies), or your own
|
|
28
|
+
strategy class as MODULE:CLASS or FILE.py:CLASS. Its settings are the
|
|
29
|
+
keyword parameters of the class's constructor.
|
|
30
|
+
|
|
31
|
+
examples:
|
|
32
|
+
split-dex --apk app.apk --out out/
|
|
33
|
+
split-dex --apk app.apk --out out/ --set all=package-affinity,cap=500
|
|
34
|
+
split-dex --apk app.apk --out out/ \\
|
|
35
|
+
--set app=package-affinity,cap=500 --set libraries=single \\
|
|
36
|
+
--assign 'com.myapp.**=app' --assign '**=libraries'
|
|
37
|
+
split-dex --apk app.apk --out out/ --set all=./my_strategy.py:MyStrategy,k=8
|
|
38
|
+
|
|
39
|
+
output in DIR:
|
|
40
|
+
<k>.dex the grouped DEX files: 0.dex, 1.dex, ..., set by set
|
|
41
|
+
index.csv class,file: every class and the DEX file it is in
|
|
42
|
+
sets.csv file,set: the set of each DEX file
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
_BYTES_PER_MB = 1048576
|
|
46
|
+
|
|
47
|
+
# A set name is written to sets.csv, so it cannot hold a comma or a line break.
|
|
48
|
+
_SET_NAME = re.compile(r"[A-Za-z0-9_.-]+")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _UsageError(split_dex.Error):
|
|
52
|
+
"""Raised when an option's value is malformed."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main(argv: collections.abc.Sequence[str] | None = None) -> int:
|
|
56
|
+
"""Run split-dex with command-line arguments.
|
|
57
|
+
|
|
58
|
+
Prints a summary of the split on stdout, and a warning on stderr for each block, such as a
|
|
59
|
+
package, that was too big for one DEX file.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
argv: The arguments, without the program name. None reads them from `sys.argv`.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
The exit status: 0 when the split succeeds, 1 when it fails. Bad arguments exit with
|
|
66
|
+
status 2 instead, through argparse's SystemExit.
|
|
67
|
+
"""
|
|
68
|
+
parser = _parser()
|
|
69
|
+
arguments = parser.parse_args(argv)
|
|
70
|
+
try:
|
|
71
|
+
options = _options(arguments)
|
|
72
|
+
except (_UsageError, strategy.StrategyError) as error:
|
|
73
|
+
parser.error(str(error))
|
|
74
|
+
try:
|
|
75
|
+
result = splitter.split(options)
|
|
76
|
+
except split_dex.Error as error:
|
|
77
|
+
print(f"split-dex: {error}", file=sys.stderr)
|
|
78
|
+
return 1
|
|
79
|
+
_print_summary(result)
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class _ListStrategies(argparse.Action):
|
|
84
|
+
"""Prints the built-in and plugin strategies and exits, like --help."""
|
|
85
|
+
|
|
86
|
+
def __call__(
|
|
87
|
+
self,
|
|
88
|
+
parser: argparse.ArgumentParser,
|
|
89
|
+
namespace: argparse.Namespace,
|
|
90
|
+
values: Any,
|
|
91
|
+
option_string: str | None = None,
|
|
92
|
+
) -> None:
|
|
93
|
+
try:
|
|
94
|
+
lines = registry.describe()
|
|
95
|
+
except strategy.StrategyError as error:
|
|
96
|
+
parser.exit(1, f"split-dex: {error}\n")
|
|
97
|
+
print("\n".join(lines))
|
|
98
|
+
parser.exit()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _parser() -> argparse.ArgumentParser:
|
|
102
|
+
version = importlib.metadata.version("split-dex")
|
|
103
|
+
parser = argparse.ArgumentParser(
|
|
104
|
+
prog="split-dex",
|
|
105
|
+
description=f"split-dex {version}: split an APK's classes into grouped DEX files.",
|
|
106
|
+
epilog=_EPILOG,
|
|
107
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
108
|
+
)
|
|
109
|
+
required = parser.add_argument_group("required arguments")
|
|
110
|
+
required.add_argument(
|
|
111
|
+
"--apk",
|
|
112
|
+
required=True,
|
|
113
|
+
type=_non_empty,
|
|
114
|
+
metavar="FILE",
|
|
115
|
+
help="input APK; all its classes*.dex entries are read",
|
|
116
|
+
)
|
|
117
|
+
required.add_argument(
|
|
118
|
+
"--out", required=True, type=_non_empty, metavar="DIR", help="output directory"
|
|
119
|
+
)
|
|
120
|
+
parser.add_argument(
|
|
121
|
+
"--set",
|
|
122
|
+
action="append",
|
|
123
|
+
default=[],
|
|
124
|
+
dest="sets",
|
|
125
|
+
metavar="NAME=STRATEGY[,KEY=VALUE...]",
|
|
126
|
+
help="a set of classes and the strategy that splits it, with the strategy's settings; "
|
|
127
|
+
"repeat for more sets (DEX files are written in this order)",
|
|
128
|
+
)
|
|
129
|
+
parser.add_argument(
|
|
130
|
+
"--assign",
|
|
131
|
+
action="append",
|
|
132
|
+
default=[],
|
|
133
|
+
dest="assignments",
|
|
134
|
+
metavar="PATTERN[,PATTERN...]=NAME",
|
|
135
|
+
help="send the classes whose names match a pattern to the set NAME; repeat, the first "
|
|
136
|
+
"match wins",
|
|
137
|
+
)
|
|
138
|
+
parser.add_argument(
|
|
139
|
+
"--list-strategies",
|
|
140
|
+
action=_ListStrategies,
|
|
141
|
+
nargs=0,
|
|
142
|
+
help="show the strategies and their settings, and exit",
|
|
143
|
+
)
|
|
144
|
+
return parser
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _non_empty(value: str) -> str:
|
|
148
|
+
if value == "":
|
|
149
|
+
raise argparse.ArgumentTypeError("must not be empty")
|
|
150
|
+
return value
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _options(arguments: argparse.Namespace) -> splitter.Options:
|
|
154
|
+
sets = [_parse_set(text) for text in arguments.sets]
|
|
155
|
+
if len(sets) == 0:
|
|
156
|
+
sets = list(splitter.default_sets())
|
|
157
|
+
names = [class_set.name for class_set in sets]
|
|
158
|
+
if len(set(names)) < len(names):
|
|
159
|
+
raise _UsageError("two --set options have the same name")
|
|
160
|
+
assignments = [rule for text in arguments.assignments for rule in _parse_assignment(text)]
|
|
161
|
+
unknown = sorted({rule.set_name for rule in assignments} - set(names))
|
|
162
|
+
if len(unknown) > 0:
|
|
163
|
+
raise _UsageError(f"--assign names a set that no --set declares: {', '.join(unknown)}")
|
|
164
|
+
if len(sets) > 1 and len(assignments) == 0:
|
|
165
|
+
raise _UsageError("with several --set options, --assign must say which classes go where")
|
|
166
|
+
return splitter.Options(
|
|
167
|
+
apk_path=pathlib.Path(arguments.apk),
|
|
168
|
+
out_dir=pathlib.Path(arguments.out),
|
|
169
|
+
sets=sets,
|
|
170
|
+
assignments=assignments,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _parse_set(text: str) -> class_sets.ClassSet:
|
|
175
|
+
"""A set from `NAME=STRATEGY[,KEY=VALUE...]`."""
|
|
176
|
+
name, separator, spec = text.partition("=")
|
|
177
|
+
if separator == "" or spec == "":
|
|
178
|
+
raise _UsageError(f"--set {text}: expected NAME=STRATEGY[,KEY=VALUE...]")
|
|
179
|
+
if _SET_NAME.fullmatch(name) is None:
|
|
180
|
+
raise _UsageError(f"--set {text}: a set name uses only letters, digits, '_', '.' and '-'")
|
|
181
|
+
strategy_name, *setting_texts = spec.split(",")
|
|
182
|
+
settings: dict[str, str] = {}
|
|
183
|
+
for setting in setting_texts:
|
|
184
|
+
key, separator, value = setting.partition("=")
|
|
185
|
+
if separator == "" or key == "":
|
|
186
|
+
raise _UsageError(f"--set {text}: expected KEY=VALUE, not {setting!r}")
|
|
187
|
+
settings[key] = value
|
|
188
|
+
return class_sets.ClassSet(name, registry.create(strategy_name, settings))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _parse_assignment(text: str) -> list[class_sets.Assignment]:
|
|
192
|
+
"""Assignments from `PATTERN[,PATTERN...]=NAME`, where `@FILE` reads patterns from a file."""
|
|
193
|
+
patterns_text, separator, set_name = text.rpartition("=")
|
|
194
|
+
if separator == "" or patterns_text == "" or set_name == "":
|
|
195
|
+
raise _UsageError(f"--assign {text}: expected PATTERN[,PATTERN...]=NAME")
|
|
196
|
+
patterns: list[str] = []
|
|
197
|
+
for pattern in patterns_text.split(","):
|
|
198
|
+
if pattern.startswith("@"):
|
|
199
|
+
patterns.extend(_read_patterns(pathlib.Path(pattern[1:])))
|
|
200
|
+
else:
|
|
201
|
+
patterns.append(pattern)
|
|
202
|
+
return [class_sets.Assignment(pattern, set_name) for pattern in patterns]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _read_patterns(path: pathlib.Path) -> list[str]:
|
|
206
|
+
"""The patterns of a file: one per line. Blank lines and lines starting with '#' are skipped."""
|
|
207
|
+
try:
|
|
208
|
+
lines = path.read_bytes().decode("utf-8").split("\n")
|
|
209
|
+
except (OSError, UnicodeDecodeError) as error:
|
|
210
|
+
raise _UsageError(f"cannot read patterns from {path}: {error}") from error
|
|
211
|
+
patterns = [line.strip() for line in lines]
|
|
212
|
+
return [pattern for pattern in patterns if pattern != "" and pattern.startswith("#") is False]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _print_summary(result: splitter.Result) -> None:
|
|
216
|
+
print(f"total_classes={result.total_classes} duplicate_classes={result.duplicate_classes}")
|
|
217
|
+
print(
|
|
218
|
+
f"total_dex={result.dex_files} overflow_splits={result.overflow_splits} "
|
|
219
|
+
f"total_mb={result.total_bytes / _BYTES_PER_MB:.1f}"
|
|
220
|
+
)
|
|
221
|
+
for set_result in result.sets:
|
|
222
|
+
print(f"set={set_result.name} classes={set_result.classes} dex={set_result.dex_files}")
|
|
223
|
+
for block in result.split_blocks:
|
|
224
|
+
print(
|
|
225
|
+
f"split-dex: warning: {block} is too big for one DEX file and was split across files",
|
|
226
|
+
file=sys.stderr,
|
|
227
|
+
)
|
split_dex/partition.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Group classes by how they are linked, without splitting a Java package.
|
|
2
|
+
|
|
3
|
+
A class usually loads the classes it is linked to, so linked classes should land in the same DEX
|
|
4
|
+
file. A package is never split across two groups: when each DEX file is served by its own class
|
|
5
|
+
loader, two classes of one package in different loaders cannot use each other's package-private
|
|
6
|
+
members, and ART throws IllegalAccessError. Keeping each package whole keeps that access inside
|
|
7
|
+
one class loader.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import collections.abc
|
|
11
|
+
import dataclasses
|
|
12
|
+
import heapq
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AffinityGraph:
|
|
16
|
+
"""An undirected, weighted graph of the links between classes.
|
|
17
|
+
|
|
18
|
+
Each class is a dense id in `range(size)`. The weight of the edge between two classes counts
|
|
19
|
+
the links between them. The graph is symmetric: an edge is stored on both of its ends.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, size: int) -> None:
|
|
23
|
+
"""Create a graph of `size` classes with no edges.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
size: The number of classes.
|
|
27
|
+
"""
|
|
28
|
+
self._neighbours: list[dict[int, int]] = [{} for _ in range(size)]
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def size(self) -> int:
|
|
32
|
+
"""The number of classes in the graph."""
|
|
33
|
+
return len(self._neighbours)
|
|
34
|
+
|
|
35
|
+
def neighbours(self, class_id: int) -> collections.abc.Mapping[int, int]:
|
|
36
|
+
"""The classes linked to a class.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
class_id: The class.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
A mapping from each linked class to the weight of its edge.
|
|
43
|
+
"""
|
|
44
|
+
return self._neighbours[class_id]
|
|
45
|
+
|
|
46
|
+
def add_edge(self, first: int, second: int, weight: int = 1) -> None:
|
|
47
|
+
"""Add `weight` to the edge between two classes, on both of its ends.
|
|
48
|
+
|
|
49
|
+
An edge from a class to itself is ignored.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
first: One end of the edge.
|
|
53
|
+
second: The other end of the edge.
|
|
54
|
+
weight: How much to add to the weight of the edge.
|
|
55
|
+
"""
|
|
56
|
+
if first == second:
|
|
57
|
+
return
|
|
58
|
+
self._neighbours[first][second] = self._neighbours[first].get(second, 0) + weight
|
|
59
|
+
self._neighbours[second][first] = self._neighbours[second].get(first, 0) + weight
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def package_of(descriptor: str) -> str:
|
|
63
|
+
"""The package of a class descriptor: the descriptor up to its last '/'.
|
|
64
|
+
|
|
65
|
+
For example `Lcom/a/B;` gives `Lcom/a`. A class in the unnamed package, such as `LFoo;`,
|
|
66
|
+
gives `L`.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
descriptor: A class descriptor.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
The package, in descriptor form.
|
|
73
|
+
"""
|
|
74
|
+
slash = descriptor.rfind("/")
|
|
75
|
+
if slash == -1:
|
|
76
|
+
return "L"
|
|
77
|
+
return descriptor[:slash]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def partition_by_package_affinity(
|
|
81
|
+
members: collections.abc.Sequence[int],
|
|
82
|
+
graph: AffinityGraph,
|
|
83
|
+
descriptors: collections.abc.Sequence[str],
|
|
84
|
+
cap: int,
|
|
85
|
+
) -> list[list[int]]:
|
|
86
|
+
"""Partition classes into groups of about `cap` classes, keeping each package whole.
|
|
87
|
+
|
|
88
|
+
The steps:
|
|
89
|
+
|
|
90
|
+
1. Group the members by package. A package is the unit that moves; it is never split.
|
|
91
|
+
2. Add up the edges between classes into edges between packages.
|
|
92
|
+
3. Put the packages in order so that linked packages are next to each other. The order
|
|
93
|
+
starts from the package linked to the most other packages. Each next package is the one
|
|
94
|
+
with the heaviest edge to the packages already placed.
|
|
95
|
+
4. Fill groups of about `cap` classes in that order, each package whole. A package of `cap`
|
|
96
|
+
classes or more ends the group it is in, so a package bigger than `cap` gets a group of
|
|
97
|
+
its own.
|
|
98
|
+
|
|
99
|
+
Packages with no links come last and are packed together, so no group is left with a single
|
|
100
|
+
stray class. The result is deterministic: ties are broken by package ids, which follow the
|
|
101
|
+
order in which `members` first shows each package, never by hash order.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
members: The ids of the classes to partition, a subset of the classes of `graph`.
|
|
105
|
+
graph: The links between all classes. Only edges between two members are used.
|
|
106
|
+
descriptors: The descriptor of every class, indexed by class id.
|
|
107
|
+
cap: The target number of classes per group. A value below 1 counts as 1.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
The groups, each a list of class ids. No group is empty.
|
|
111
|
+
"""
|
|
112
|
+
packages = _group_by_package(members, descriptors)
|
|
113
|
+
edges = _package_edges(members, graph, packages)
|
|
114
|
+
order = _order_packages(edges)
|
|
115
|
+
return fill_groups((packages.classes[package] for package in order), cap)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def fill_groups(
|
|
119
|
+
units: collections.abc.Iterable[collections.abc.Sequence[int]], cap: int
|
|
120
|
+
) -> list[list[int]]:
|
|
121
|
+
"""Fill groups of about `cap` classes with whole units, such as packages, in order.
|
|
122
|
+
|
|
123
|
+
A unit that would push the current group over `cap` starts a new group, and a group that
|
|
124
|
+
reaches `cap` is closed. So a unit of `cap` classes or more gets a group of its own.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
units: The units, each a sequence of class ids, in the order they are filled in.
|
|
128
|
+
cap: The target number of classes per group. A value below 1 counts as 1.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
The groups, each a list of class ids. No group is empty.
|
|
132
|
+
"""
|
|
133
|
+
cap = max(cap, 1)
|
|
134
|
+
groups: list[list[int]] = []
|
|
135
|
+
current: list[int] = []
|
|
136
|
+
for unit in units:
|
|
137
|
+
if len(current) > 0 and len(current) + len(unit) > cap:
|
|
138
|
+
groups.append(current)
|
|
139
|
+
current = []
|
|
140
|
+
current.extend(unit)
|
|
141
|
+
if len(current) >= cap:
|
|
142
|
+
groups.append(current)
|
|
143
|
+
current = []
|
|
144
|
+
if len(current) > 0:
|
|
145
|
+
groups.append(current)
|
|
146
|
+
return groups
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclasses.dataclass(frozen=True)
|
|
150
|
+
class _Packages:
|
|
151
|
+
"""The packages of a set of classes. Package ids count from 0, in order of appearance.
|
|
152
|
+
|
|
153
|
+
Attributes:
|
|
154
|
+
classes: The classes of each package, indexed by package id.
|
|
155
|
+
package_of_class: The package id of each class.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
classes: list[list[int]]
|
|
159
|
+
package_of_class: dict[int, int]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _group_by_package(
|
|
163
|
+
members: collections.abc.Sequence[int], descriptors: collections.abc.Sequence[str]
|
|
164
|
+
) -> _Packages:
|
|
165
|
+
package_ids: dict[str, int] = {}
|
|
166
|
+
classes: list[list[int]] = []
|
|
167
|
+
package_of_class: dict[int, int] = {}
|
|
168
|
+
for class_id in members:
|
|
169
|
+
package_id = package_ids.setdefault(package_of(descriptors[class_id]), len(classes))
|
|
170
|
+
if package_id == len(classes):
|
|
171
|
+
classes.append([])
|
|
172
|
+
classes[package_id].append(class_id)
|
|
173
|
+
package_of_class[class_id] = package_id
|
|
174
|
+
return _Packages(classes, package_of_class)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _package_edges(
|
|
178
|
+
members: collections.abc.Sequence[int], graph: AffinityGraph, packages: _Packages
|
|
179
|
+
) -> list[dict[int, int]]:
|
|
180
|
+
"""Add up the edges between classes of two different packages into package edges."""
|
|
181
|
+
edges: list[dict[int, int]] = [{} for _ in packages.classes]
|
|
182
|
+
for class_id in members:
|
|
183
|
+
package = packages.package_of_class[class_id]
|
|
184
|
+
for neighbour, weight in graph.neighbours(class_id).items():
|
|
185
|
+
neighbour_package = packages.package_of_class.get(neighbour)
|
|
186
|
+
if neighbour_package is None or neighbour_package == package:
|
|
187
|
+
continue
|
|
188
|
+
edges[package][neighbour_package] = edges[package].get(neighbour_package, 0) + weight
|
|
189
|
+
return edges
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _order_packages(
|
|
193
|
+
edges: collections.abc.Sequence[collections.abc.Mapping[int, int]],
|
|
194
|
+
) -> list[int]:
|
|
195
|
+
"""Order the packages so that linked packages are next to each other.
|
|
196
|
+
|
|
197
|
+
Walks start from the packages with the most links (ties: the lower id first). Each walk is
|
|
198
|
+
best-first, like Prim's algorithm: it takes the package with the heaviest edge to the
|
|
199
|
+
packages already placed.
|
|
200
|
+
"""
|
|
201
|
+
seeds = sorted(range(len(edges)), key=lambda package: (-len(edges[package]), package))
|
|
202
|
+
placed = [False] * len(edges)
|
|
203
|
+
order: list[int] = []
|
|
204
|
+
for seed in seeds:
|
|
205
|
+
if placed[seed] is False:
|
|
206
|
+
_walk_from(seed, edges, placed, order)
|
|
207
|
+
return order
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _walk_from(
|
|
211
|
+
seed: int,
|
|
212
|
+
edges: collections.abc.Sequence[collections.abc.Mapping[int, int]],
|
|
213
|
+
placed: list[bool],
|
|
214
|
+
order: list[int],
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Place every package reachable from `seed`, heaviest edge first, and append it to `order`."""
|
|
217
|
+
# A max-heap of (edge weight, package id), kept as a min-heap of the negated values: the
|
|
218
|
+
# heaviest edge comes out first, and of equal weights the higher package id.
|
|
219
|
+
heap = [(0, -seed)]
|
|
220
|
+
while len(heap) > 0:
|
|
221
|
+
_, negated_package = heapq.heappop(heap)
|
|
222
|
+
package = -negated_package
|
|
223
|
+
if placed[package] is True:
|
|
224
|
+
continue
|
|
225
|
+
placed[package] = True
|
|
226
|
+
order.append(package)
|
|
227
|
+
for neighbour, weight in edges[package].items():
|
|
228
|
+
if placed[neighbour] is False:
|
|
229
|
+
heapq.heappush(heap, (-weight, -neighbour))
|
split_dex/py.typed
ADDED
|
File without changes
|