circup 2.0.1__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.
- circup/__init__.py +26 -0
- circup/backends.py +957 -0
- circup/bundle.py +170 -0
- circup/command_utils.py +627 -0
- circup/commands.py +723 -0
- circup/config/bundle_config.json +5 -0
- circup/config/bundle_config.json.license +3 -0
- circup/logging.py +33 -0
- circup/module.py +209 -0
- circup/shared.py +218 -0
- circup-2.0.1.dist-info/LICENSE +21 -0
- circup-2.0.1.dist-info/METADATA +357 -0
- circup-2.0.1.dist-info/RECORD +16 -0
- circup-2.0.1.dist-info/WHEEL +5 -0
- circup-2.0.1.dist-info/entry_points.txt +2 -0
- circup-2.0.1.dist-info/top_level.txt +1 -0
circup/backends.py
ADDED
|
@@ -0,0 +1,957 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2019 Nicholas Tollervey, written for Adafruit Industries
|
|
2
|
+
# SPDX-FileCopyrightText: 2023 Tim Cocks, written for Adafruit Industries
|
|
3
|
+
#
|
|
4
|
+
# SPDX-License-Identifier: MIT
|
|
5
|
+
"""
|
|
6
|
+
Backend classes that represent interfaces to physical devices.
|
|
7
|
+
"""
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import sys
|
|
11
|
+
import socket
|
|
12
|
+
import tempfile
|
|
13
|
+
from urllib.parse import urlparse, urljoin
|
|
14
|
+
import click
|
|
15
|
+
import requests
|
|
16
|
+
from requests.adapters import HTTPAdapter
|
|
17
|
+
from requests.auth import HTTPBasicAuth
|
|
18
|
+
|
|
19
|
+
from circup.shared import DATA_DIR, BAD_FILE_FORMAT, extract_metadata, _get_modules_file
|
|
20
|
+
|
|
21
|
+
#: The location to store a local copy of code.py for use with --auto and
|
|
22
|
+
# web workflow
|
|
23
|
+
LOCAL_CODE_PY_COPY = os.path.join(DATA_DIR, "code.tmp.py")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Backend:
|
|
27
|
+
"""
|
|
28
|
+
Backend parent class to be extended for workflow specific
|
|
29
|
+
implementations
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, logger, version_override=None):
|
|
33
|
+
self.device_location = None
|
|
34
|
+
self.LIB_DIR_PATH = None
|
|
35
|
+
self.version_override = version_override
|
|
36
|
+
self.logger = logger
|
|
37
|
+
|
|
38
|
+
def get_circuitpython_version(self):
|
|
39
|
+
"""
|
|
40
|
+
Must be overridden by subclass for implementation!
|
|
41
|
+
|
|
42
|
+
Returns the version number of CircuitPython running on the board connected
|
|
43
|
+
via ``device_url``, along with the board ID.
|
|
44
|
+
|
|
45
|
+
:param str device_location: http based device URL or local file path.
|
|
46
|
+
:return: A tuple with the version string for CircuitPython and the board ID string.
|
|
47
|
+
"""
|
|
48
|
+
raise NotImplementedError
|
|
49
|
+
|
|
50
|
+
def _get_modules(self, device_lib_path):
|
|
51
|
+
"""
|
|
52
|
+
To be overridden by subclass
|
|
53
|
+
"""
|
|
54
|
+
raise NotImplementedError
|
|
55
|
+
|
|
56
|
+
def get_modules(self, device_url):
|
|
57
|
+
"""
|
|
58
|
+
Get a dictionary containing metadata about all the Python modules found in
|
|
59
|
+
the referenced path.
|
|
60
|
+
|
|
61
|
+
:param str device_url: URL to be used to find modules.
|
|
62
|
+
:return: A dictionary containing metadata about the found modules.
|
|
63
|
+
"""
|
|
64
|
+
return self._get_modules(device_url)
|
|
65
|
+
|
|
66
|
+
def get_device_versions(self):
|
|
67
|
+
"""
|
|
68
|
+
Returns a dictionary of metadata from modules on the connected device.
|
|
69
|
+
|
|
70
|
+
:param str device_url: URL for the device.
|
|
71
|
+
:return: A dictionary of metadata about the modules available on the
|
|
72
|
+
connected device.
|
|
73
|
+
"""
|
|
74
|
+
return self.get_modules(os.path.join(self.device_location, self.LIB_DIR_PATH))
|
|
75
|
+
|
|
76
|
+
def _create_library_directory(self, device_path, library_path):
|
|
77
|
+
"""
|
|
78
|
+
To be overridden by subclass
|
|
79
|
+
"""
|
|
80
|
+
raise NotImplementedError
|
|
81
|
+
|
|
82
|
+
def install_module_py(self, metadata, location=None):
|
|
83
|
+
"""
|
|
84
|
+
To be overridden by subclass
|
|
85
|
+
"""
|
|
86
|
+
raise NotImplementedError
|
|
87
|
+
|
|
88
|
+
def install_module_mpy(self, bundle, metadata):
|
|
89
|
+
"""
|
|
90
|
+
To be overridden by subclass
|
|
91
|
+
"""
|
|
92
|
+
raise NotImplementedError
|
|
93
|
+
|
|
94
|
+
def copy_file(self, target_file, location_to_paste):
|
|
95
|
+
"""Paste a copy of the specified file at the location given
|
|
96
|
+
To be overridden by subclass
|
|
97
|
+
"""
|
|
98
|
+
raise NotImplementedError
|
|
99
|
+
|
|
100
|
+
# pylint: disable=too-many-locals,too-many-branches,too-many-arguments,too-many-nested-blocks,too-many-statements
|
|
101
|
+
def install_module(
|
|
102
|
+
self, device_path, device_modules, name, pyext, mod_names, upgrade=False
|
|
103
|
+
): # pragma: no cover
|
|
104
|
+
"""
|
|
105
|
+
Finds a connected device and installs a given module name if it
|
|
106
|
+
is available in the current module bundle and is not already
|
|
107
|
+
installed on the device.
|
|
108
|
+
TODO: There is currently no check for the version.
|
|
109
|
+
|
|
110
|
+
:param str device_path: The path to the connected board.
|
|
111
|
+
:param list(dict) device_modules: List of module metadata from the device.
|
|
112
|
+
:param str name: Name of module to install
|
|
113
|
+
:param bool pyext: Boolean to specify if the module should be installed from
|
|
114
|
+
source or from a pre-compiled module
|
|
115
|
+
:param mod_names: Dictionary of metadata from modules that can be generated
|
|
116
|
+
with get_bundle_versions()
|
|
117
|
+
:param bool upgrade: Upgrade the specified modules if they're already installed.
|
|
118
|
+
"""
|
|
119
|
+
local_path = None
|
|
120
|
+
if os.path.exists(name):
|
|
121
|
+
# local file exists use that.
|
|
122
|
+
local_path = name
|
|
123
|
+
name = local_path.split(os.path.sep)[-1]
|
|
124
|
+
name = name.replace(".py", "").replace(".mpy", "")
|
|
125
|
+
click.echo(f"Installing from local path: {local_path}")
|
|
126
|
+
|
|
127
|
+
if not name:
|
|
128
|
+
click.echo("No module name(s) provided.")
|
|
129
|
+
return
|
|
130
|
+
if name in mod_names or local_path is not None:
|
|
131
|
+
|
|
132
|
+
# Grab device modules to check if module already installed
|
|
133
|
+
if name in device_modules:
|
|
134
|
+
if not upgrade:
|
|
135
|
+
# skip already installed modules if no -upgrade flag
|
|
136
|
+
click.echo("'{}' is already installed.".format(name))
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
# uninstall the module before installing
|
|
140
|
+
name = name.lower()
|
|
141
|
+
_mod_names = {}
|
|
142
|
+
for module_item, _metadata in device_modules.items():
|
|
143
|
+
_mod_names[module_item.replace(".py", "").lower()] = _metadata
|
|
144
|
+
if name in _mod_names:
|
|
145
|
+
_metadata = _mod_names[name]
|
|
146
|
+
module_path = _metadata["path"]
|
|
147
|
+
self.uninstall(device_path, module_path)
|
|
148
|
+
|
|
149
|
+
new_module_size = 0
|
|
150
|
+
library_path = (
|
|
151
|
+
os.path.join(device_path, self.LIB_DIR_PATH)
|
|
152
|
+
if not isinstance(self, WebBackend)
|
|
153
|
+
else urljoin(device_path, self.LIB_DIR_PATH)
|
|
154
|
+
)
|
|
155
|
+
if local_path is None:
|
|
156
|
+
metadata = mod_names[name]
|
|
157
|
+
bundle = metadata["bundle"]
|
|
158
|
+
else:
|
|
159
|
+
metadata = {"path": local_path}
|
|
160
|
+
|
|
161
|
+
new_module_size = os.path.getsize(metadata["path"])
|
|
162
|
+
if os.path.isdir(metadata["path"]):
|
|
163
|
+
# pylint: disable=unused-variable
|
|
164
|
+
for dirpath, dirnames, filenames in os.walk(metadata["path"]):
|
|
165
|
+
for f in filenames:
|
|
166
|
+
fp = os.path.join(dirpath, f)
|
|
167
|
+
try:
|
|
168
|
+
if not os.path.islink(fp): # Ignore symbolic links
|
|
169
|
+
new_module_size += os.path.getsize(fp)
|
|
170
|
+
else:
|
|
171
|
+
self.logger.warning(
|
|
172
|
+
f"Skipping symbolic link in space calculation: {fp}"
|
|
173
|
+
)
|
|
174
|
+
except OSError as e:
|
|
175
|
+
self.logger.error(
|
|
176
|
+
f"Error: {e} - Skipping file in space calculation: {fp}"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if self.get_free_space() < new_module_size:
|
|
180
|
+
self.logger.error(
|
|
181
|
+
f"Aborted installing module {name} - "
|
|
182
|
+
f"not enough free space ({new_module_size} < {self.get_free_space()})"
|
|
183
|
+
)
|
|
184
|
+
click.secho(
|
|
185
|
+
f"Aborted installing module {name} - "
|
|
186
|
+
f"not enough free space ({new_module_size} < {self.get_free_space()})",
|
|
187
|
+
fg="red",
|
|
188
|
+
)
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
# Create the library directory first.
|
|
192
|
+
self._create_library_directory(device_path, library_path)
|
|
193
|
+
if local_path is None:
|
|
194
|
+
if pyext:
|
|
195
|
+
# Use Python source for module.
|
|
196
|
+
self.install_module_py(metadata)
|
|
197
|
+
else:
|
|
198
|
+
# Use pre-compiled mpy modules.
|
|
199
|
+
self.install_module_mpy(bundle, metadata)
|
|
200
|
+
else:
|
|
201
|
+
self.copy_file(metadata["path"], "lib")
|
|
202
|
+
click.echo("Installed '{}'.".format(name))
|
|
203
|
+
else:
|
|
204
|
+
click.echo("Unknown module named, '{}'.".format(name))
|
|
205
|
+
|
|
206
|
+
# def libraries_from_imports(self, code_py, mod_names):
|
|
207
|
+
# """
|
|
208
|
+
# To be overridden by subclass
|
|
209
|
+
# """
|
|
210
|
+
# raise NotImplementedError
|
|
211
|
+
|
|
212
|
+
def uninstall(self, device_path, module_path):
|
|
213
|
+
"""
|
|
214
|
+
To be overridden by subclass
|
|
215
|
+
"""
|
|
216
|
+
raise NotImplementedError
|
|
217
|
+
|
|
218
|
+
def update(self, module):
|
|
219
|
+
"""
|
|
220
|
+
To be overridden by subclass
|
|
221
|
+
"""
|
|
222
|
+
raise NotImplementedError
|
|
223
|
+
|
|
224
|
+
def get_file_path(self, filename):
|
|
225
|
+
"""
|
|
226
|
+
To be overridden by subclass
|
|
227
|
+
"""
|
|
228
|
+
raise NotImplementedError
|
|
229
|
+
|
|
230
|
+
def get_free_space(self):
|
|
231
|
+
"""
|
|
232
|
+
To be overridden by subclass
|
|
233
|
+
"""
|
|
234
|
+
raise NotImplementedError
|
|
235
|
+
|
|
236
|
+
def is_device_present(self):
|
|
237
|
+
"""
|
|
238
|
+
To be overriden by subclass
|
|
239
|
+
"""
|
|
240
|
+
raise NotImplementedError
|
|
241
|
+
|
|
242
|
+
@staticmethod
|
|
243
|
+
def parse_boot_out_file(boot_out_contents):
|
|
244
|
+
"""
|
|
245
|
+
Parse the contents of boot_out.txt
|
|
246
|
+
Returns: circuitpython version and board id
|
|
247
|
+
"""
|
|
248
|
+
lines = boot_out_contents.split("\n")
|
|
249
|
+
version_line = lines[0]
|
|
250
|
+
circuit_python = version_line.split(";")[0].split(" ")[-3]
|
|
251
|
+
board_line = lines[1]
|
|
252
|
+
if board_line.startswith("Board ID:"):
|
|
253
|
+
board_id = board_line[9:].strip()
|
|
254
|
+
else:
|
|
255
|
+
board_id = ""
|
|
256
|
+
return circuit_python, board_id
|
|
257
|
+
|
|
258
|
+
def file_exists(self, filepath):
|
|
259
|
+
"""
|
|
260
|
+
To be overriden by subclass
|
|
261
|
+
"""
|
|
262
|
+
raise NotImplementedError
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _writeable_error():
|
|
266
|
+
click.secho(
|
|
267
|
+
"CircuitPython Web Workflow Device not writable\n - "
|
|
268
|
+
"Remount storage as writable to device (not PC)",
|
|
269
|
+
fg="red",
|
|
270
|
+
)
|
|
271
|
+
sys.exit(1)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class WebBackend(Backend):
|
|
275
|
+
"""
|
|
276
|
+
Backend for interacting with a device via Web Workflow
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
def __init__( # pylint: disable=too-many-arguments
|
|
280
|
+
self, host, password, logger, timeout=10, version_override=None
|
|
281
|
+
):
|
|
282
|
+
super().__init__(logger)
|
|
283
|
+
if password is None:
|
|
284
|
+
raise ValueError("--host needs --password")
|
|
285
|
+
|
|
286
|
+
# pylint: disable=no-member
|
|
287
|
+
# verify hostname/address
|
|
288
|
+
try:
|
|
289
|
+
socket.getaddrinfo(host, 80, proto=socket.IPPROTO_TCP)
|
|
290
|
+
except socket.gaierror as exc:
|
|
291
|
+
raise RuntimeError(
|
|
292
|
+
"Invalid host: {}.".format(host) + " You should remove the 'http://'"
|
|
293
|
+
if "http://" in host or "https://" in host
|
|
294
|
+
else "Could not find or connect to specified device"
|
|
295
|
+
) from exc
|
|
296
|
+
|
|
297
|
+
self.FS_PATH = "fs/"
|
|
298
|
+
self.LIB_DIR_PATH = f"{self.FS_PATH}lib/"
|
|
299
|
+
self.host = host
|
|
300
|
+
self.password = password
|
|
301
|
+
self.device_location = f"http://:{self.password}@{self.host}"
|
|
302
|
+
|
|
303
|
+
self.session = requests.Session()
|
|
304
|
+
self.session.mount(self.device_location, HTTPAdapter(max_retries=5))
|
|
305
|
+
self.library_path = self.device_location + "/" + self.LIB_DIR_PATH
|
|
306
|
+
self.timeout = timeout
|
|
307
|
+
self.version_override = version_override
|
|
308
|
+
|
|
309
|
+
def install_file_http(self, source, location=None):
|
|
310
|
+
"""
|
|
311
|
+
Install file to device using web workflow.
|
|
312
|
+
:param source source file.
|
|
313
|
+
:param location the location on the device to copy the source
|
|
314
|
+
directory in to. If omitted is CIRCUITPY/lib/ used.
|
|
315
|
+
"""
|
|
316
|
+
file_name = source.split(os.path.sep)
|
|
317
|
+
file_name = file_name[-2] if file_name[-1] == "" else file_name[-1]
|
|
318
|
+
|
|
319
|
+
if location is None:
|
|
320
|
+
target = self.device_location + "/" + self.LIB_DIR_PATH + file_name
|
|
321
|
+
else:
|
|
322
|
+
target = self.device_location + "/" + self.FS_PATH + location + file_name
|
|
323
|
+
|
|
324
|
+
auth = HTTPBasicAuth("", self.password)
|
|
325
|
+
|
|
326
|
+
with open(source, "rb") as fp:
|
|
327
|
+
r = self.session.put(target, fp.read(), auth=auth, timeout=self.timeout)
|
|
328
|
+
if r.status_code == 409:
|
|
329
|
+
_writeable_error()
|
|
330
|
+
r.raise_for_status()
|
|
331
|
+
|
|
332
|
+
def install_dir_http(self, source, location=None):
|
|
333
|
+
"""
|
|
334
|
+
Install directory to device using web workflow.
|
|
335
|
+
:param source source directory.
|
|
336
|
+
:param location the location on the device to copy the source
|
|
337
|
+
directory in to. If omitted is CIRCUITPY/lib/ used.
|
|
338
|
+
"""
|
|
339
|
+
mod_name = source.split(os.path.sep)
|
|
340
|
+
mod_name = mod_name[-2] if mod_name[-1] == "" else mod_name[-1]
|
|
341
|
+
if location is None:
|
|
342
|
+
target = self.device_location + "/" + self.LIB_DIR_PATH + mod_name
|
|
343
|
+
else:
|
|
344
|
+
target = self.device_location + "/" + self.FS_PATH + location + mod_name
|
|
345
|
+
target = target + "/" if target[:-1] != "/" else target
|
|
346
|
+
url = urlparse(target)
|
|
347
|
+
auth = HTTPBasicAuth("", url.password)
|
|
348
|
+
|
|
349
|
+
# Create the top level directory.
|
|
350
|
+
with self.session.put(target, auth=auth, timeout=self.timeout) as r:
|
|
351
|
+
if r.status_code == 409:
|
|
352
|
+
_writeable_error()
|
|
353
|
+
r.raise_for_status()
|
|
354
|
+
|
|
355
|
+
# Traverse the directory structure and create the directories/files.
|
|
356
|
+
for root, dirs, files in os.walk(source):
|
|
357
|
+
rel_path = os.path.relpath(root, source)
|
|
358
|
+
if rel_path == ".":
|
|
359
|
+
rel_path = ""
|
|
360
|
+
for name in dirs:
|
|
361
|
+
path_to_create = (
|
|
362
|
+
urljoin(
|
|
363
|
+
urljoin(target, rel_path + "/", allow_fragments=False),
|
|
364
|
+
name,
|
|
365
|
+
allow_fragments=False,
|
|
366
|
+
)
|
|
367
|
+
if rel_path != ""
|
|
368
|
+
else urljoin(target, name, allow_fragments=False)
|
|
369
|
+
)
|
|
370
|
+
path_to_create = (
|
|
371
|
+
path_to_create + "/"
|
|
372
|
+
if path_to_create[:-1] != "/"
|
|
373
|
+
else path_to_create
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
with self.session.put(
|
|
377
|
+
path_to_create, auth=auth, timeout=self.timeout
|
|
378
|
+
) as r:
|
|
379
|
+
if r.status_code == 409:
|
|
380
|
+
_writeable_error()
|
|
381
|
+
r.raise_for_status()
|
|
382
|
+
for name in files:
|
|
383
|
+
with open(os.path.join(root, name), "rb") as fp:
|
|
384
|
+
path_to_create = (
|
|
385
|
+
urljoin(
|
|
386
|
+
urljoin(target, rel_path + "/", allow_fragments=False),
|
|
387
|
+
name,
|
|
388
|
+
allow_fragments=False,
|
|
389
|
+
)
|
|
390
|
+
if rel_path != ""
|
|
391
|
+
else urljoin(target, name, allow_fragments=False)
|
|
392
|
+
)
|
|
393
|
+
with self.session.put(
|
|
394
|
+
path_to_create, fp.read(), auth=auth, timeout=self.timeout
|
|
395
|
+
) as r:
|
|
396
|
+
if r.status_code == 409:
|
|
397
|
+
_writeable_error()
|
|
398
|
+
r.raise_for_status()
|
|
399
|
+
|
|
400
|
+
def get_circuitpython_version(self):
|
|
401
|
+
"""
|
|
402
|
+
Returns the version number of CircuitPython running on the board connected
|
|
403
|
+
via ``device_path``, along with the board ID. This is obtained using
|
|
404
|
+
RESTful API from the /cp/version.json URL.
|
|
405
|
+
|
|
406
|
+
:return: A tuple with the version string for CircuitPython and the board ID string.
|
|
407
|
+
"""
|
|
408
|
+
if self.version_override is not None:
|
|
409
|
+
return self.version_override
|
|
410
|
+
|
|
411
|
+
# pylint: disable=arguments-renamed
|
|
412
|
+
with self.session.get(
|
|
413
|
+
self.device_location + "/cp/version.json", timeout=self.timeout
|
|
414
|
+
) as r:
|
|
415
|
+
# pylint: disable=no-member
|
|
416
|
+
if r.status_code != requests.codes.ok:
|
|
417
|
+
click.secho(
|
|
418
|
+
f" Unable to get version from {self.device_location}: {r.status_code}",
|
|
419
|
+
fg="red",
|
|
420
|
+
)
|
|
421
|
+
sys.exit(1)
|
|
422
|
+
# pylint: enable=no-member
|
|
423
|
+
ver_json = r.json()
|
|
424
|
+
return ver_json.get("version"), ver_json.get("board_id")
|
|
425
|
+
|
|
426
|
+
def _get_modules(self, device_lib_path):
|
|
427
|
+
return self._get_modules_http(device_lib_path)
|
|
428
|
+
|
|
429
|
+
def _get_modules_http(self, url):
|
|
430
|
+
"""
|
|
431
|
+
Get a dictionary containing metadata about all the Python modules found using
|
|
432
|
+
the referenced URL.
|
|
433
|
+
|
|
434
|
+
:param str url: URL for the modules.
|
|
435
|
+
:return: A dictionary containing metadata about the found modules.
|
|
436
|
+
"""
|
|
437
|
+
result = {}
|
|
438
|
+
u = urlparse(url)
|
|
439
|
+
auth = HTTPBasicAuth("", u.password)
|
|
440
|
+
with self.session.get(
|
|
441
|
+
url, auth=auth, headers={"Accept": "application/json"}, timeout=self.timeout
|
|
442
|
+
) as r:
|
|
443
|
+
r.raise_for_status()
|
|
444
|
+
|
|
445
|
+
directory_mods = []
|
|
446
|
+
single_file_mods = []
|
|
447
|
+
|
|
448
|
+
for entry in r.json()["files"]:
|
|
449
|
+
|
|
450
|
+
entry_name = entry.get("name")
|
|
451
|
+
if entry.get("directory"):
|
|
452
|
+
directory_mods.append(entry_name)
|
|
453
|
+
else:
|
|
454
|
+
if entry_name.endswith(".py") or entry_name.endswith(".mpy"):
|
|
455
|
+
single_file_mods.append(entry_name)
|
|
456
|
+
|
|
457
|
+
self._get_modules_http_single_mods(auth, result, single_file_mods, url)
|
|
458
|
+
self._get_modules_http_dir_mods(auth, directory_mods, result, url)
|
|
459
|
+
|
|
460
|
+
return result
|
|
461
|
+
|
|
462
|
+
def _get_modules_http_dir_mods(self, auth, directory_mods, result, url):
|
|
463
|
+
# pylint: disable=too-many-locals
|
|
464
|
+
"""
|
|
465
|
+
Builds result dictionary with keys containing module names and values containing a
|
|
466
|
+
dictionary with metadata bout the module like version, compatibility, mpy or not etc.
|
|
467
|
+
|
|
468
|
+
:param auth HTTP authentication.
|
|
469
|
+
:param directory_mods list of modules.
|
|
470
|
+
:param result dictionary for the result.
|
|
471
|
+
:param url: URL of the device.
|
|
472
|
+
"""
|
|
473
|
+
for dm in directory_mods:
|
|
474
|
+
if str(urlparse(dm).scheme).lower() not in ("http", "https"):
|
|
475
|
+
dm_url = url + dm + "/"
|
|
476
|
+
else:
|
|
477
|
+
dm_url = dm
|
|
478
|
+
|
|
479
|
+
with self.session.get(
|
|
480
|
+
dm_url,
|
|
481
|
+
auth=auth,
|
|
482
|
+
headers={"Accept": "application/json"},
|
|
483
|
+
timeout=self.timeout,
|
|
484
|
+
) as r:
|
|
485
|
+
r.raise_for_status()
|
|
486
|
+
mpy = False
|
|
487
|
+
|
|
488
|
+
for entry in r.json()["files"]:
|
|
489
|
+
entry_name = entry.get("name")
|
|
490
|
+
if not entry.get("directory") and (
|
|
491
|
+
entry_name.endswith(".py") or entry_name.endswith(".mpy")
|
|
492
|
+
):
|
|
493
|
+
if entry_name.endswith(".mpy"):
|
|
494
|
+
mpy = True
|
|
495
|
+
|
|
496
|
+
with self.session.get(
|
|
497
|
+
dm_url + entry_name, auth=auth, timeout=self.timeout
|
|
498
|
+
) as rr:
|
|
499
|
+
rr.raise_for_status()
|
|
500
|
+
idx = entry_name.rfind(".")
|
|
501
|
+
with tempfile.NamedTemporaryFile(
|
|
502
|
+
prefix=entry_name[:idx] + "-",
|
|
503
|
+
suffix=entry_name[idx:],
|
|
504
|
+
delete=False,
|
|
505
|
+
) as fp:
|
|
506
|
+
fp.write(rr.content)
|
|
507
|
+
tmp_name = fp.name
|
|
508
|
+
metadata = extract_metadata(tmp_name, self.logger)
|
|
509
|
+
os.remove(tmp_name)
|
|
510
|
+
if "__version__" in metadata:
|
|
511
|
+
metadata["path"] = dm_url
|
|
512
|
+
result[dm] = metadata
|
|
513
|
+
# break now if any of the submodules has a bad format
|
|
514
|
+
if metadata["__version__"] == BAD_FILE_FORMAT:
|
|
515
|
+
break
|
|
516
|
+
|
|
517
|
+
if result.get(dm) is None:
|
|
518
|
+
result[dm] = {"path": dm_url, "mpy": mpy}
|
|
519
|
+
|
|
520
|
+
def _get_modules_http_single_mods(self, auth, result, single_file_mods, url):
|
|
521
|
+
"""
|
|
522
|
+
:param auth HTTP authentication.
|
|
523
|
+
:param single_file_mods list of modules.
|
|
524
|
+
:param result dictionary for the result.
|
|
525
|
+
:param url: URL of the device.
|
|
526
|
+
"""
|
|
527
|
+
for sfm in single_file_mods:
|
|
528
|
+
if str(urlparse(sfm).scheme).lower() not in ("http", "https"):
|
|
529
|
+
sfm_url = url + sfm
|
|
530
|
+
else:
|
|
531
|
+
sfm_url = sfm
|
|
532
|
+
with self.session.get(sfm_url, auth=auth, timeout=self.timeout) as r:
|
|
533
|
+
r.raise_for_status()
|
|
534
|
+
idx = sfm.rfind(".")
|
|
535
|
+
with tempfile.NamedTemporaryFile(
|
|
536
|
+
prefix=sfm[:idx] + "-", suffix=sfm[idx:], delete=False
|
|
537
|
+
) as fp:
|
|
538
|
+
fp.write(r.content)
|
|
539
|
+
tmp_name = fp.name
|
|
540
|
+
metadata = extract_metadata(tmp_name, self.logger)
|
|
541
|
+
os.remove(tmp_name)
|
|
542
|
+
metadata["path"] = sfm_url
|
|
543
|
+
result[sfm[:idx]] = metadata
|
|
544
|
+
|
|
545
|
+
def _create_library_directory(self, device_path, library_path):
|
|
546
|
+
url = urlparse(device_path)
|
|
547
|
+
auth = HTTPBasicAuth("", url.password)
|
|
548
|
+
with self.session.put(library_path, auth=auth, timeout=self.timeout) as r:
|
|
549
|
+
if r.status_code == 409:
|
|
550
|
+
_writeable_error()
|
|
551
|
+
r.raise_for_status()
|
|
552
|
+
|
|
553
|
+
def copy_file(self, target_file, location_to_paste):
|
|
554
|
+
if os.path.isdir(target_file):
|
|
555
|
+
create_directory_url = urljoin(
|
|
556
|
+
self.device_location,
|
|
557
|
+
"/".join(("fs", location_to_paste, target_file, "")),
|
|
558
|
+
)
|
|
559
|
+
self._create_library_directory(self.device_location, create_directory_url)
|
|
560
|
+
self.install_dir_http(target_file)
|
|
561
|
+
else:
|
|
562
|
+
self.install_file_http(target_file)
|
|
563
|
+
|
|
564
|
+
def install_module_mpy(self, bundle, metadata):
|
|
565
|
+
"""
|
|
566
|
+
:param bundle library bundle.
|
|
567
|
+
:param library_path library path
|
|
568
|
+
:param metadata dictionary.
|
|
569
|
+
"""
|
|
570
|
+
module_name = os.path.basename(metadata["path"]).replace(".py", ".mpy")
|
|
571
|
+
if not module_name:
|
|
572
|
+
# Must be a directory based module.
|
|
573
|
+
module_name = os.path.basename(os.path.dirname(metadata["path"]))
|
|
574
|
+
major_version = self.get_circuitpython_version()[0].split(".")[0]
|
|
575
|
+
bundle_platform = "{}mpy".format(major_version)
|
|
576
|
+
bundle_path = os.path.join(bundle.lib_dir(bundle_platform), module_name)
|
|
577
|
+
if os.path.isdir(bundle_path):
|
|
578
|
+
|
|
579
|
+
self.install_dir_http(bundle_path)
|
|
580
|
+
|
|
581
|
+
elif os.path.isfile(bundle_path):
|
|
582
|
+
self.install_file_http(bundle_path)
|
|
583
|
+
|
|
584
|
+
else:
|
|
585
|
+
raise IOError("Cannot find compiled version of module.")
|
|
586
|
+
|
|
587
|
+
# pylint: enable=too-many-locals,too-many-branches
|
|
588
|
+
def install_module_py(self, metadata, location=None):
|
|
589
|
+
"""
|
|
590
|
+
:param library_path library path
|
|
591
|
+
:param metadata dictionary.
|
|
592
|
+
"""
|
|
593
|
+
|
|
594
|
+
source_path = metadata["path"] # Path to Python source version.
|
|
595
|
+
if os.path.isdir(source_path):
|
|
596
|
+
self.install_dir_http(source_path, location=location)
|
|
597
|
+
|
|
598
|
+
else:
|
|
599
|
+
self.install_file_http(source_path, location=location)
|
|
600
|
+
|
|
601
|
+
def get_auto_file_path(self, auto_file_path):
|
|
602
|
+
"""
|
|
603
|
+
Make a local temp copy of the --auto file from the device.
|
|
604
|
+
Returns the path to the local copy.
|
|
605
|
+
"""
|
|
606
|
+
url = auto_file_path
|
|
607
|
+
auth = HTTPBasicAuth("", self.password)
|
|
608
|
+
with self.session.get(url, auth=auth, timeout=self.timeout) as r:
|
|
609
|
+
r.raise_for_status()
|
|
610
|
+
with open(LOCAL_CODE_PY_COPY, "w", encoding="utf-8") as f:
|
|
611
|
+
f.write(r.text)
|
|
612
|
+
return LOCAL_CODE_PY_COPY
|
|
613
|
+
|
|
614
|
+
def uninstall(self, device_path, module_path):
|
|
615
|
+
"""
|
|
616
|
+
Uninstall given module on device using REST API.
|
|
617
|
+
"""
|
|
618
|
+
url = urlparse(device_path)
|
|
619
|
+
auth = HTTPBasicAuth("", url.password)
|
|
620
|
+
with self.session.delete(module_path, auth=auth, timeout=self.timeout) as r:
|
|
621
|
+
if r.status_code == 409:
|
|
622
|
+
_writeable_error()
|
|
623
|
+
r.raise_for_status()
|
|
624
|
+
|
|
625
|
+
def update(self, module):
|
|
626
|
+
"""
|
|
627
|
+
Delete the module on the device, then copy the module from the bundle
|
|
628
|
+
back onto the device.
|
|
629
|
+
|
|
630
|
+
The caller is expected to handle any exceptions raised.
|
|
631
|
+
"""
|
|
632
|
+
self._update_http(module)
|
|
633
|
+
|
|
634
|
+
def file_exists(self, filepath):
|
|
635
|
+
"""
|
|
636
|
+
return True if the file exists, otherwise False.
|
|
637
|
+
"""
|
|
638
|
+
auth = HTTPBasicAuth("", self.password)
|
|
639
|
+
resp = requests.get(
|
|
640
|
+
self.get_file_path(filepath), auth=auth, timeout=self.timeout
|
|
641
|
+
)
|
|
642
|
+
if resp.status_code == 200:
|
|
643
|
+
return True
|
|
644
|
+
return False
|
|
645
|
+
|
|
646
|
+
def _update_http(self, module):
|
|
647
|
+
"""
|
|
648
|
+
Update the module using web workflow.
|
|
649
|
+
"""
|
|
650
|
+
if module.file:
|
|
651
|
+
# Copy the file (will overwrite).
|
|
652
|
+
self.install_file_http(module.bundle_path)
|
|
653
|
+
else:
|
|
654
|
+
# Delete the directory (recursive) first.
|
|
655
|
+
url = urlparse(module.path)
|
|
656
|
+
auth = HTTPBasicAuth("", url.password)
|
|
657
|
+
with self.session.delete(module.path, auth=auth, timeout=self.timeout) as r:
|
|
658
|
+
if r.status_code == 409:
|
|
659
|
+
_writeable_error()
|
|
660
|
+
r.raise_for_status()
|
|
661
|
+
self.install_dir_http(module.bundle_path)
|
|
662
|
+
|
|
663
|
+
def get_file_path(self, filename):
|
|
664
|
+
"""
|
|
665
|
+
retuns the full path on the device to a given file name.
|
|
666
|
+
"""
|
|
667
|
+
return urljoin(
|
|
668
|
+
urljoin(self.device_location, "fs/", allow_fragments=False),
|
|
669
|
+
filename,
|
|
670
|
+
allow_fragments=False,
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
def is_device_present(self):
|
|
674
|
+
"""
|
|
675
|
+
returns True if the device is currently connected and running supported version
|
|
676
|
+
"""
|
|
677
|
+
try:
|
|
678
|
+
with self.session.get(f"{self.device_location}/cp/version.json") as r:
|
|
679
|
+
r.raise_for_status()
|
|
680
|
+
web_api_version = r.json().get("web_api_version")
|
|
681
|
+
if web_api_version is None:
|
|
682
|
+
self.logger.error("Unable to get web API version from device.")
|
|
683
|
+
click.secho("Unable to get web API version from device.", fg="red")
|
|
684
|
+
return False
|
|
685
|
+
|
|
686
|
+
if web_api_version < 4:
|
|
687
|
+
self.logger.error(
|
|
688
|
+
f"Device running unsupported web API version {web_api_version} < 4."
|
|
689
|
+
)
|
|
690
|
+
click.secho(
|
|
691
|
+
f"Device running unsupported web API version {web_api_version} < 4.",
|
|
692
|
+
fg="red",
|
|
693
|
+
)
|
|
694
|
+
return False
|
|
695
|
+
except requests.exceptions.ConnectionError:
|
|
696
|
+
return False
|
|
697
|
+
|
|
698
|
+
return True
|
|
699
|
+
|
|
700
|
+
def get_device_versions(self):
|
|
701
|
+
"""
|
|
702
|
+
Returns a dictionary of metadata from modules on the connected device.
|
|
703
|
+
|
|
704
|
+
:param str device_url: URL for the device.
|
|
705
|
+
:return: A dictionary of metadata about the modules available on the
|
|
706
|
+
connected device.
|
|
707
|
+
"""
|
|
708
|
+
return self.get_modules(urljoin(self.device_location, self.LIB_DIR_PATH))
|
|
709
|
+
|
|
710
|
+
def get_free_space(self):
|
|
711
|
+
"""
|
|
712
|
+
Returns the free space on the device in bytes.
|
|
713
|
+
"""
|
|
714
|
+
auth = HTTPBasicAuth("", self.password)
|
|
715
|
+
with self.session.get(
|
|
716
|
+
urljoin(self.device_location, "fs/"),
|
|
717
|
+
auth=auth,
|
|
718
|
+
headers={"Accept": "application/json"},
|
|
719
|
+
timeout=self.timeout,
|
|
720
|
+
) as r:
|
|
721
|
+
r.raise_for_status()
|
|
722
|
+
if r.json().get("free") is None:
|
|
723
|
+
self.logger.error("Unable to get free block count from device.")
|
|
724
|
+
click.secho("Unable to get free block count from device.", fg="red")
|
|
725
|
+
elif r.json().get("block_size") is None:
|
|
726
|
+
self.logger.error("Unable to get block size from device.")
|
|
727
|
+
click.secho("Unable to get block size from device.", fg="red")
|
|
728
|
+
elif r.json().get("writable") is None or r.json().get("writable") is False:
|
|
729
|
+
self.logger.error(
|
|
730
|
+
"CircuitPython Web Workflow Device not writable\n - "
|
|
731
|
+
"Remount storage as writable to device (not PC)"
|
|
732
|
+
)
|
|
733
|
+
click.secho(
|
|
734
|
+
"CircuitPython Web Workflow Device not writable\n - "
|
|
735
|
+
"Remount storage as writable to device (not PC)",
|
|
736
|
+
fg="red",
|
|
737
|
+
)
|
|
738
|
+
else:
|
|
739
|
+
return r.json()["free"] * r.json()["block_size"] # bytes
|
|
740
|
+
sys.exit(1)
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
class DiskBackend(Backend):
|
|
744
|
+
"""
|
|
745
|
+
Backend for interacting with a device via USB Workflow
|
|
746
|
+
|
|
747
|
+
:param String device_location: Path to the device
|
|
748
|
+
:param logger: logger to use for outputting messages
|
|
749
|
+
:param String boot_out: Optional mock contents of a boot_out.txt file
|
|
750
|
+
to use for version information.
|
|
751
|
+
:param String version_override: Optional mock version to use.
|
|
752
|
+
"""
|
|
753
|
+
|
|
754
|
+
def __init__(self, device_location, logger, boot_out=None, version_override=None):
|
|
755
|
+
if device_location is None:
|
|
756
|
+
raise ValueError(
|
|
757
|
+
"Auto locating USB Disk based device failed. "
|
|
758
|
+
"Please specify --path argument or ensure your device "
|
|
759
|
+
"is connected and mounted under the name CIRCUITPY."
|
|
760
|
+
)
|
|
761
|
+
super().__init__(logger)
|
|
762
|
+
self.LIB_DIR_PATH = "lib"
|
|
763
|
+
self.device_location = device_location
|
|
764
|
+
self.library_path = os.path.join(self.device_location, self.LIB_DIR_PATH)
|
|
765
|
+
self.version_info = None
|
|
766
|
+
if boot_out is not None:
|
|
767
|
+
self.version_info = self.parse_boot_out_file(boot_out)
|
|
768
|
+
self.version_override = version_override
|
|
769
|
+
|
|
770
|
+
def get_circuitpython_version(self):
|
|
771
|
+
"""
|
|
772
|
+
Returns the version number of CircuitPython running on the board connected
|
|
773
|
+
via ``device_path``, along with the board ID. This is obtained from the
|
|
774
|
+
``boot_out.txt`` file on the device, whose first line will start with
|
|
775
|
+
something like this::
|
|
776
|
+
|
|
777
|
+
Adafruit CircuitPython 4.1.0 on 2019-08-02;
|
|
778
|
+
|
|
779
|
+
While the second line is::
|
|
780
|
+
|
|
781
|
+
Board ID:raspberry_pi_pico
|
|
782
|
+
|
|
783
|
+
:return: A tuple with the version string for CircuitPython and the board ID string.
|
|
784
|
+
"""
|
|
785
|
+
if self.version_override is not None:
|
|
786
|
+
return self.version_override
|
|
787
|
+
|
|
788
|
+
if not self.version_info:
|
|
789
|
+
try:
|
|
790
|
+
with open(
|
|
791
|
+
os.path.join(self.device_location, "boot_out.txt"),
|
|
792
|
+
"r",
|
|
793
|
+
encoding="utf-8",
|
|
794
|
+
) as boot:
|
|
795
|
+
boot_out_contents = boot.read()
|
|
796
|
+
circuit_python, board_id = self.parse_boot_out_file(
|
|
797
|
+
boot_out_contents
|
|
798
|
+
)
|
|
799
|
+
except FileNotFoundError:
|
|
800
|
+
click.secho(
|
|
801
|
+
"Missing file boot_out.txt on the device: wrong path or drive corrupted.",
|
|
802
|
+
fg="red",
|
|
803
|
+
)
|
|
804
|
+
self.logger.error("boot_out.txt not found.")
|
|
805
|
+
sys.exit(1)
|
|
806
|
+
return circuit_python, board_id
|
|
807
|
+
|
|
808
|
+
return self.version_info
|
|
809
|
+
|
|
810
|
+
def _get_modules(self, device_lib_path):
|
|
811
|
+
"""
|
|
812
|
+
Get a dictionary containing metadata about all the Python modules found in
|
|
813
|
+
the referenced path.
|
|
814
|
+
|
|
815
|
+
:param str device_lib_path: URL to be used to find modules.
|
|
816
|
+
:return: A dictionary containing metadata about the found modules.
|
|
817
|
+
"""
|
|
818
|
+
return _get_modules_file(device_lib_path, self.logger)
|
|
819
|
+
|
|
820
|
+
def _create_library_directory(self, device_path, library_path):
|
|
821
|
+
if not os.path.exists(library_path): # pragma: no cover
|
|
822
|
+
os.makedirs(library_path)
|
|
823
|
+
|
|
824
|
+
def copy_file(self, target_file, location_to_paste):
|
|
825
|
+
target_filename = target_file.split(os.path.sep)[-1]
|
|
826
|
+
if os.path.isdir(target_file):
|
|
827
|
+
shutil.copytree(
|
|
828
|
+
target_file,
|
|
829
|
+
os.path.join(self.device_location, location_to_paste, target_filename),
|
|
830
|
+
)
|
|
831
|
+
else:
|
|
832
|
+
shutil.copyfile(
|
|
833
|
+
target_file,
|
|
834
|
+
os.path.join(self.device_location, location_to_paste, target_filename),
|
|
835
|
+
)
|
|
836
|
+
|
|
837
|
+
def install_module_mpy(self, bundle, metadata):
|
|
838
|
+
"""
|
|
839
|
+
:param bundle library bundle.
|
|
840
|
+
:param metadata dictionary.
|
|
841
|
+
"""
|
|
842
|
+
module_name = os.path.basename(metadata["path"]).replace(".py", ".mpy")
|
|
843
|
+
if not module_name:
|
|
844
|
+
# Must be a directory based module.
|
|
845
|
+
module_name = os.path.basename(os.path.dirname(metadata["path"]))
|
|
846
|
+
|
|
847
|
+
major_version = self.get_circuitpython_version()[0].split(".")[0]
|
|
848
|
+
bundle_platform = "{}mpy".format(major_version)
|
|
849
|
+
bundle_path = os.path.join(bundle.lib_dir(bundle_platform), module_name)
|
|
850
|
+
if os.path.isdir(bundle_path):
|
|
851
|
+
target_path = os.path.join(self.library_path, module_name)
|
|
852
|
+
# Copy the directory.
|
|
853
|
+
shutil.copytree(bundle_path, target_path)
|
|
854
|
+
elif os.path.isfile(bundle_path):
|
|
855
|
+
|
|
856
|
+
target = os.path.basename(bundle_path)
|
|
857
|
+
|
|
858
|
+
target_path = os.path.join(self.library_path, target)
|
|
859
|
+
|
|
860
|
+
# Copy file.
|
|
861
|
+
shutil.copyfile(bundle_path, target_path)
|
|
862
|
+
else:
|
|
863
|
+
raise IOError("Cannot find compiled version of module.")
|
|
864
|
+
|
|
865
|
+
# pylint: enable=too-many-locals,too-many-branches
|
|
866
|
+
def install_module_py(self, metadata, location=None):
|
|
867
|
+
"""
|
|
868
|
+
:param metadata dictionary.
|
|
869
|
+
:param location the location on the device to copy the py module to.
|
|
870
|
+
If omitted is CIRCUITPY/lib/ used.
|
|
871
|
+
"""
|
|
872
|
+
if location is None:
|
|
873
|
+
location = self.library_path
|
|
874
|
+
else:
|
|
875
|
+
location = os.path.join(self.device_location, location)
|
|
876
|
+
|
|
877
|
+
source_path = metadata["path"] # Path to Python source version.
|
|
878
|
+
if os.path.isdir(source_path):
|
|
879
|
+
target = os.path.basename(os.path.dirname(source_path))
|
|
880
|
+
target_path = os.path.join(location, target)
|
|
881
|
+
# Copy the directory.
|
|
882
|
+
shutil.copytree(source_path, target_path)
|
|
883
|
+
else:
|
|
884
|
+
target = os.path.basename(source_path)
|
|
885
|
+
target_path = os.path.join(location, target)
|
|
886
|
+
# Copy file.
|
|
887
|
+
shutil.copyfile(source_path, target_path)
|
|
888
|
+
|
|
889
|
+
def get_auto_file_path(self, auto_file_path):
|
|
890
|
+
"""
|
|
891
|
+
Returns the path on the device to the file to be read for --auto.
|
|
892
|
+
"""
|
|
893
|
+
return auto_file_path
|
|
894
|
+
|
|
895
|
+
def uninstall(self, device_path, module_path):
|
|
896
|
+
"""
|
|
897
|
+
Uninstall module using local file system.
|
|
898
|
+
"""
|
|
899
|
+
library_path = os.path.join(device_path, "lib")
|
|
900
|
+
if os.path.isdir(module_path):
|
|
901
|
+
target = os.path.basename(os.path.dirname(module_path))
|
|
902
|
+
target_path = os.path.join(library_path, target)
|
|
903
|
+
# Remove the directory.
|
|
904
|
+
shutil.rmtree(target_path)
|
|
905
|
+
else:
|
|
906
|
+
target = os.path.basename(module_path)
|
|
907
|
+
target_path = os.path.join(library_path, target)
|
|
908
|
+
# Remove file
|
|
909
|
+
os.remove(target_path)
|
|
910
|
+
|
|
911
|
+
def update(self, module):
|
|
912
|
+
"""
|
|
913
|
+
Delete the module on the device, then copy the module from the bundle
|
|
914
|
+
back onto the device.
|
|
915
|
+
|
|
916
|
+
The caller is expected to handle any exceptions raised.
|
|
917
|
+
"""
|
|
918
|
+
self._update_file(module)
|
|
919
|
+
|
|
920
|
+
def _update_file(self, module):
|
|
921
|
+
"""
|
|
922
|
+
Update the module using file system.
|
|
923
|
+
"""
|
|
924
|
+
if os.path.isdir(module.path):
|
|
925
|
+
# Delete and copy the directory.
|
|
926
|
+
shutil.rmtree(module.path, ignore_errors=True)
|
|
927
|
+
shutil.copytree(module.bundle_path, module.path)
|
|
928
|
+
else:
|
|
929
|
+
# Delete and copy file.
|
|
930
|
+
os.remove(module.path)
|
|
931
|
+
shutil.copyfile(module.bundle_path, module.path)
|
|
932
|
+
|
|
933
|
+
def file_exists(self, filepath):
|
|
934
|
+
"""
|
|
935
|
+
return True if the file exists, otherwise False.
|
|
936
|
+
"""
|
|
937
|
+
return os.path.exists(os.path.join(self.device_location, filepath))
|
|
938
|
+
|
|
939
|
+
def get_file_path(self, filename):
|
|
940
|
+
"""
|
|
941
|
+
returns the full path on the device to a given file name.
|
|
942
|
+
"""
|
|
943
|
+
return os.path.join(self.device_location, filename)
|
|
944
|
+
|
|
945
|
+
def is_device_present(self):
|
|
946
|
+
"""
|
|
947
|
+
returns True if the device is currently connected
|
|
948
|
+
"""
|
|
949
|
+
return os.path.exists(self.device_location)
|
|
950
|
+
|
|
951
|
+
def get_free_space(self):
|
|
952
|
+
"""
|
|
953
|
+
Returns the free space on the device in bytes.
|
|
954
|
+
"""
|
|
955
|
+
# pylint: disable=unused-variable
|
|
956
|
+
_, total, free = shutil.disk_usage(self.device_location)
|
|
957
|
+
return free
|