remap-badblocks 0.8__py3-none-any.whl → 0.9__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.
- remap_badblocks/__init__.py +1 -1
- remap_badblocks/cli/commands/update.py +8 -6
- remap_badblocks/src/badblocks/_remap_badblocks.py +23 -23
- remap_badblocks/src/mapping.py +5 -1
- remap_badblocks-0.9.dist-info/METADATA +107 -0
- remap_badblocks-0.9.dist-info/RECORD +36 -0
- {remap_badblocks-0.8.dist-info → remap_badblocks-0.9.dist-info}/WHEEL +1 -1
- __init__.py +0 -0
- cli/__init__.py +0 -0
- cli/__main__.py +0 -239
- cli/commands/__init__.py +0 -8
- cli/commands/add.py +0 -91
- cli/commands/apply.py +0 -81
- cli/commands/get.py +0 -28
- cli/commands/remove.py +0 -45
- cli/commands/update.py +0 -208
- cli/commands/version.py +0 -15
- remap_badblocks-0.8.dist-info/METADATA +0 -130
- remap_badblocks-0.8.dist-info/RECORD +0 -66
- src/badblocks/_compute_good_ranges.py +0 -43
- src/badblocks/_find_badblocks.py +0 -76
- src/badblocks/_mapping_generation.py +0 -12
- src/badblocks/_remap_badblocks.py +0 -114
- src/badblocks/badblocks.py +0 -40
- src/devices/__init__.py +0 -0
- src/devices/device_config.py +0 -62
- src/devices/devices_config.py +0 -300
- src/devices/exceptions.py +0 -22
- src/devices_config_constants.py +0 -3
- src/mapping.py +0 -109
- src/remappers/_check_applied_devices.py +0 -10
- src/remappers/_generate_dm_table.py +0 -27
- src/test_utils.py +0 -18
- src/utils/__init__.py +0 -0
- src/utils/_get_device_info.py +0 -43
- src/utils/_iterable_bytes_converter.py +0 -19
- src/utils/_parse_inputs.py +0 -84
- src/utils/_run_command.py +0 -76
- src/utils/_sort_devices.py +0 -26
- {remap_badblocks-0.8.dist-info → remap_badblocks-0.9.dist-info}/entry_points.txt +0 -0
- {remap_badblocks-0.8.dist-info → remap_badblocks-0.9.dist-info}/licenses/LICENSE +0 -0
- {remap_badblocks-0.8.dist-info → remap_badblocks-0.9.dist-info}/top_level.txt +0 -0
src/mapping.py
DELETED
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
import sqlite3
|
|
2
|
-
from dataclasses import dataclass
|
|
3
|
-
from typing import Collection, Iterator
|
|
4
|
-
|
|
5
|
-
from remap_badblocks.src.utils._iterable_bytes_converter import (
|
|
6
|
-
iterable_from_bytes, iterable_to_bytes)
|
|
7
|
-
|
|
8
|
-
DEFAULT_INT_LENGTH = 8
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
@dataclass
|
|
12
|
-
class MappingElement:
|
|
13
|
-
start_id_virtual: int
|
|
14
|
-
start_id_real: int
|
|
15
|
-
length: int
|
|
16
|
-
|
|
17
|
-
INT_LENGTH: int = DEFAULT_INT_LENGTH
|
|
18
|
-
BYTES_LENGTH: int = 3 * INT_LENGTH
|
|
19
|
-
|
|
20
|
-
def __bytes__(self) -> bytes:
|
|
21
|
-
"""
|
|
22
|
-
Convert the MappingElement to bytes for storage.
|
|
23
|
-
"""
|
|
24
|
-
return iterable_to_bytes(
|
|
25
|
-
(
|
|
26
|
-
self.start_id_virtual,
|
|
27
|
-
self.start_id_real,
|
|
28
|
-
self.length,
|
|
29
|
-
),
|
|
30
|
-
length=self.INT_LENGTH,
|
|
31
|
-
)
|
|
32
|
-
|
|
33
|
-
def __index__(self) -> int:
|
|
34
|
-
"""
|
|
35
|
-
Convert the MappingElement to an integer for storage.
|
|
36
|
-
"""
|
|
37
|
-
return int.from_bytes(self.__bytes__(), "big")
|
|
38
|
-
|
|
39
|
-
@classmethod
|
|
40
|
-
def from_bytes(cls, data: bytes) -> "MappingElement":
|
|
41
|
-
"""
|
|
42
|
-
Create a MappingElement from bytes.
|
|
43
|
-
"""
|
|
44
|
-
if len(data) != cls.BYTES_LENGTH:
|
|
45
|
-
raise ValueError(f"Data must be exactly {cls.BYTES_LENGTH} bytes long.")
|
|
46
|
-
start_id_virtual, start_id_real, length = iterable_from_bytes(
|
|
47
|
-
data, length=cls.INT_LENGTH
|
|
48
|
-
)
|
|
49
|
-
return cls(start_id_virtual, start_id_real, length)
|
|
50
|
-
|
|
51
|
-
def __hash__(self) -> int:
|
|
52
|
-
"""
|
|
53
|
-
Hash the MappingElement for use in sets or dictionaries.
|
|
54
|
-
"""
|
|
55
|
-
return self.__index__()
|
|
56
|
-
|
|
57
|
-
@classmethod
|
|
58
|
-
def from_tuple(cls, _tuple: tuple[int, int, int]) -> "MappingElement":
|
|
59
|
-
"""
|
|
60
|
-
Create a MappingElement from a tuple.
|
|
61
|
-
"""
|
|
62
|
-
if len(_tuple) != 3:
|
|
63
|
-
raise ValueError("Tuple must contain exactly three elements.")
|
|
64
|
-
return cls(
|
|
65
|
-
start_id_virtual=_tuple[0], start_id_real=_tuple[1], length=_tuple[2]
|
|
66
|
-
)
|
|
67
|
-
|
|
68
|
-
def to_tuple(self) -> tuple[int, int, int]:
|
|
69
|
-
return self.start_id_virtual, self.start_id_real, self.length
|
|
70
|
-
|
|
71
|
-
def __iter__(self) -> Iterator[int]:
|
|
72
|
-
return iter(self.to_tuple())
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
@dataclass
|
|
76
|
-
class Mapping:
|
|
77
|
-
elements: Collection[MappingElement]
|
|
78
|
-
|
|
79
|
-
def __bytes__(self) -> bytes:
|
|
80
|
-
"""
|
|
81
|
-
Convert the Mapping to bytes for storage.
|
|
82
|
-
"""
|
|
83
|
-
return b"".join(bytes(element) for element in self.elements)
|
|
84
|
-
|
|
85
|
-
@classmethod
|
|
86
|
-
def from_bytes(cls, data: bytes) -> "Mapping":
|
|
87
|
-
"""
|
|
88
|
-
Create a Mapping from bytes.
|
|
89
|
-
"""
|
|
90
|
-
if len(data) % MappingElement.BYTES_LENGTH != 0:
|
|
91
|
-
raise ValueError(
|
|
92
|
-
"Data length is not a multiple of {} bytes.",
|
|
93
|
-
MappingElement.BYTES_LENGTH,
|
|
94
|
-
)
|
|
95
|
-
elements: list[MappingElement] = []
|
|
96
|
-
for i in range(0, len(data), MappingElement.BYTES_LENGTH):
|
|
97
|
-
elements.append(
|
|
98
|
-
MappingElement.from_bytes(data[i : i + MappingElement.BYTES_LENGTH])
|
|
99
|
-
)
|
|
100
|
-
return cls(elements)
|
|
101
|
-
|
|
102
|
-
def to_sql_binary(self) -> sqlite3.Binary:
|
|
103
|
-
"""
|
|
104
|
-
Convert the Mapping to a binary format suitable for SQLite storage.
|
|
105
|
-
"""
|
|
106
|
-
return sqlite3.Binary(bytes(self))
|
|
107
|
-
|
|
108
|
-
def __iter__(self) -> Iterator[MappingElement]:
|
|
109
|
-
return iter(self.elements)
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
from typing import Iterable
|
|
2
|
-
|
|
3
|
-
from remap_badblocks.src.devices.device_config import DeviceConfig
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def filter_applied_devices(devices: Iterable[DeviceConfig]):
|
|
7
|
-
def check_applied(device: DeviceConfig) -> bool:
|
|
8
|
-
return device.get_applied_path().is_block_device()
|
|
9
|
-
|
|
10
|
-
return filter(check_applied, devices)
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
from pathlib import Path
|
|
2
|
-
from typing import Generator, Iterable, TypeVar, Union
|
|
3
|
-
|
|
4
|
-
from remap_badblocks.src.devices.devices_config import Mapping
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
def generate_dm_table(
|
|
8
|
-
device: Union[Path, str],
|
|
9
|
-
mapping: Union[Iterable[tuple[int, int, int]], Mapping],
|
|
10
|
-
block_size: int,
|
|
11
|
-
) -> Generator[str, None, None]:
|
|
12
|
-
"""Generate a device-mapper linear mapping table from good ranges."""
|
|
13
|
-
block_size_multiplier = block_size / 512
|
|
14
|
-
assert block_size_multiplier.is_integer(), "Block size must be a multiple of 512"
|
|
15
|
-
block_size_multiplier = int(block_size_multiplier)
|
|
16
|
-
|
|
17
|
-
T = TypeVar("T")
|
|
18
|
-
|
|
19
|
-
def get_first(iterable: Iterable[T]) -> T:
|
|
20
|
-
return next(iter(iterable))
|
|
21
|
-
|
|
22
|
-
for start_virtual, start_real, length in sorted(mapping, key=get_first):
|
|
23
|
-
start_virtual *= block_size_multiplier
|
|
24
|
-
start_real *= block_size_multiplier
|
|
25
|
-
length *= block_size_multiplier
|
|
26
|
-
|
|
27
|
-
yield f"{start_virtual} {length} linear {device} {start_real}"
|
src/test_utils.py
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
from random import shuffle
|
|
2
|
-
from typing import Generator, Iterable, TypeVar
|
|
3
|
-
|
|
4
|
-
T = TypeVar("T")
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
def iter_to_shuffled_generator(_iter: Iterable[T]) -> Generator[T, None, None]:
|
|
8
|
-
_iter_list = list(_iter)
|
|
9
|
-
shuffle(_iter_list)
|
|
10
|
-
for v in _iter_list:
|
|
11
|
-
yield v
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def count_sectors_in_ranges(ranges: Iterable[tuple[int, int]]):
|
|
15
|
-
n_sectors = 0
|
|
16
|
-
for start, end in ranges:
|
|
17
|
-
n_sectors += end - start
|
|
18
|
-
return n_sectors
|
src/utils/__init__.py
DELETED
|
File without changes
|
src/utils/_get_device_info.py
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import re
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def resolve_device_name(device: Path) -> str:
|
|
7
|
-
resolved = device.resolve()
|
|
8
|
-
if not resolved.is_block_device():
|
|
9
|
-
raise ValueError(f"{device} is not a valid block device.")
|
|
10
|
-
_resolved = str(resolved)
|
|
11
|
-
m = re.match(r"^/dev/([a-zA-Z0-9\-]+)$", _resolved)
|
|
12
|
-
if not m:
|
|
13
|
-
raise RuntimeError(f"Could not parse '{_resolved}'.")
|
|
14
|
-
return m.group(1)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def get_disk_block_size(device_name: str) -> int:
|
|
18
|
-
"""Get the block size of the disk."""
|
|
19
|
-
path = os.path.join("/sys/block/", device_name, "queue/physical_block_size")
|
|
20
|
-
try:
|
|
21
|
-
with open(path, "r") as f:
|
|
22
|
-
block_size = f.read()
|
|
23
|
-
return int(block_size.strip())
|
|
24
|
-
except Exception as e:
|
|
25
|
-
raise RuntimeError(f"Could not read block size for {device_name}: {e}") from e
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def get_disk_number_of_blocks(device_name: str) -> int:
|
|
29
|
-
"""Get the number of blocks on the disk."""
|
|
30
|
-
block_size = get_disk_block_size(device_name)
|
|
31
|
-
path = os.path.join("/sys/block/", device_name, "size")
|
|
32
|
-
try:
|
|
33
|
-
with open(path, "r") as f:
|
|
34
|
-
txt = f.read()
|
|
35
|
-
size_in_blocks = int(txt.strip()) * 512 / block_size
|
|
36
|
-
assert (
|
|
37
|
-
size_in_blocks.is_integer()
|
|
38
|
-
), f"Size in blocks is not an integer: {size_in_blocks}"
|
|
39
|
-
return int(size_in_blocks)
|
|
40
|
-
except Exception as e:
|
|
41
|
-
raise RuntimeError(
|
|
42
|
-
f"Could not read size in physical blocks for {device_name}: {e}"
|
|
43
|
-
) from e
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
from typing import Iterable
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
def iterable_to_bytes(iterable: Iterable[int], length: int = 4) -> bytes:
|
|
5
|
-
"""
|
|
6
|
-
Convert an iterable of integers to bytes.
|
|
7
|
-
"""
|
|
8
|
-
return b"".join(i.to_bytes(length, "big") for i in iterable)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def iterable_from_bytes(data: bytes, length: int = 4) -> Iterable[int]:
|
|
12
|
-
"""
|
|
13
|
-
Convert bytes to an iterable of integers.
|
|
14
|
-
"""
|
|
15
|
-
if len(data) % length != 0:
|
|
16
|
-
raise ValueError(f"Data length must be a multiple of {length} bytes.")
|
|
17
|
-
return (
|
|
18
|
-
int.from_bytes(data[i : i + length], "big") for i in range(0, len(data), length)
|
|
19
|
-
)
|
src/utils/_parse_inputs.py
DELETED
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
import re
|
|
2
|
-
from typing import Optional
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
def parse_string_with_unit_to_bytes(txt: str, unit_multiplier: int) -> int:
|
|
6
|
-
return round(float(txt) * unit_multiplier)
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def parse_bytes_to_sectors(_input: int, sector_size: int) -> int:
|
|
10
|
-
if _input % sector_size != 0:
|
|
11
|
-
raise ValueError(
|
|
12
|
-
f"{_input}B is not a multiple of the sector size {sector_size}B."
|
|
13
|
-
)
|
|
14
|
-
return int(_input / sector_size)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def parse_memory_number_to_bytes(txt: str, sector_size: int) -> int:
|
|
18
|
-
"""
|
|
19
|
-
Parses a memory value (a sector number or a space size) and outputs a sector number.
|
|
20
|
-
"""
|
|
21
|
-
txt = txt.strip()
|
|
22
|
-
|
|
23
|
-
try:
|
|
24
|
-
return parse_string_with_unit_to_bytes(txt, sector_size)
|
|
25
|
-
except ValueError:
|
|
26
|
-
pass
|
|
27
|
-
try:
|
|
28
|
-
if txt.endswith("MB"):
|
|
29
|
-
return parse_string_with_unit_to_bytes(txt[:-2], 1024**2)
|
|
30
|
-
elif txt.endswith("GB"):
|
|
31
|
-
return parse_string_with_unit_to_bytes(txt[:-2], 1024**3)
|
|
32
|
-
elif txt.endswith("KB"):
|
|
33
|
-
return parse_string_with_unit_to_bytes(txt[:-2], 1024)
|
|
34
|
-
elif txt.endswith("B"):
|
|
35
|
-
return parse_string_with_unit_to_bytes(txt[:-1], 1)
|
|
36
|
-
else:
|
|
37
|
-
raise ValueError(f"Invalid format: {txt}")
|
|
38
|
-
except ValueError as e:
|
|
39
|
-
raise ValueError(f"Failed to parse memory space from '{txt}': {e}") from e
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def parse_memory_range_to_bytes(
|
|
43
|
-
txt: str, sector_size: int
|
|
44
|
-
) -> tuple[Optional[int], Optional[int]]:
|
|
45
|
-
"""
|
|
46
|
-
Parse a memory space range from a string and check the format is valid. Returns a sector range.
|
|
47
|
-
I.e. checks that the format is 'start-end', where each can be omitted, and start <= end.
|
|
48
|
-
"""
|
|
49
|
-
txt = txt.strip()
|
|
50
|
-
|
|
51
|
-
m = re.match(
|
|
52
|
-
r"^(?P<start>\d+(\.\d+)?(?:[KMGT]?B)?)?-(?P<end>\d+(\.\d+)?(?:[KMGT]?B)?)?$",
|
|
53
|
-
txt,
|
|
54
|
-
)
|
|
55
|
-
if m is None:
|
|
56
|
-
raise ValueError(
|
|
57
|
-
f"Invalid format: {txt}. Expected format: 'start-end', where each can be omitted."
|
|
58
|
-
)
|
|
59
|
-
groups: dict[str, str | None] = {key: m.group(key) for key in ("start", "end")}
|
|
60
|
-
values: dict[str, int] = {
|
|
61
|
-
key: parse_memory_number_to_bytes(value, sector_size)
|
|
62
|
-
for key, value in groups.items()
|
|
63
|
-
if value is not None
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
start, end = values.get("start"), values.get("end")
|
|
67
|
-
|
|
68
|
-
if start is not None and end is not None and (end < start):
|
|
69
|
-
raise ValueError(f"End {end} must be greater than or equal to start {start}.")
|
|
70
|
-
|
|
71
|
-
return start, end
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def parse_memory_range_to_sectors(
|
|
75
|
-
txt: str, sector_size: int
|
|
76
|
-
) -> tuple[Optional[int], Optional[int]]:
|
|
77
|
-
start, end = parse_memory_range_to_bytes(txt, sector_size)
|
|
78
|
-
|
|
79
|
-
if start is not None:
|
|
80
|
-
start = parse_bytes_to_sectors(start, sector_size)
|
|
81
|
-
if end is not None:
|
|
82
|
-
end = parse_bytes_to_sectors(end, sector_size)
|
|
83
|
-
|
|
84
|
-
return start, end
|
src/utils/_run_command.py
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
import subprocess
|
|
2
|
-
import sys
|
|
3
|
-
import threading
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
from typing import IO, Iterable, Optional, Protocol, Union
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class Stringifyiable(Protocol):
|
|
9
|
-
"""Protocol for objects that can be converted to a string."""
|
|
10
|
-
|
|
11
|
-
def __str__(self) -> str:
|
|
12
|
-
"""Return the string representation of the object."""
|
|
13
|
-
...
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def pipe_stderr_to_stream(pipe: IO[str], stream: IO[str]) -> None:
|
|
17
|
-
for line in pipe:
|
|
18
|
-
print(f"[stderr] {line.strip()}", file=stream)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def run_command_realtime(cmd: list[str], stdin: Optional[str] = None) -> Iterable[str]:
|
|
22
|
-
"""Run a command and yield its output line by line in real-time."""
|
|
23
|
-
with subprocess.Popen(
|
|
24
|
-
cmd,
|
|
25
|
-
stdout=subprocess.PIPE,
|
|
26
|
-
stderr=subprocess.PIPE,
|
|
27
|
-
stdin=subprocess.PIPE,
|
|
28
|
-
text=True,
|
|
29
|
-
) as process:
|
|
30
|
-
if process.stdout is None:
|
|
31
|
-
raise RuntimeError("Failed to capture stdout from the command.")
|
|
32
|
-
|
|
33
|
-
if stdin is not None:
|
|
34
|
-
if process.stdin is None:
|
|
35
|
-
raise RuntimeError("Failed to pass stdin to command.")
|
|
36
|
-
else:
|
|
37
|
-
if not stdin.endswith("\n"):
|
|
38
|
-
stdin += "\n"
|
|
39
|
-
process.stdin.write(stdin)
|
|
40
|
-
process.stdin.close()
|
|
41
|
-
|
|
42
|
-
# Start background stderr reader
|
|
43
|
-
stderr_thread: Optional[threading.Thread] = None
|
|
44
|
-
if process.stderr is not None:
|
|
45
|
-
stderr_thread = threading.Thread(
|
|
46
|
-
target=pipe_stderr_to_stream, args=(process.stderr, sys.stderr)
|
|
47
|
-
)
|
|
48
|
-
stderr_thread.start()
|
|
49
|
-
|
|
50
|
-
for line in process.stdout:
|
|
51
|
-
yield line.strip()
|
|
52
|
-
|
|
53
|
-
process.wait()
|
|
54
|
-
if stderr_thread is not None:
|
|
55
|
-
stderr_thread.join()
|
|
56
|
-
|
|
57
|
-
if process.returncode != 0:
|
|
58
|
-
# Capture stderr if needed
|
|
59
|
-
raise RuntimeError(f"Command failed with code {process.returncode}")
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def pipe_lines_to_file(
|
|
63
|
-
lines: Iterable[Stringifyiable], output_file: Union[str, Path, IO[str]]
|
|
64
|
-
) -> None:
|
|
65
|
-
"""Save the lines to the specified output file in real-time."""
|
|
66
|
-
f: Optional[IO[str]] = None
|
|
67
|
-
try:
|
|
68
|
-
if not isinstance(output_file, IO):
|
|
69
|
-
f = open(output_file, "w")
|
|
70
|
-
output_file = f
|
|
71
|
-
for line in lines:
|
|
72
|
-
output_file.write(str(line) + "\n")
|
|
73
|
-
output_file.flush() # Ensure the content is written to the file immediately
|
|
74
|
-
finally:
|
|
75
|
-
if f is not None:
|
|
76
|
-
f.close()
|
src/utils/_sort_devices.py
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
from typing import Collection, Iterable
|
|
2
|
-
|
|
3
|
-
from remap_badblocks.src.devices.devices_config import DeviceConfig
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def sort_devices_by_dependencies(
|
|
7
|
-
devices: Collection[DeviceConfig],
|
|
8
|
-
already_applied_devices: Iterable[DeviceConfig] = set(),
|
|
9
|
-
) -> Iterable[DeviceConfig]:
|
|
10
|
-
devices = list(devices)
|
|
11
|
-
sorted_devices: list[DeviceConfig] = []
|
|
12
|
-
already_sorted_ids: set[int] = set(map(lambda x: x.id, already_applied_devices))
|
|
13
|
-
|
|
14
|
-
while devices:
|
|
15
|
-
for device in devices:
|
|
16
|
-
if all(dep in already_sorted_ids for dep in device.depends_on):
|
|
17
|
-
sorted_devices.append(device)
|
|
18
|
-
already_sorted_ids.add(device.id)
|
|
19
|
-
devices.remove(device)
|
|
20
|
-
break
|
|
21
|
-
else:
|
|
22
|
-
raise ValueError(
|
|
23
|
-
"Circular dependency detected or no device can be applied."
|
|
24
|
-
)
|
|
25
|
-
|
|
26
|
-
return sorted_devices
|
|
File without changes
|
|
File without changes
|
|
File without changes
|