Merge lp:~jderose/dmedia/drives-plus into lp:dmedia
- drives-plus
- Merge into trunk
Proposed by
Jason Gerard DeRose
Status: | Merged |
---|---|
Merged at revision: | 759 |
Proposed branch: | lp:~jderose/dmedia/drives-plus |
Merge into: | lp:dmedia |
Diff against target: |
1081 lines (+703/-166) 4 files modified
dmedia-provision-drive (+12/-4) dmedia-service (+3/-2) dmedia/drives.py (+181/-135) dmedia/tests/test_drives.py (+507/-25) |
To merge this branch: | bzr merge lp:~jderose/dmedia/drives-plus |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
David Jordan | Approve | ||
Review via email: mp+193727@code.launchpad.net |
Commit message
Description of the change
For background, please see this bug:
https:/
Despite a fairly large diff, there is no functionality change here. This is simply a cleanup and refactoring in order to make it easier to write unit tests, plus providing said unit tests.
Currently the only two consumers of the `dmedia.drives` module are the `dmedia-
To post a comment you must log in.
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'dmedia-provision-drive' |
2 | --- dmedia-provision-drive 2013-08-04 14:03:47 +0000 |
3 | +++ dmedia-provision-drive 2013-11-04 01:28:24 +0000 |
4 | @@ -44,11 +44,12 @@ |
5 | |
6 | import argparse |
7 | import os |
8 | +import tempfile |
9 | |
10 | from filestore import _dumps |
11 | from dbase32 import random_id, isdb32 |
12 | |
13 | -from dmedia.drives import Drive, VALID_DRIVE |
14 | +from dmedia.drives import Drive, Devices, VALID_DRIVE |
15 | |
16 | |
17 | parser = argparse.ArgumentParser() |
18 | @@ -90,7 +91,14 @@ |
19 | |
20 | if os.getuid() != 0: |
21 | raise SystemExit('Error: must be run as root') |
22 | + |
23 | drive = Drive(args.dev) |
24 | -doc = drive.provision(args.label, args.id) |
25 | -print(_dumps(doc)) |
26 | - |
27 | +partition = drive.provision(args.label, args.id) |
28 | +devices = Devices() |
29 | +info = devices.get_partition_info(partition.dev) |
30 | +tmpdir = tempfile.mkdtemp(prefix='dmedia.') |
31 | +try: |
32 | + doc = partition.create_filestore(tmpdir, args.id, 1, **info) |
33 | + print(_dumps(doc)) |
34 | +finally: |
35 | + os.rmdir(tmpdir) |
36 | |
37 | === modified file 'dmedia-service' |
38 | --- dmedia-service 2013-10-29 03:31:24 +0000 |
39 | +++ dmedia-service 2013-11-04 01:28:24 +0000 |
40 | @@ -51,7 +51,7 @@ |
41 | from dmedia.service.background import Snapshots, LazyAccess, Downloads |
42 | from dmedia.service.avahi import Avahi |
43 | from dmedia.service.peers import Browser, Publisher |
44 | -from dmedia.drives import get_parentdir_info |
45 | +from dmedia.drives import Devices |
46 | |
47 | |
48 | BUS = dmedia.BUS |
49 | @@ -60,6 +60,7 @@ |
50 | session = dbus.SessionBus() |
51 | mainloop = GLib.MainLoop() |
52 | VolumeMonitor = Gio.VolumeMonitor.get() |
53 | +devices = Devices() |
54 | |
55 | |
56 | def on_sighup(signum, frame): |
57 | @@ -206,7 +207,7 @@ |
58 | if isfilestore(self.couch.basedir): |
59 | self.core.connect_filestore(self.couch.basedir) |
60 | else: |
61 | - info = get_parentdir_info(self.couch.basedir) |
62 | + info = devices.get_parentdir_info(self.couch.basedir) |
63 | self.core.create_filestore(self.couch.basedir, **info) |
64 | VolumeMonitor.connect('mount-added', self.on_mount_added) |
65 | VolumeMonitor.connect('mount-pre-unmount', self.on_mount_pre_unmount) |
66 | |
67 | === modified file 'dmedia/drives.py' |
68 | --- dmedia/drives.py 2013-11-02 19:06:33 +0000 |
69 | +++ dmedia/drives.py 2013-11-04 01:28:24 +0000 |
70 | @@ -24,10 +24,9 @@ |
71 | """ |
72 | |
73 | from uuid import UUID |
74 | -from subprocess import check_call, check_output |
75 | +import subprocess |
76 | import re |
77 | import time |
78 | -import tempfile |
79 | import os |
80 | from os import path |
81 | |
82 | @@ -38,9 +37,20 @@ |
83 | from .units import bytes10 |
84 | |
85 | |
86 | -udev_client = GUdev.Client.new(['block']) |
87 | VALID_DRIVE = re.compile('^/dev/[sv]d[a-z]$') |
88 | -VALID_PARTITION = re.compile('^/dev/[sv]d[a-z][1-9]$') |
89 | +VALID_PARTITION = re.compile('^(/dev/[sv]d[a-z])([1-9])$') |
90 | + |
91 | + |
92 | +def check_drive_dev(dev): |
93 | + if not VALID_DRIVE.match(dev): |
94 | + raise ValueError('Invalid drive device file: {!r}'.format(dev)) |
95 | + return dev |
96 | + |
97 | + |
98 | +def check_partition_dev(dev): |
99 | + if not VALID_PARTITION.match(dev): |
100 | + raise ValueError('Invalid partition device file: {!r}'.format(dev)) |
101 | + return dev |
102 | |
103 | |
104 | def db32_to_uuid(store_id): |
105 | @@ -91,17 +101,6 @@ |
106 | return string.replace('\\x20', ' ').strip() |
107 | |
108 | |
109 | -class NoSuchDevice(Exception): |
110 | - pass |
111 | - |
112 | - |
113 | -def get_device(dev): |
114 | - device = udev_client.query_by_device_file(dev) |
115 | - if device is None: |
116 | - raise NoSuchDevice('No such device: {!r}'.format(dev)) |
117 | - return device |
118 | - |
119 | - |
120 | def get_drive_info(device): |
121 | physical = device.get_sysfs_attr_as_uint64('queue/physical_block_size') |
122 | logical = device.get_sysfs_attr_as_uint64('queue/logical_block_size') |
123 | @@ -110,15 +109,20 @@ |
124 | return { |
125 | 'drive_block_physical': physical, |
126 | 'drive_block_logical': logical, |
127 | + 'drive_alignment_offset': device.get_sysfs_attr_as_int('alignment_offset'), |
128 | + 'drive_discard_alignment': device.get_sysfs_attr_as_int('discard_alignment'), |
129 | 'drive_bytes': drive_bytes, |
130 | 'drive_size': bytes10(drive_bytes), |
131 | - 'drive_model': device.get_property('ID_MODEL'), |
132 | + 'drive_model': unfuck(device.get_property('ID_MODEL_ENC')), |
133 | 'drive_model_id': device.get_property('ID_MODEL_ID'), |
134 | 'drive_revision': device.get_property('ID_REVISION'), |
135 | 'drive_serial': device.get_property('ID_SERIAL_SHORT'), |
136 | 'drive_wwn': device.get_property('ID_WWN_WITH_EXTENSION'), |
137 | - 'drive_vendor': device.get_property('ID_VENDOR'), |
138 | + 'drive_vendor': unfuck(device.get_property('ID_VENDOR_ENC')), |
139 | + 'drive_vendor_id': device.get_property('ID_VENDOR_ID'), |
140 | 'drive_removable': bool(device.get_sysfs_attr_as_int('removable')), |
141 | + 'drive_bus': device.get_property('ID_BUS'), |
142 | + 'drive_rpm': device.get_property('ID_ATA_ROTATION_RATE_RPM'), |
143 | } |
144 | |
145 | |
146 | @@ -151,8 +155,8 @@ |
147 | 'partition_scheme': device.get_property('ID_PART_ENTRY_SCHEME'), |
148 | 'partition_number': device.get_property_as_int('ID_PART_ENTRY_NUMBER'), |
149 | 'partition_bytes': part_bytes, |
150 | + 'partition_start_bytes': part_start_sector * logical, |
151 | 'partition_size': bytes10(part_bytes), |
152 | - 'partition_start_bytes': part_start_sector * logical, |
153 | |
154 | 'filesystem_type': device.get_property('ID_FS_TYPE'), |
155 | 'filesystem_uuid': device.get_property('ID_FS_UUID'), |
156 | @@ -169,22 +173,68 @@ |
157 | raise ValueError('Could not find disk size with unit=MiB') |
158 | |
159 | |
160 | -class Drive: |
161 | - def __init__(self, dev): |
162 | - if not VALID_DRIVE.match(dev): |
163 | - raise ValueError('Invalid drive device file: {!r}'.format(dev)) |
164 | - self.dev = dev |
165 | +def parse_mounts(procdir='/proc'): |
166 | + text = open(path.join(procdir, 'mounts'), 'r').read() |
167 | + mounts = {} |
168 | + for line in text.splitlines(): |
169 | + (dev, mount, type_, options, dump, pass_) = line.split() |
170 | + mounts[mount.replace('\\040', ' ')] = dev |
171 | + return mounts |
172 | + |
173 | + |
174 | +class Mockable: |
175 | + """ |
176 | + Mock calls to `subprocess.check_call()`, `subprocess.check_output()`. |
177 | + """ |
178 | + |
179 | + def __init__(self, mocking=False): |
180 | + assert isinstance(mocking, bool) |
181 | + self.mocking = mocking |
182 | + self.calls = [] |
183 | + self.outputs = [] |
184 | + |
185 | + def reset(self, mocking=False, outputs=None): |
186 | + assert isinstance(mocking, bool) |
187 | + self.mocking = mocking |
188 | + self.calls.clear() |
189 | + self.outputs.clear() |
190 | + if outputs: |
191 | + assert mocking is True |
192 | + for value in outputs: |
193 | + assert isinstance(value, bytes) |
194 | + self.outputs.append(value) |
195 | + |
196 | + def check_call(self, cmd): |
197 | + assert isinstance(cmd, list) |
198 | + if self.mocking: |
199 | + self.calls.append(('check_call', cmd)) |
200 | + else: |
201 | + subprocess.check_call(cmd) |
202 | + |
203 | + def check_output(self, cmd): |
204 | + assert isinstance(cmd, list) |
205 | + if self.mocking: |
206 | + self.calls.append(('check_output', cmd)) |
207 | + return self.outputs.pop(0) |
208 | + else: |
209 | + return subprocess.check_output(cmd) |
210 | + |
211 | + |
212 | +class Drive(Mockable): |
213 | + def __init__(self, dev, mocking=False): |
214 | + super().__init__(mocking) |
215 | + self.dev = check_drive_dev(dev) |
216 | |
217 | def get_partition(self, index): |
218 | assert isinstance(index, int) |
219 | assert index >= 1 |
220 | - return Partition('{}{}'.format(self.dev, index)) |
221 | + return Partition('{}{}'.format(self.dev, index), mocking=self.mocking) |
222 | |
223 | def rereadpt(self): |
224 | - check_call(['blockdev', '--rereadpt', self.dev]) |
225 | + self.check_call(['blockdev', '--rereadpt', self.dev]) |
226 | |
227 | def zero(self): |
228 | - check_call(['dd', |
229 | + self.check_call(['dd', |
230 | 'if=/dev/zero', |
231 | 'of={}'.format(self.dev), |
232 | 'bs=4M', |
233 | @@ -201,37 +251,34 @@ |
234 | return cmd |
235 | |
236 | def mklabel(self): |
237 | - check_call(self.parted('mklabel', 'gpt')) |
238 | + self.check_call(self.parted('mklabel', 'gpt')) |
239 | |
240 | def print(self): |
241 | cmd = self.parted('print') |
242 | - return check_output(cmd).decode('utf-8') |
243 | + return self.check_output(cmd).decode('utf-8') |
244 | |
245 | def init_partition_table(self): |
246 | self.rereadpt() # Make sure existing partitions aren't mounted |
247 | self.zero() |
248 | - time.sleep(2) |
249 | + time.sleep(1) |
250 | self.rereadpt() |
251 | self.mklabel() |
252 | - |
253 | - text = self.print() |
254 | - print(text) |
255 | - self.size = parse_drive_size(text) |
256 | + self.size = parse_drive_size(self.print()) |
257 | self.index = 0 |
258 | self.start = 1 |
259 | self.stop = self.size - 1 |
260 | assert self.start < self.stop |
261 | |
262 | - @property |
263 | - def remaining(self): |
264 | - return self.stop - self.start |
265 | - |
266 | def mkpart(self, start, stop): |
267 | assert isinstance(start, int) |
268 | assert isinstance(stop, int) |
269 | assert 1 <= start < stop <= self.stop |
270 | cmd = self.parted('mkpart', 'primary', 'ext2', str(start), str(stop)) |
271 | - check_call(cmd) |
272 | + self.check_call(cmd) |
273 | + |
274 | + @property |
275 | + def remaining(self): |
276 | + return self.stop - self.start |
277 | |
278 | def add_partition(self, size): |
279 | assert isinstance(size, int) |
280 | @@ -246,19 +293,14 @@ |
281 | self.init_partition_table() |
282 | partition = self.add_partition(self.remaining) |
283 | partition.mkfs_ext4(label, store_id) |
284 | - time.sleep(2) |
285 | - doc = partition.create_filestore(store_id) |
286 | - return doc |
287 | - |
288 | - |
289 | -class Partition: |
290 | - def __init__(self, dev): |
291 | - if not VALID_PARTITION.match(dev): |
292 | - raise ValueError('Invalid partition device file: {!r}'.format(dev)) |
293 | - self.dev = dev |
294 | - |
295 | - def get_info(self): |
296 | - return get_partition_info(get_device(self.dev)) |
297 | + time.sleep(1) |
298 | + return partition |
299 | + |
300 | + |
301 | +class Partition(Mockable): |
302 | + def __init__(self, dev, mocking=False): |
303 | + super().__init__(mocking) |
304 | + self.dev = check_partition_dev(dev) |
305 | |
306 | def mkfs_ext4(self, label, store_id): |
307 | cmd = ['mkfs.ext4', self.dev, |
308 | @@ -266,97 +308,101 @@ |
309 | '-U', db32_to_uuid(store_id), |
310 | '-m', '0', # 0% reserved blocks |
311 | ] |
312 | - check_call(cmd) |
313 | + self.check_call(cmd) |
314 | |
315 | - def create_filestore(self, store_id, copies=1): |
316 | - kw = self.get_info() |
317 | - tmpdir = tempfile.mkdtemp(prefix='dmedia.') |
318 | + def create_filestore(self, mount, store_id=None, copies=1, **kw): |
319 | fs = None |
320 | - check_call(['mount', self.dev, tmpdir]) |
321 | + self.check_call(['mount', self.dev, mount]) |
322 | try: |
323 | - fs = FileStore.create(tmpdir, store_id, 1, **kw) |
324 | - check_call(['chmod', '0777', tmpdir]) |
325 | + fs = FileStore.create(mount, store_id, copies, **kw) |
326 | + self.check_call(['chmod', '0777', mount]) |
327 | return fs.doc |
328 | finally: |
329 | del fs |
330 | - check_call(['umount', tmpdir]) |
331 | - os.rmdir(tmpdir) |
332 | + self.check_call(['umount', self.dev]) |
333 | + |
334 | + |
335 | +class DeviceNotFound(Exception): |
336 | + def __init__(self, dev): |
337 | + self.dev = dev |
338 | + super().__init__('No such device: {!r}'.format(dev)) |
339 | |
340 | |
341 | class Devices: |
342 | + """ |
343 | + Gather disk and partition info using udev. |
344 | + """ |
345 | + |
346 | def __init__(self): |
347 | - self.client = GUdev.Client.new(['block']) |
348 | - self.partitions = {} |
349 | - |
350 | - def run(self): |
351 | - self.client.connect('uevent', self.on_uevent) |
352 | - for device in self.client.query_by_subsystem('block'): |
353 | - self.on_uevent(None, 'add', device) |
354 | - |
355 | - def on_uevent(self, client, action, device): |
356 | - _type = device.get_devtype() |
357 | - dev = device.get_device_file() |
358 | - if _type == 'partition': |
359 | - print(action, _type, dev) |
360 | - if action == 'add': |
361 | - self.add_partition(device) |
362 | - elif action == 'remove': |
363 | - self.remove_partition(device) |
364 | - |
365 | - def add_partition(self, device): |
366 | - dev = device.get_device_file() |
367 | - print('add_partition({!r})'.format(dev)) |
368 | - |
369 | - def remove_partition(self, device): |
370 | - dev = device.get_device_file() |
371 | - print('add_partition({!r})'.format(dev)) |
372 | - |
373 | - |
374 | -def parse_mounts(procdir='/proc'): |
375 | - text = open(path.join(procdir, 'mounts'), 'r').read() |
376 | - mounts = {} |
377 | - for line in text.splitlines(): |
378 | - (dev, mount, type_, options, dump, pass_) = line.split() |
379 | - mounts[mount.replace('\\040', ' ')] = dev |
380 | - return mounts |
381 | - |
382 | - |
383 | -def get_homedir_info(homedir): |
384 | - mounts = parse_mounts() |
385 | - mountdir = homedir |
386 | - while True: |
387 | - if mountdir in mounts: |
388 | - try: |
389 | - device = get_device(mounts[mountdir]) |
390 | - return get_partition_info(device) |
391 | - except NoSuchDevice: |
392 | - pass |
393 | - if mountdir == '/': |
394 | - return {} |
395 | - mountdir = path.dirname(mountdir) |
396 | - |
397 | - |
398 | -def get_parentdir_info(parentdir): |
399 | - mounts = parse_mounts() |
400 | - mountdir = parentdir |
401 | - while True: |
402 | - if mountdir in mounts: |
403 | - try: |
404 | - device = get_device(mounts[mountdir]) |
405 | - return get_partition_info(device) |
406 | - except NoSuchDevice: |
407 | - pass |
408 | - if mountdir == '/': |
409 | - return {} |
410 | - mountdir = path.dirname(mountdir) |
411 | - |
412 | - |
413 | -def get_mountdir_info(mountdir): |
414 | - mounts = parse_mounts() |
415 | - if mountdir in mounts: |
416 | - try: |
417 | - device = get_device(mounts[mountdir]) |
418 | - return get_partition_info(device) |
419 | - except NoSuchDevice: |
420 | - pass |
421 | - return {} |
422 | + self.udev_client = self.get_udev_client() |
423 | + |
424 | + def get_udev_client(self): |
425 | + """ |
426 | + Making this easy to override for mocking purposes. |
427 | + """ |
428 | + return GUdev.Client.new(['block']) |
429 | + |
430 | + def get_device(self, dev): |
431 | + """ |
432 | + Get a device object by its dev path (eg, ``'/dev/sda'``). |
433 | + """ |
434 | + device = self.udev_client.query_by_device_file(dev) |
435 | + if device is None: |
436 | + raise DeviceNotFound(dev) |
437 | + return device |
438 | + |
439 | + def get_drive_info(self, dev): |
440 | + device = self.get_device(check_drive_dev(dev)) |
441 | + return get_drive_info(device) |
442 | + |
443 | + def get_partition_info(self, dev): |
444 | + device = self.get_device(check_partition_dev(dev)) |
445 | + return get_partition_info(device) |
446 | + |
447 | + def iter_drives(self): |
448 | + for device in self.udev_client.query_by_subsystem('block'): |
449 | + if device.get_devtype() != 'disk': |
450 | + continue |
451 | + if VALID_DRIVE.match(device.get_device_file()): |
452 | + yield device |
453 | + |
454 | + def iter_partitions(self): |
455 | + for device in self.udev_client.query_by_subsystem('block'): |
456 | + if device.get_devtype() != 'partition': |
457 | + continue |
458 | + if VALID_PARTITION.match(device.get_device_file()): |
459 | + yield device |
460 | + |
461 | + def get_parentdir_info(self, parentdir): |
462 | + assert path.abspath(parentdir) == parentdir |
463 | + mounts = parse_mounts() |
464 | + mountdir = parentdir |
465 | + while True: |
466 | + if mountdir in mounts: |
467 | + try: |
468 | + device = self.get_device(mounts[mountdir]) |
469 | + return get_partition_info(device) |
470 | + except DeviceNotFound: |
471 | + pass |
472 | + if mountdir == '/': |
473 | + return {} |
474 | + mountdir = path.dirname(mountdir) |
475 | + |
476 | + def get_info(self): |
477 | + return { |
478 | + 'drives': dict( |
479 | + (drive.get_device_file(), get_drive_info(drive)) |
480 | + for drive in self.iter_drives() |
481 | + ), |
482 | + 'partitions': dict( |
483 | + (partition.get_device_file(), get_drive_info(partition)) |
484 | + for partition in self.iter_partitions() |
485 | + ), |
486 | + } |
487 | + |
488 | + |
489 | +if __name__ == '__main__': |
490 | + d = Devices() |
491 | + print(_dumps(d.get_info())) |
492 | + print(_dumps(parse_mounts())) |
493 | + print(_dumps(d.get_parentdir_info('/'))) |
494 | |
495 | === modified file 'dmedia/tests/test_drives.py' |
496 | --- dmedia/tests/test_drives.py 2013-07-21 20:04:46 +0000 |
497 | +++ dmedia/tests/test_drives.py 2013-11-04 01:28:24 +0000 |
498 | @@ -26,12 +26,21 @@ |
499 | from unittest import TestCase |
500 | import uuid |
501 | import os |
502 | - |
503 | -from dbase32 import db32enc |
504 | - |
505 | +import string |
506 | +import re |
507 | +import subprocess |
508 | +from random import SystemRandom |
509 | + |
510 | +from filestore import FileStore |
511 | +from dbase32 import db32enc, random_id |
512 | +from gi.repository import GUdev |
513 | + |
514 | +from .base import TempDir |
515 | from dmedia import drives |
516 | |
517 | |
518 | +random = SystemRandom() |
519 | + |
520 | PARTED_PRINT = """ |
521 | Model: ATA WDC WD30EZRX-00D (scsi) |
522 | Disk /dev/sdd: 2861588MiB |
523 | @@ -42,6 +51,76 @@ |
524 | 1 1.00MiB 2861587MiB 2861586MiB ext4 primary |
525 | """ |
526 | |
527 | +EXPECTED_DRIVE_KEYS = ( |
528 | + 'drive_block_physical', |
529 | + 'drive_block_logical', |
530 | + 'drive_alignment_offset', |
531 | + 'drive_discard_alignment', |
532 | + 'drive_bytes', |
533 | + 'drive_size', |
534 | + 'drive_model', |
535 | + 'drive_model_id', |
536 | + 'drive_revision', |
537 | + 'drive_serial', |
538 | + 'drive_wwn', |
539 | + 'drive_vendor', |
540 | + 'drive_vendor_id', |
541 | + 'drive_removable', |
542 | + 'drive_bus', |
543 | + 'drive_rpm', |
544 | +) |
545 | + |
546 | +EXPECTED_PARTITION_KEYS = EXPECTED_DRIVE_KEYS + ( |
547 | + 'partition_scheme', |
548 | + 'partition_number', |
549 | + 'partition_bytes', |
550 | + 'partition_start_bytes', |
551 | + 'partition_size', |
552 | + 'partition_size', |
553 | + 'filesystem_type', |
554 | + 'filesystem_uuid', |
555 | + 'filesystem_label', |
556 | +) |
557 | + |
558 | + |
559 | +def random_drive_dev(): |
560 | + base = random.choice(['/dev/sd', '/dev/vd']) |
561 | + letter = random.choice(string.ascii_lowercase) |
562 | + dev = '{}{}'.format(base, letter) |
563 | + assert drives.VALID_DRIVE.match(dev) |
564 | + return dev |
565 | + |
566 | + |
567 | +def random_partition_dev(): |
568 | + drive_dev = random_drive_dev() |
569 | + number = random.randint(1, 9) |
570 | + dev = '{}{:d}'.format(drive_dev, number) |
571 | + assert drives.VALID_PARTITION.match(dev) |
572 | + return dev |
573 | + |
574 | + |
575 | +class TestConstants(TestCase): |
576 | + def test_VALID_DRIVE(self): |
577 | + self.assertIsInstance(drives.VALID_DRIVE, re._pattern_type) |
578 | + for base in ('/dev/sd', '/dev/vd'): |
579 | + for letter in string.ascii_lowercase: |
580 | + dev = base + letter |
581 | + m = drives.VALID_DRIVE.match(dev) |
582 | + self.assertIsNotNone(m) |
583 | + self.assertEqual(m.group(0), dev) |
584 | + |
585 | + def test_VALID_PARTITION(self): |
586 | + self.assertIsInstance(drives.VALID_PARTITION, re._pattern_type) |
587 | + for base in ('/dev/sd', '/dev/vd'): |
588 | + for letter in string.ascii_lowercase: |
589 | + for number in range(1, 10): |
590 | + dev = '{}{}{:d}'.format(base, letter, number) |
591 | + m = drives.VALID_PARTITION.match(dev) |
592 | + self.assertIsNotNone(m) |
593 | + self.assertEqual(m.group(0), dev) |
594 | + self.assertEqual(m.group(1), base + letter) |
595 | + self.assertEqual(m.group(2), str(number)) |
596 | + |
597 | |
598 | class TestFunctions(TestCase): |
599 | def test_db32_to_uuid(self): |
600 | @@ -60,7 +139,7 @@ |
601 | drives.uuid_to_db32(str(uuid.UUID(bytes=data))), |
602 | db32enc(data[:15]) |
603 | ) |
604 | - |
605 | + |
606 | def test_unfuck(self): |
607 | self.assertIsNone(drives.unfuck(None)) |
608 | fucked = 'WDC\\x20WD30EZRX-00DC0B0\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20' |
609 | @@ -69,12 +148,144 @@ |
610 | def test_parse_drive_size(self): |
611 | self.assertEqual(drives.parse_drive_size(PARTED_PRINT), 2861588) |
612 | |
613 | + def test_get_drive_info(self): |
614 | + d = drives.Devices() |
615 | + for device in d.iter_drives(): |
616 | + info = drives.get_drive_info(device) |
617 | + self.assertEqual(set(info), set(EXPECTED_DRIVE_KEYS)) |
618 | + |
619 | + def test_get_partition_info(self): |
620 | + d = drives.Devices() |
621 | + for device in d.iter_partitions(): |
622 | + info = drives.get_partition_info(device) |
623 | + self.assertEqual(set(info), set(EXPECTED_PARTITION_KEYS)) |
624 | + m = drives.VALID_PARTITION.match(device.get_device_file()) |
625 | + self.assertIsNotNone(m) |
626 | + drive_device = d.get_device(m.group(1)) |
627 | + sub = dict( |
628 | + (key, info[key]) for key in EXPECTED_DRIVE_KEYS |
629 | + ) |
630 | + self.assertEqual(sub, drives.get_drive_info(drive_device)) |
631 | + |
632 | + def test_parse_mounts(self): |
633 | + mounts = drives.parse_mounts() |
634 | + self.assertIsInstance(mounts, dict) |
635 | + for (key, value) in mounts.items(): |
636 | + self.assertIsInstance(key, str) |
637 | + self.assertIsInstance(value, str) |
638 | + |
639 | + |
640 | +class TestMockable(TestCase): |
641 | + def test_init(self): |
642 | + inst = drives.Mockable() |
643 | + self.assertIs(inst.mocking, False) |
644 | + self.assertEqual(inst.calls, []) |
645 | + self.assertEqual(inst.outputs, []) |
646 | + |
647 | + inst = drives.Mockable(mocking=True) |
648 | + self.assertIs(inst.mocking, True) |
649 | + self.assertEqual(inst.calls, []) |
650 | + self.assertEqual(inst.outputs, []) |
651 | + |
652 | + def test_reset(self): |
653 | + inst = drives.Mockable() |
654 | + calls = inst.calls |
655 | + outputs = inst.outputs |
656 | + self.assertIsNone(inst.reset()) |
657 | + self.assertIs(inst.mocking, False) |
658 | + self.assertIs(inst.calls, calls) |
659 | + self.assertEqual(inst.calls, []) |
660 | + self.assertIs(inst.outputs, outputs) |
661 | + self.assertEqual(inst.outputs, []) |
662 | + |
663 | + inst = drives.Mockable(mocking=True) |
664 | + calls = inst.calls |
665 | + outputs = inst.outputs |
666 | + inst.calls.extend( |
667 | + [('check_call', ['stuff']), ('check_output', ['junk'])] |
668 | + ) |
669 | + inst.outputs.extend([b'foo', b'bar']) |
670 | + self.assertIsNone(inst.reset()) |
671 | + self.assertIs(inst.mocking, False) |
672 | + self.assertIs(inst.calls, calls) |
673 | + self.assertEqual(inst.calls, []) |
674 | + self.assertIs(inst.outputs, outputs) |
675 | + self.assertEqual(inst.outputs, []) |
676 | + |
677 | + inst = drives.Mockable(mocking=True) |
678 | + calls = inst.calls |
679 | + outputs = inst.outputs |
680 | + inst.calls.extend( |
681 | + [('check_call', ['stuff']), ('check_output', ['junk'])] |
682 | + ) |
683 | + inst.outputs.extend([b'foo', b'bar']) |
684 | + self.assertIsNone(inst.reset(mocking=True, outputs=[b'aye', b'bee'])) |
685 | + self.assertIs(inst.mocking, True) |
686 | + self.assertIs(inst.calls, calls) |
687 | + self.assertEqual(inst.calls, []) |
688 | + self.assertIs(inst.outputs, outputs) |
689 | + self.assertEqual(inst.outputs, [b'aye', b'bee']) |
690 | + |
691 | + def test_check_call(self): |
692 | + inst = drives.Mockable() |
693 | + self.assertIsNone(inst.check_call(['/bin/true'])) |
694 | + self.assertEqual(inst.calls, []) |
695 | + self.assertEqual(inst.outputs, []) |
696 | + with self.assertRaises(subprocess.CalledProcessError) as cm: |
697 | + inst.check_call(['/bin/false']) |
698 | + self.assertEqual(cm.exception.cmd, ['/bin/false']) |
699 | + self.assertEqual(cm.exception.returncode, 1) |
700 | + self.assertEqual(inst.calls, []) |
701 | + self.assertEqual(inst.outputs, []) |
702 | + |
703 | + inst = drives.Mockable(mocking=True) |
704 | + self.assertIsNone(inst.check_call(['/bin/true'])) |
705 | + self.assertEqual(inst.calls, [ |
706 | + ('check_call', ['/bin/true']), |
707 | + ]) |
708 | + self.assertEqual(inst.outputs, []) |
709 | + self.assertIsNone(inst.check_call(['/bin/false'])) |
710 | + self.assertEqual(inst.calls, [ |
711 | + ('check_call', ['/bin/true']), |
712 | + ('check_call', ['/bin/false']), |
713 | + ]) |
714 | + self.assertEqual(inst.outputs, []) |
715 | + |
716 | + def test_check_output(self): |
717 | + inst = drives.Mockable() |
718 | + self.assertEqual(inst.check_output(['/bin/echo', 'foobar']), b'foobar\n') |
719 | + self.assertEqual(inst.calls, []) |
720 | + self.assertEqual(inst.outputs, []) |
721 | + with self.assertRaises(subprocess.CalledProcessError) as cm: |
722 | + inst.check_output(['/bin/false', 'stuff']) |
723 | + self.assertEqual(cm.exception.cmd, ['/bin/false', 'stuff']) |
724 | + self.assertEqual(cm.exception.returncode, 1) |
725 | + self.assertEqual(inst.calls, []) |
726 | + self.assertEqual(inst.outputs, []) |
727 | + |
728 | + inst.reset(mocking=True, outputs=[b'foo', b'bar']) |
729 | + self.assertEqual(inst.check_output(['/bin/echo', 'stuff']), b'foo') |
730 | + self.assertEqual(inst.calls, [ |
731 | + ('check_output', ['/bin/echo', 'stuff']), |
732 | + ]) |
733 | + self.assertEqual(inst.outputs, [b'bar']) |
734 | + self.assertEqual(inst.check_output(['/bin/false', 'stuff']), b'bar') |
735 | + self.assertEqual(inst.calls, [ |
736 | + ('check_output', ['/bin/echo', 'stuff']), |
737 | + ('check_output', ['/bin/false', 'stuff']), |
738 | + ]) |
739 | + self.assertEqual(inst.outputs, []) |
740 | + |
741 | |
742 | class TestDrive(TestCase): |
743 | def test_init(self): |
744 | for dev in ('/dev/sda', '/dev/sdz', '/dev/vda', '/dev/vdz'): |
745 | inst = drives.Drive(dev) |
746 | self.assertIs(inst.dev, dev) |
747 | + self.assertIs(inst.mocking, False) |
748 | + inst = drives.Drive(dev, mocking=True) |
749 | + self.assertIs(inst.dev, dev) |
750 | + self.assertIs(inst.mocking, True) |
751 | with self.assertRaises(ValueError) as cm: |
752 | drives.Drive('/dev/sda1') |
753 | self.assertEqual(str(cm.exception), |
754 | @@ -92,31 +303,157 @@ |
755 | ) |
756 | |
757 | def test_get_partition(self): |
758 | - inst = drives.Drive('/dev/sdb') |
759 | - part = inst.get_partition(1) |
760 | - self.assertIsInstance(part, drives.Partition) |
761 | - self.assertEqual(part.dev, '/dev/sdb1') |
762 | - part = inst.get_partition(2) |
763 | - self.assertIsInstance(part, drives.Partition) |
764 | - self.assertEqual(part.dev, '/dev/sdb2') |
765 | - |
766 | - inst = drives.Drive('/dev/vdz') |
767 | - part = inst.get_partition(8) |
768 | - self.assertIsInstance(part, drives.Partition) |
769 | - self.assertEqual(part.dev, '/dev/vdz8') |
770 | - part = inst.get_partition(9) |
771 | - self.assertIsInstance(part, drives.Partition) |
772 | - self.assertEqual(part.dev, '/dev/vdz9') |
773 | + for base in ('/dev/sd', '/dev/vd'): |
774 | + for letter in string.ascii_lowercase: |
775 | + dev = base + letter |
776 | + for number in range(1, 10): |
777 | + # mocking=True |
778 | + inst = drives.Drive(dev) |
779 | + part = inst.get_partition(number) |
780 | + self.assertIsInstance(part, drives.Partition) |
781 | + self.assertEqual(part.dev, '{}{:d}'.format(dev, number)) |
782 | + self.assertIs(part.mocking, False) |
783 | + # mocking=False |
784 | + inst = drives.Drive(dev, mocking=True) |
785 | + part = inst.get_partition(number) |
786 | + self.assertIsInstance(part, drives.Partition) |
787 | + self.assertEqual(part.dev, '{}{:d}'.format(dev, number)) |
788 | + self.assertIs(part.mocking, True) |
789 | + |
790 | + def test_rereadpt(self): |
791 | + dev = random_drive_dev() |
792 | + inst = drives.Drive(dev, mocking=True) |
793 | + self.assertIsNone(inst.rereadpt()) |
794 | + self.assertEqual(inst.calls, [ |
795 | + ('check_call', ['blockdev', '--rereadpt', dev]), |
796 | + ]) |
797 | + |
798 | + def test_zero(self): |
799 | + dev = random_drive_dev() |
800 | + inst = drives.Drive(dev, mocking=True) |
801 | + self.assertIsNone(inst.zero()) |
802 | + self.assertEqual(inst.calls, [ |
803 | + ('check_call', ['dd', 'if=/dev/zero', 'of={}'.format(dev), 'bs=4M', 'count=1', 'oflag=sync']), |
804 | + ]) |
805 | + |
806 | + def test_parted(self): |
807 | + dev = random_drive_dev() |
808 | + inst = drives.Drive(dev, mocking=True) |
809 | + self.assertEqual(inst.parted(), |
810 | + ['parted', '-s', dev, 'unit', 'MiB'] |
811 | + ) |
812 | + self.assertEqual(inst.calls, []) |
813 | + self.assertEqual(inst.parted('print'), |
814 | + ['parted', '-s', dev, 'unit', 'MiB', 'print'] |
815 | + ) |
816 | + self.assertEqual(inst.calls, []) |
817 | + self.assertEqual(inst.parted('mklabel', 'gpt'), |
818 | + ['parted', '-s', dev, 'unit', 'MiB', 'mklabel', 'gpt'] |
819 | + ) |
820 | + self.assertEqual(inst.calls, []) |
821 | + |
822 | + def test_mklabel(self): |
823 | + dev = random_drive_dev() |
824 | + inst = drives.Drive(dev, mocking=True) |
825 | + self.assertIsNone(inst.mklabel()) |
826 | + self.assertEqual(inst.calls, [ |
827 | + ('check_call', ['parted', '-s', dev, 'unit', 'MiB', 'mklabel', 'gpt']), |
828 | + ]) |
829 | + |
830 | + def test_print(self): |
831 | + dev = random_drive_dev() |
832 | + inst = drives.Drive(dev) |
833 | + marker = random_id() |
834 | + inst.reset(mocking=True, outputs=[marker.encode('utf-8')]) |
835 | + self.assertEqual(inst.print(), marker) |
836 | + self.assertEqual(inst.calls, [ |
837 | + ('check_output', ['parted', '-s', dev, 'unit', 'MiB', 'print']), |
838 | + ]) |
839 | + self.assertEqual(inst.outputs, []) |
840 | + |
841 | + def test_init_partition_table(self): |
842 | + dev = random_drive_dev() |
843 | + inst = drives.Drive(dev) |
844 | + inst.reset(mocking=True, outputs=[PARTED_PRINT.encode('utf-8')]) |
845 | + self.assertIsNone(inst.init_partition_table()) |
846 | + self.assertEqual(inst.calls, [ |
847 | + ('check_call', ['blockdev', '--rereadpt', dev]), |
848 | + ('check_call', ['dd', 'if=/dev/zero', 'of={}'.format(dev), 'bs=4M', 'count=1', 'oflag=sync']), |
849 | + ('check_call', ['blockdev', '--rereadpt', dev]), |
850 | + ('check_call', ['parted', '-s', dev, 'unit', 'MiB', 'mklabel', 'gpt']), |
851 | + ('check_output', ['parted', '-s', dev, 'unit', 'MiB', 'print']), |
852 | + ]) |
853 | + self.assertEqual(inst.outputs, []) |
854 | + self.assertEqual(inst.size, 2861588) |
855 | + self.assertEqual(inst.index, 0) |
856 | + self.assertEqual(inst.start, 1) |
857 | + self.assertEqual(inst.stop, 2861587) |
858 | + |
859 | + def test_mkpart(self): |
860 | + dev = random_drive_dev() |
861 | + inst = drives.Drive(dev, mocking=True) |
862 | + inst.start = 1 |
863 | + inst.stop = 2861587 |
864 | + self.assertIsNone(inst.mkpart(1, 2861587)) |
865 | + self.assertEqual(inst.calls, [ |
866 | + ('check_call', ['parted', '-s', dev, 'unit', 'MiB', 'mkpart', 'primary', 'ext2', '1', '2861587']), |
867 | + ]) |
868 | + |
869 | + def test_remaining(self): |
870 | + dev = random_drive_dev() |
871 | + inst = drives.Drive(dev, mocking=True) |
872 | + inst.start = 1 |
873 | + inst.stop = 2861587 |
874 | + self.assertEqual(inst.remaining, 2861586) |
875 | + inst.start = 12345 |
876 | + self.assertEqual(inst.remaining, 2849242) |
877 | + inst.stop = 1861587 |
878 | + self.assertEqual(inst.remaining, 1849242) |
879 | + self.assertEqual(inst.calls, []) |
880 | + |
881 | + def test_add_partition(self): |
882 | + dev = random_drive_dev() |
883 | + inst = drives.Drive(dev, mocking=True) |
884 | + inst.index = 0 |
885 | + inst.start = 1 |
886 | + inst.stop = 2861587 |
887 | + |
888 | + part = inst.add_partition(123456) |
889 | + self.assertIsInstance(part, drives.Partition) |
890 | + self.assertIs(part.mocking, True) |
891 | + self.assertEqual(part.dev, '{}{:d}'.format(dev, 1)) |
892 | + self.assertEqual(part.calls, []) |
893 | + self.assertEqual(inst.index, 1) |
894 | + self.assertEqual(inst.calls, [ |
895 | + ('check_call', ['parted', '-s', dev, 'unit', 'MiB', 'mkpart', 'primary', 'ext2', '1', '123457']), |
896 | + ]) |
897 | + self.assertEqual(inst.remaining, 2738130) |
898 | + |
899 | + part = inst.add_partition(2738130) |
900 | + self.assertIsInstance(part, drives.Partition) |
901 | + self.assertIs(part.mocking, True) |
902 | + self.assertEqual(part.dev, '{}{:d}'.format(dev, 2)) |
903 | + self.assertEqual(part.calls, []) |
904 | + self.assertEqual(inst.index, 2) |
905 | + self.assertEqual(inst.calls, [ |
906 | + ('check_call', ['parted', '-s', dev, 'unit', 'MiB', 'mkpart', 'primary', 'ext2', '1', '123457']), |
907 | + ('check_call', ['parted', '-s', dev, 'unit', 'MiB', 'mkpart', 'primary', 'ext2', '123457', '2861587']), |
908 | + ]) |
909 | + self.assertEqual(inst.remaining, 0) |
910 | |
911 | |
912 | class TestPartition(TestCase): |
913 | def test_init(self): |
914 | - for dev in ('/dev/sda1', '/dev/sda9', '/dev/sdz1', '/dev/sdz9'): |
915 | - part = drives.Partition(dev) |
916 | - self.assertIs(part.dev, dev) |
917 | - for dev in ('/dev/vda1', '/dev/vda9', '/dev/vdz1', '/dev/vdz9'): |
918 | - part = drives.Partition(dev) |
919 | - self.assertIs(part.dev, dev) |
920 | + for base in ('/dev/sd', '/dev/vd'): |
921 | + for letter in string.ascii_lowercase: |
922 | + for number in range(1, 10): |
923 | + dev = '{}{}{:d}'.format(base, letter, number) |
924 | + inst = drives.Partition(dev) |
925 | + self.assertIs(inst.dev, dev) |
926 | + self.assertIs(inst.mocking, False) |
927 | + inst = drives.Partition(dev, mocking=True) |
928 | + self.assertIs(inst.dev, dev) |
929 | + self.assertIs(inst.mocking, True) |
930 | |
931 | with self.assertRaises(ValueError) as cm: |
932 | drives.Partition('/dev/sda11') |
933 | @@ -130,3 +467,148 @@ |
934 | "Invalid partition device file: '/dev/sda0'" |
935 | ) |
936 | |
937 | + def test_mkfs_ext4(self): |
938 | + dev = random_partition_dev() |
939 | + inst = drives.Partition(dev, mocking=True) |
940 | + label = random_id(5) |
941 | + store_id = random_id() |
942 | + ext4_uuid = drives.db32_to_uuid(store_id) |
943 | + self.assertIsNone(inst.mkfs_ext4(label, store_id)) |
944 | + self.assertEqual(inst.calls, [ |
945 | + ('check_call', ['mkfs.ext4', dev, '-L', label, '-U', ext4_uuid, '-m', '0']), |
946 | + ]) |
947 | + |
948 | + def test_create_filestore(self): |
949 | + dev = random_partition_dev() |
950 | + inst = drives.Partition(dev, mocking=True) |
951 | + tmp = TempDir() |
952 | + store_id = random_id() |
953 | + ext4_uuid = drives.db32_to_uuid(store_id) |
954 | + label = random_id(5) |
955 | + serial = random_id(10) |
956 | + kw = { |
957 | + 'drive_serial': serial, |
958 | + 'filesystem_type': 'ext4', |
959 | + 'filesystem_uuid': ext4_uuid, |
960 | + 'filesystem_label': label, |
961 | + } |
962 | + doc = inst.create_filestore(tmp.dir, store_id, 1, **kw) |
963 | + self.assertIsInstance(doc, dict) |
964 | + self.assertEqual(set(doc), set([ |
965 | + '_id', |
966 | + 'time', |
967 | + 'type', |
968 | + 'plugin', |
969 | + 'copies', |
970 | + 'drive_serial', |
971 | + 'filesystem_type', |
972 | + 'filesystem_uuid', |
973 | + 'filesystem_label', |
974 | + ])) |
975 | + self.assertEqual(doc, { |
976 | + '_id': store_id, |
977 | + 'time': doc['time'], |
978 | + 'type': 'dmedia/store', |
979 | + 'plugin': 'filestore', |
980 | + 'copies': 1, |
981 | + 'drive_serial': serial, |
982 | + 'filesystem_type': 'ext4', |
983 | + 'filesystem_uuid': ext4_uuid, |
984 | + 'filesystem_label': label, |
985 | + }) |
986 | + fs = FileStore(tmp.dir, store_id) |
987 | + self.assertEqual(fs.doc, doc) |
988 | + del fs |
989 | + self.assertEqual(inst.calls, [ |
990 | + ('check_call', ['mount', dev, tmp.dir]), |
991 | + ('check_call', ['chmod', '0777', tmp.dir]), |
992 | + ('check_call', ['umount', dev]), |
993 | + ]) |
994 | + |
995 | + |
996 | +class TestDeviceNotFound(TestCase): |
997 | + def test_init(self): |
998 | + dev = '/dev/{}'.format(random_id()) |
999 | + inst = drives.DeviceNotFound(dev) |
1000 | + self.assertIs(inst.dev, dev) |
1001 | + self.assertEqual(str(inst), 'No such device: {!r}'.format(dev)) |
1002 | + |
1003 | + |
1004 | +class TestDevices(TestCase): |
1005 | + def test_init(self): |
1006 | + inst = drives.Devices() |
1007 | + self.assertIsInstance(inst.udev_client, GUdev.Client) |
1008 | + |
1009 | + marker = random_id() |
1010 | + |
1011 | + class Subclass(drives.Devices): |
1012 | + def get_udev_client(self): |
1013 | + return marker |
1014 | + |
1015 | + inst = Subclass() |
1016 | + self.assertIs(inst.udev_client, marker) |
1017 | + |
1018 | + def test_get_device(self): |
1019 | + inst = drives.Devices() |
1020 | + dev = '/dev/nopenopenope' |
1021 | + with self.assertRaises(drives.DeviceNotFound) as cm: |
1022 | + inst.get_device(dev) |
1023 | + self.assertIs(cm.exception.dev, dev) |
1024 | + self.assertEqual(str(cm.exception), |
1025 | + "No such device: '/dev/nopenopenope'" |
1026 | + ) |
1027 | + |
1028 | + def test_get_drive_info(self): |
1029 | + inst = drives.Devices() |
1030 | + for device in inst.iter_drives(): |
1031 | + dev = device.get_device_file() |
1032 | + self.assertEqual( |
1033 | + inst.get_drive_info(dev), |
1034 | + drives.get_drive_info(device), |
1035 | + ) |
1036 | + with self.assertRaises(ValueError) as cm: |
1037 | + inst.get_drive_info('/dev/sdaa') |
1038 | + self.assertEqual(str(cm.exception), |
1039 | + "Invalid drive device file: '/dev/sdaa'" |
1040 | + ) |
1041 | + |
1042 | + def test_get_partition_info(self): |
1043 | + inst = drives.Devices() |
1044 | + for device in inst.iter_partitions(): |
1045 | + dev = device.get_device_file() |
1046 | + self.assertEqual( |
1047 | + inst.get_partition_info(dev), |
1048 | + drives.get_partition_info(device), |
1049 | + ) |
1050 | + with self.assertRaises(ValueError) as cm: |
1051 | + inst.get_partition_info('/dev/sda11') |
1052 | + self.assertEqual(str(cm.exception), |
1053 | + "Invalid partition device file: '/dev/sda11'" |
1054 | + ) |
1055 | + |
1056 | + def test_iter_drives(self): |
1057 | + inst = drives.Devices() |
1058 | + for drive in inst.iter_drives(): |
1059 | + self.assertIsInstance(drive, GUdev.Device) |
1060 | + self.assertEqual(drive.get_devtype(), 'disk') |
1061 | + self.assertTrue(drives.VALID_DRIVE.match(drive.get_device_file())) |
1062 | + |
1063 | + def test_iter_partitions(self): |
1064 | + inst = drives.Devices() |
1065 | + for drive in inst.iter_partitions(): |
1066 | + self.assertIsInstance(drive, GUdev.Device) |
1067 | + self.assertEqual(drive.get_devtype(), 'partition') |
1068 | + self.assertTrue(drives.VALID_PARTITION.match(drive.get_device_file())) |
1069 | + |
1070 | + def test_get_parentdir_info(self): |
1071 | + inst = drives.Devices() |
1072 | + info = inst.get_parentdir_info('/') |
1073 | + self.assertIsInstance(info, dict) |
1074 | + if info != {}: |
1075 | + self.assertEqual(set(info), set(EXPECTED_PARTITION_KEYS)) |
1076 | + |
1077 | + def test_get_info(self): |
1078 | + inst = drives.Devices() |
1079 | + info = inst.get_info() |
1080 | + self.assertIsInstance(info, dict) |
1081 | + self.assertEqual(set(info), set(['drives', 'partitions'])) |
looks fine