Coverage for /var/devmt/py/utils4_1.8.0/utils4/utils.py: 99%

126 statements  

« prev     ^ index     » next       coverage.py v7.6.9, created at 2025-08-26 21:39 +0100

1# -*- coding: utf-8 -*- 

2""" 

3:Purpose: Central library for general utility-based methods. 

4 

5 This ``utils`` module was the starting place of the original 

6 ``utils`` library. Therefore, it's historically been a 

7 'dumping-ground' for general S3DEV utilities and function 

8 wrappers specialised to the needs of S3DEV projects, which 

9 did not seem to fit in anywhere else. So we'll be honest, 

10 it's a bit of a melting pot of functions. 

11 

12 With the overhaul of the ``utils3`` library into ``utils4``, 

13 *many* of the original functions, which were no longer being 

14 used, have been removed in an effort to clean the module's 

15 code base. 

16 

17 If you are looking for a function which used to be here, 

18 please refer to the last ``utils3`` release, which is 

19 v0.15.1. 

20 

21:Developer: J Berendt 

22:Email: support@s3dev.uk 

23 

24Note: 

25 Any libraries which are not built-in, are imported *only* 

26 if/when the function which uses them is called. 

27 

28 This helps to reduce the packages required by ``utils4``. 

29 

30:Example: 

31 

32 For usage examples, please refer to the docstring for each method. 

33 

34""" 

35# pylint: disable=import-error 

36# pylint: disable=import-outside-toplevel # Keep required dependencies to a minimum. 

37# pylint: disable=wrong-import-order 

38 

39from __future__ import annotations 

40 

41import importlib 

42import io 

43import os 

44import platform 

45import re 

46import site 

47import string 

48import subprocess 

49from datetime import datetime 

50from typing import Generator, Union 

51# locals 

52from utils4.reporterror import reporterror 

53from utils4.user_interface import ui 

54try: 

55 # The C library is only available if installed. 

56 from . import futils # pylint: disable=no-name-in-module 

57except ImportError: 

58 pass 

59 

60# OS-dependent imports 

61try: # pragma: nocover 

62 import win32api 

63 import win32file 

64except ImportError: 

65 pass 

66 

67 

68def clean_dataframe(df: pd.DataFrame): # noqa # pylint: disable=undefined-variable 

69 """Clean a ``pandas.DataFrame`` data structure. 

70 

71 Args: 

72 df (pd.DataFrame): DataFrame to be cleaned. 

73 

74 :Design: 

75 The DataFrame is cleaned *in-place*. An object is *not* returned by 

76 this function. 

77 

78 The following cleaning tasks are performed: 

79 

80 - Column names: 

81 

82 - All punctuation characters are removed, with the exception 

83 of three characters. See next bullet point. 

84 - The ``-``, ``[space]`` and ``_`` characters are replaced 

85 with an underscore. 

86 - All column names are converted to lower case. 

87 

88 - Data: 

89 

90 - All ``object`` (string) fields, are stripped of leading and 

91 trailing whitespace. 

92 

93 :Example: 

94 

95 Example for cleaning a DataFrame:: 

96 

97 >>> import pandas as pd # For demonstration only. 

98 >>> from utils4 import utils 

99 

100 >>> # Define a dirty testing dataset. 

101 >>> df = pd.DataFrame({'Column #1': [' Text field 1.', 

102 ' Text field 2.', 

103 ' Text field 3. ', 

104 ' Text field 4. ', 

105 ' Text field 5. '], 

106 ' COLUmn (2)': [1.0, 

107 2.0, 

108 3.0, 

109 '4', 

110 '5.0'], 

111 'COLUMN 3 ': [1, 

112 2, 

113 3.0, 

114 4, 

115 5.0]}) 

116 >>> utils.clean_dataframe(df) 

117 >>> df 

118 column_1 column_2 column_3 

119 0 Text field 1. 1.0 1.0 

120 1 Text field 2. 2.0 2.0 

121 2 Text field 3. 3.0 3.0 

122 3 Text field 4. 4 4.0 

123 4 Text field 5. 5.0 5.0 

124 

125 """ 

126 # Define replacement/translation characters. 

127 repls = {k: '' for k in string.punctuation} 

128 repls.update({'-':'_', '_': '_', ' ': '_'}) 

129 trans = str.maketrans(repls) 

130 # Clean column names. 

131 df.columns = [c.strip().lower().translate(trans) for c in df.columns] 

132 # Strip whitespace from text values. 

133 for col in df: 

134 if df[col].dtype == object: 

135 df[col] = df[col].astype(str).str.strip() 

136 

137def direxists(path: str, create_path: bool=False) -> bool: 

138 """Test if a directory exists. If not, create it, if instructed. 

139 

140 Args: 

141 path (str): The directory path to be tested. 

142 create_path (bool, optional): Create the path if it doesn't exist. 

143 Defaults to False. 

144 

145 :Design: 

146 Function designed to test if a directory path exists. If the 

147 path does *not* exist, the path can be created; as determined by 

148 the ``create_path`` parameter. 

149 

150 This function extends the built-in :func:`os.path.exists()` function 

151 in that the path can be created if it doesn't already exist, by 

152 passing the ``create_path`` parameter as ``True``. 

153 

154 If the path is created by this function, the function is recursively 

155 called to test if the path exists, and will return ``True``. 

156 

157 If a filename is passed with the path, the filename is automatically 

158 stripped from the path before the test begins. 

159 

160 :Example: 

161 

162 Test if a directory exists, and create it if it does not exist:: 

163 

164 >>> from utils4 import utils 

165 

166 >>> utils.direxists(path='/tmp/path/to_create/file.csv', 

167 create_path=True) 

168 

169 Returns: 

170 bool: True if the directory exists (or was created), otherwise False. 

171 

172 """ 

173 found = False 

174 if os.path.splitext(path)[1]: 

175 path, _ = os.path.split(path) # Remove file if passed with the path. 

176 if os.path.exists(path): 

177 found = True 

178 else: 

179 if create_path: 

180 os.makedirs(name=path) 

181 found = direxists(path=path, create_path=False) 

182 return found 

183 

184def excludedirs(source: list[str], exclude: list[str]) -> list[str]: 

185 """Exclude the listed directories from the source. 

186 

187 Args: 

188 source (list[str]): List of source paths. 

189 exclude (list[str]): List of directories to be excluded from 

190 ``source``. 

191 

192 :Design: 

193 The paths in ``exclude`` are expanded to their realpath, with 

194 a trailing path separator explicitly added to ensure only 

195 directory paths are matched. 

196 

197 For example, if the trailing path separator was not added, 

198 ``.gitignore`` would be excluded if ``./.git`` was in 

199 ``exclude`` paths. Adding the trailing path separator 

200 prevents this. 

201 

202 Returns: 

203 list[str]: A new list of paths where any ``source`` path 

204 sharing a common base path with any ``exclude`` path has 

205 been removed. 

206 

207 """ 

208 # Cannot be a generator as it's iterated multiple times. 

209 exclude = tuple(map(lambda x: f'{os.path.realpath(x)}/', exclude)) 

210 return [s for s in source if all(e not in s for e in exclude)] 

211 

212def fileexists(filepath: str, error: str='ignore') -> bool: 

213 """Test if a file exists. If not, notify the user or raise an error. 

214 

215 Args: 

216 filepath (str): Full file path to test. 

217 error (bool, optional): Action to be taken if the file does not exist. 

218 Defaults to 'ignore'. Options: 

219 

220 - ``'ignore'``: Take no action. 

221 - ``'alert'``: Alert the user the filepath does not exist via 

222 a simple message to the terminal. 

223 - ``'raise'``: Raise a ``FileNotFoundError``. This will abort 

224 all subsequent processing. 

225 

226 :Design: 

227 Function designed check if a file exists. A boolean value is 

228 returned to the calling program. 

229 

230 This function extends the built-in :func:`os.path.isfile` function 

231 in that the user can be notified if the path does not exist, or an 

232 error can be raised. 

233 

234 :Example: 

235 

236 Test if a file exists, using ``'ignore'``, the default action:: 

237 

238 >>> from utils4 import utils 

239 

240 >>> if utils.fileexists(filepath='/tmp/path/to/file.csv'): 

241 >>> ... 

242 >>> else: 

243 >>> ... 

244 

245 

246 Test if a file exists, using ``'alert'``:: 

247 

248 >>> from utils4 import utils 

249 

250 >>> if utils.fileexists(filepath='/tmp/path/to/file.csv', 

251 error='alert'): 

252 >>> ... 

253 >>> else: 

254 >>> ... 

255 

256 File not found: /tmp/path/to/file.csv 

257 

258 

259 Test if a file exists, using ``'raise'``:: 

260 

261 >>> from utils4 import utils 

262 

263 >>> if utils.fileexists(filepath='/tmp/path/to/file.csv', 

264 error='raise'): 

265 >>> ... 

266 >>> else: 

267 >>> ... 

268 

269 FileNotFoundError: File not found: /tmp/path/to/file.csv 

270 

271 Raises: 

272 FileNotFoundError: If the filepath does not exist and the ``error`` 

273 parameter is ``'raise'``. 

274 

275 Returns: 

276 bool: True if the file exists, otherwise False. 

277 

278 """ 

279 found = False 

280 if os.path.isfile(filepath): 

281 found = True 

282 else: 

283 if error == 'alert': 

284 ui.print_warning(f'\nFile not found: {filepath}') 

285 elif error == 'raise': 

286 raise FileNotFoundError(f'File not found: {filepath}') 

287 return found 

288 

289 

290def format_exif_date(datestring: str, 

291 input_format: str='%Y:%m:%d %H:%M:%S', 

292 output_format: str='%Y%m%d%H%M%S', 

293 return_datetime: bool=False) -> Union[datetime, str]: 

294 """Format an exif timestamp. 

295 

296 This function is useful for storing an exif date as a datetime string. 

297 For example, extracting the exif data from an image to be stored into 

298 a database. 

299 

300 Args: 

301 datestring (str): The datetime string to be formatted. 

302 A typical exif date format is: yyyy:mm:dd hh:mi:ss 

303 input_format (str, optional): Format mask for the input datetime value. 

304 Defaults to '%Y:%m:%d %H:%M:%S'. 

305 output_format (str, optional): Format mask for the output datetime, 

306 if returned as a string. Defaults to '%Y%m%d%H%M%S'. 

307 return_datetime (bool, optional): Return a ``datetime`` object, rather 

308 than a formatted string. 

309 

310 :Design: 

311 Function designed to convert the exif date/timestamp from 

312 '2010:01:31 12:31:18' (or a caller specified format) to a format 

313 specified by the caller. 

314 

315 The default input mask is the standard exif capture datetime format. 

316 

317 :Example: 

318 

319 Convert the exif datetime to the default output string format:: 

320 

321 >>> from utils4 import utils 

322 

323 >>> formatted = utils.format_exif_date('2010:01:31 12:31:18') 

324 >>> formatted 

325 '20100131123118' 

326 

327 

328 Convert the exif datetime to a datetime object:: 

329 

330 >>> from utils4 import utils 

331 

332 >>> formatted = utils.format_exif_date('2010:01:31 12:31:18', 

333 return_datetime=True) 

334 >>> formatted 

335 datetime.datetime(2010, 1, 31, 12, 31, 18) 

336 

337 

338 Returns: 

339 Union[str, datetime.datetime]: A formatted datetime string, if the 

340 ``return_datetime`` parameter is ``False``, otherwise a 

341 ``datetime.datetime`` object. 

342 

343 """ 

344 # pylint: disable=no-else-return 

345 _dt = datetime.strptime(datestring, input_format) 

346 if return_datetime: 

347 return _dt 

348 else: 

349 return _dt.strftime(output_format) 

350 

351def get_os() -> str: 

352 """Get the platform's OS. 

353 

354 This method is a very thin wrapper around the :func:`platform.system()` 

355 function. 

356 

357 :Example: 

358 :: 

359 

360 >>> from utils4 import utils 

361 

362 >>> utils.get_os() 

363 'linux' 

364 

365 Returns: 

366 str: A string of the platform's operating system, in lower case. 

367 

368 """ 

369 return platform.system().lower() 

370 

371def get_removable_drives() -> Generator[str, str, str]: 

372 """Return a generator of removable drives. 

373 

374 .. important:: 

375 

376 This is a Windows-only function. 

377 

378 Note: 

379 A removable drive is identified by the constant 2, which is the 

380 value of the enum ``win32con.DRIVE_REMOVABLE``. 

381 

382 This code uses the integer 2 to: 

383 

384 1) Save the extra import. 

385 2) Help keep the code compact, concise and clear. 

386 

387 :Example: 

388 

389 To obtain a list of removable drives from a Windows system:: 

390 

391 >>> from utils4 import utils 

392 

393 >>> list(utils.get_removable_drives()) 

394 ['E:', 'H:'] 

395 

396 Raises: 

397 NotImplementedError: Raised if the OS is not Windows. 

398 

399 Yields: 

400 Generator[str]: Each removable drive letter as a 

401 string. For example: ``'E:'`` 

402 

403 """ 

404 if get_os() == 'windows': # pragma: nocover 

405 yield from filter(lambda x: win32file.GetDriveType(x) == 2, 

406 win32api.GetLogicalDriveStrings().split('\\\x00')) 

407 else: 

408 raise NotImplementedError('This function is Windows-only.') 

409 

410def getdrivername(driver: str, return_all: bool=False) -> list: # pragma: nocover 

411 """Return a list of ODBC driver names, matching the regex pattern. 

412 

413 Args: 

414 driver (str): A **regex pattern** for the ODBC driver you're searching. 

415 return_all (bool, optional): If True, *all* drivers matching the 

416 pattern are returned. Defaults to False, which returns only the 

417 first driver name. 

418 

419 :Design: 

420 This is a helper function designed to get and return the names 

421 of ODBC drivers. 

422 

423 The ``driver`` parameter should be formatted as a regex 

424 pattern. If multiple drivers are found, by default, only the 

425 first driver in the list is returned. However, the 

426 ``return_all`` parameter adjusts this action to return all driver 

427 names. 

428 

429 This function has a dependency on the ``pyodbc`` library. Therefore, 

430 the :func:`~utils.testimport()` function is called before ``pyodbc`` 

431 is imported. If the ``pyodbc`` library is not installed, the user is 

432 notified. 

433 

434 :Dependencies: 

435 - ``pyodbc`` library 

436 

437 :Example: 

438 

439 Get the driver name for the SQL Server ODBC driver:: 

440 

441 >>> from utils4 import utils 

442 >>> driver = utils.getdrivername(driver='SQL Server.*') 

443 

444 :Troubleshooting: 

445 

446 - On Unix-like systems, the following error message:: 

447 

448 ImportError: libodbc.so.2: cannot open shared object file: No such file or directory 

449 

450 can be resolved by installing the ``unixodbc-dev`` package as:: 

451 

452 $ sudo apt install unixodbc-dev 

453 

454 Returns: 

455 list: A list of ODBC drivers, if any were found. 

456 

457 """ 

458 drivers = [] 

459 if testimport('pyodbc', verbose=True): 

460 import pyodbc 

461 drivers = [i for i in pyodbc.drivers() if re.search(driver, i)] 

462 if not return_all and drivers: 

463 drivers = drivers[0] 

464 return drivers 

465 

466def getsitepackages() -> str: 

467 """Return the Python installation's site packages directory. 

468 

469 :Design: 

470 The function first uses the local :func:`~utils.get_os()` 

471 function to get the system's OS. The OS is then tested and the 

472 site-packages location is returned using the OS-appropriate element 

473 from the list returned by the built-in :func:`site.getsitepackages` 

474 function. 

475 

476 If the OS is not accounted for, or fails the test, a value of 

477 'unknown' is returned. 

478 

479 :Rationale: 

480 The need for this function comes out of the observation there are many 

481 (many!) different ways on stackoverflow (and other sites) to get the 

482 location to which ``pip`` will install a package, and many of the 

483 answers contradict each other. Also, the :func:`site.getsitepackages` 

484 function returns a list of options (in all tested cases); and the 

485 Linux / Windows paths are in different locations in this list. 

486 

487 :Example: 

488 

489 Get the location of the ``site-packages`` directory:: 

490 

491 >>> from utils4 import utils 

492 

493 >>> utils.getsitepackages() 

494 '/home/<username>/venvs/py38/lib/python3.8/site-packages' 

495 

496 Returns: 

497 str: Full path to the ``site-packages`` directory. 

498 

499 """ 

500 _os = get_os() 

501 pkgs = 'unknown' 

502 if 'win' in _os: # pragma: nocover # utils4 will *rarely* ever be tested on Windows. 

503 pkgs = site.getsitepackages()[1] 

504 elif 'lin' in _os: 

505 pkgs = site.getsitepackages()[0] 

506 return pkgs 

507 

508def gzip_compress(in_path: str, out_path: str=None, size: int=None) -> str: 

509 """Compress a file using ``gzip``. 

510 

511 Args: 

512 in_path (str): Full path to the file to be compressed. If the file 

513 does not exist, a ``FileNotFoundError`` is raised. 

514 out_path (str, optional): Full path to the compressed output file. 

515 Defaults to None. If this value is ``None`` a ``'.gz'`` file 

516 extension is appended to the path provided to the ``in_path`` 

517 parameter. 

518 size (int, optional): Size of the chunk to be read / written during 

519 compression. Defaults to 10MiB. 

520 

521 :Example: 

522 

523 Compress a text file:: 

524 

525 >>> from utils4 import utils 

526 

527 >>> utils.gzip_compress(in_path='/tmp/rand.txt') 

528 '/tmp/rand.txt.gz' 

529 

530 

531 Compress a text file, specifying the output path:: 

532 

533 >>> from utils4 import utils 

534 

535 >>> utils.gzip_compress(in_path='/tmp/rand.txt', out_path='/tmp/rand2.txt.gz') 

536 '/tmp/rand2.txt.gz' 

537 

538 Returns: 

539 str: Full path to the output file. 

540 

541 """ 

542 import gzip 

543 size = 1024*1024*10 if size is None else size # Default to 10MiB. 

544 if fileexists(filepath=in_path, error='raise'): 

545 if out_path is None: 

546 out_path = f'{in_path}.gz' 

547 with open(in_path, 'rb') as f_in, open(out_path, 'wb') as f_out: 

548 chunk = f_in.read(size) 

549 while len(chunk) > 0: 

550 comp = gzip.compress(data=chunk, compresslevel=9) 

551 f_out.write(comp) 

552 chunk = f_in.read(size) 

553 return out_path 

554 

555def gzip_decompress(path: str, encoding: str='utf-8', size: int=None) -> bool: 

556 """Decompress a ``.gz`` file using ``gzip``. 

557 

558 Args: 

559 path (str): Full path to the file to be decompressed. If the file 

560 does not exist, a ``FileNotFoundError`` is raised. 

561 encoding (str, optional): Encoding to be used to decode the 

562 decompressed binary data. Defaults to 'utf-8'. 

563 size (int, optional): Size of the chunk to be read / written during 

564 decompression. Defaults to 1MiB. 

565 

566 Note: 

567 The output path is simply the ``path`` value with *last* file 

568 extension removed. 

569 

570 In general cases, a file compressed using gzip will have a ``.gz`` 

571 extension appended onto the existing filename and extension. 

572 For example: ``data.txt.gz``. 

573 

574 Note: 

575 **Newline Characters:** 

576 

577 When the decompressed file is written, the ``newline`` character is 

578 specified as ``''``, which enables 'universal newline mode', whereby 

579 the system's newline character is used. However, the *original* line 

580 endings - those used in the compressed file - are written back to the 

581 decompressed file. 

582 

583 This method is used to ensure the checksum hash on the original 

584 (unzipped) and decompressed file can be compared. 

585 

586 :Example: 

587 

588 Decompress a text file:: 

589 

590 >>> from utils4 import utils 

591 

592 >>> utils.gzip_decompress(path='/tmp/rand.txt.gz') 

593 True 

594 

595 Returns: 

596 bool: True if the decompression was successful, otherwise False. 

597 

598 """ 

599 # pylint: disable=line-too-long 

600 import gzip 

601 size = (1<<2)**10 if size is None else size # Default to 1 MiB. 

602 success = False 

603 try: 

604 if fileexists(filepath=path, error='raise'): 

605 out_path = os.path.splitext(path)[0] 

606 with open(path, 'rb') as f_in, open(out_path, 'w', encoding='utf-8', newline='') as f_out: 

607 chunk = f_in.read(size) 

608 while len(chunk) > 1: 

609 decomp = gzip.decompress(data=chunk).decode(encoding=encoding) 

610 f_out.write(decomp) 

611 chunk = f_in.read(size) 

612 success = True 

613 except Exception as err: 

614 reporterror(err) 

615 return success 

616 

617# Tested by the test_x_futils module. 

618def is7zip(path: str) -> bool: # pragma: nocover 

619 r"""Determine if a file is a 7-zip archive. 

620 

621 Args: 

622 path (str): Full path to the file to be tested. 

623 

624 :Example: 

625 

626 Test if a file is a 7-zip archive:: 

627 

628 >>> from utils4 import utils 

629 

630 >>> utils.is7zip('/path/to/file.7z') 

631 True 

632 

633 Note: 

634 A file is tested to be a 7-zip archive by checking the 

635 first six bytes of the file itself, *not* using the file 

636 extension. 

637 

638 :Design: 

639 This method calls the :func:`futils.is7zip` function with the 

640 given arguments. 

641 

642 Returns: 

643 bool: True if the first six bytes of the file matches the 

644 expected file signature for a 7-zip archive (shown below). 

645 Otherwise, False. 

646 

647 - ``0x37 0x7a 0xbc 0xaf 0x27 0x1c`` 

648 

649 """ 

650 return bool(futils.is7zip(path)) 

651 

652# Tested by the test_x_futils module. 

653def isascii(path: str, size: int=2048) -> bool: # pragma: nocover 

654 """Determine if a file is plain-text (ASCII only). 

655 

656 A file is deemed non-binary if *all* of the characters in the file 

657 are within ASCII's printable range. 

658 

659 Args: 

660 path (str): Full path to the file to be tested. 

661 size (int, optional): Number of bytes to read in a chunk. 

662 Defaults to 2048 (2 MiB). 

663 

664 :Example: 

665 

666 Test if a file is a plain-text (ASCII-only) file:: 

667 

668 >>> from utils4 import utils 

669 

670 >>> utils.isascii('/usr/local/bin/python3.12-config') 

671 True 

672 

673 :Design: 

674 This function simply inverts the return value of the 

675 :func:`isbinary` function. For design detail, refer to the 

676 :meth:`isbinary` documentation. 

677 

678 This method calls the :func:`futils.isascii` function with the 

679 given arguments. 

680 

681 Returns: 

682 bool: True if *all* characters in the file are plain-text 

683 (ASCII only), otherwise False. 

684 

685 """ 

686 return bool(futils.isascii(path, size)) 

687 

688# Tested by the test_x_futils module. 

689def isbinary(path: str, size: int=1024) -> bool: # pragma: nocover 

690 """Determine if a file is binary. 

691 

692 A file is deemed non-binary if *all* of the characters in the file 

693 are within ASCII's printable range. Refer to the **References** 

694 section for further definition. 

695 

696 Args: 

697 path (str): Full path to the file to be tested. 

698 size (int, optional): Number of bytes to read in a chunk. 

699 Defaults to 1024 (1 MiB). 

700 

701 :Example: 

702 

703 Test if a file is a binary file or executable:: 

704 

705 >>> from utils4 import utils 

706 

707 >>> utils.isbinary('/usr/bin/python3') 

708 True 

709 

710 :Design: 

711 For each chunk of size ``size``, read each character; if the 

712 character is outside the ASCII printable range, True is returned 

713 immediately as the file is not plain-text. Otherwise, if a file 

714 is read to the end, with all characters being within ASCII's 

715 printable range, False is returned as the file is plain-text 

716 (ASCII only). 

717 

718 This method calls the :func:`futils.isbinary` function with the 

719 given arguments. 

720 

721 :References: 

722 

723 - `How to detect if a file is binary <so_ref1_>`_ 

724 - `ASCII printable character reference <so_ref2_>`_ 

725 

726 .. _so_ref1: https://stackoverflow.com/a/7392391/6340496 

727 .. _so_ref2: https://stackoverflow.com/a/32184831/6340496 

728 

729 Returns: 

730 bool: True if *any* of the characters in the file are outside 

731 ASCII's printable range. Otherwise, False. 

732 

733 """ 

734 return bool(futils.isbinary(path, size)) 

735 

736# Tested by the test_x_futils module. 

737def isgzip(path: str) -> bool: # pragma: nocover 

738 r"""Determine if a file is a GZIP compressed file. 

739 

740 Args: 

741 path (str): Full path to the file to be tested. 

742 

743 :Example: 

744 

745 Test if a file is a GZIP compressed file:: 

746 

747 >>> from utils4 import utils 

748 

749 >>> utils.isgzip('/path/to/file.gz') 

750 True 

751 

752 

753 Test if a file is a GZIP archive:: 

754 

755 >>> from utils4 import utils 

756 

757 >>> utils.isgzip('/path/to/file.tar.gz') 

758 True 

759 

760 Note: 

761 A file is tested to be a GZIP compressed file by checking the 

762 first two bytes of the file itself, *not* using the file 

763 extension. 

764 

765 :Design: 

766 This method calls the :func:`futils.isgzip` function with the 

767 given arguments. 

768 

769 Returns: 

770 bool: True if the first two bytes of the file matches the 

771 expected file signature for a GZIP compressed file (shown below). 

772 Otherwise, False. 

773 

774 - ``0x1f 0x8b`` 

775 

776 """ 

777 return bool(futils.isgzip(path)) 

778 

779# Tested by the test_x_futils module. 

780def ispdf(path: str) -> bool: # pragma: nocover 

781 r"""Determine if a file is a ``PDF`` file. 

782 

783 Args: 

784 path (str): Full path to the file to be tested. 

785 

786 :Example: 

787 

788 Test if a file is a PDF file:: 

789 

790 >>> from utils4 import utils 

791 

792 >>> utils.ispdf('/path/to/document.pdf') 

793 True 

794 

795 Note: 

796 A file is tested to be a ``PDF`` file by checking the first five 

797 bytes of the file itself, *not* using the file extension. 

798 

799 :Design: 

800 This method calls the :func:`futils.ispdf` function with the 

801 given arguments. 

802 

803 Returns: 

804 bool: True if the first five bytes of the file matches the 

805 expected file signature for a PDF file (shown below), which 

806 corresponds to ``%PDF-``. Otherwise, False. 

807 

808 - ``0x25 0x50 0x44 0x46 0x2d`` 

809 

810 """ 

811 return bool(futils.ispdf(path)) 

812 

813# Tested by the test_x_futils module. 

814def iszip(path: str) -> bool: # pragma: nocover 

815 r"""Determine if a file is a ``ZIP`` archive. 

816 

817 Args: 

818 path (str): Full path to the file to be tested. 

819 

820 Tip: 

821 As Python wheel files are `ZIP-format archives <zip-wheel_>`_ 

822 (per PEP-491), this function can be used to test wheel files as 

823 well. 

824 

825 :Example: 

826 

827 Test if a file is a ZIP archive:: 

828 

829 >>> from utils4 import utils 

830 

831 >>> utils.iszip('/path/to/file.zip') 

832 True 

833 

834 Test if a file is a true Python wheel:: 

835 

836 >>> from utils4 import utils 

837 

838 >>> utils.iszip('/path/to/sphinx-8.1.3-py3-none-any.whl') 

839 True 

840 

841 Note: 

842 A file is tested to be a ``ZIP`` archive by checking the 

843 `first four bytes <zip-format_>`_ of the file itself, *not* 

844 using the file extension. 

845 

846 It is up to the caller to handle empty or spanned ZIP 

847 archives appropriately. 

848 

849 :Design: 

850 This method calls the :func:`futils.iszip` function with the 

851 given arguments. 

852 

853 Returns: 

854 bool: True if the first four bytes of the file matches any of the 

855 expected file signatures for a ZIP archive (shown below). 

856 Otherwise, False. 

857 

858 - ``0x50 0x4b 0x03 0x04``: 'Standard' archive 

859 - ``0x50 0x4b 0x05 0x06``: Empty archive 

860 - ``0x50 0x4b 0x07 0x08``: Spanned archive 

861 

862 .. _zip-format: https://en.wikipedia.org/wiki/ZIP_(file_format)#Local_file_header 

863 .. _zip-wheel: https://peps.python.org/pep-0491/#abstract 

864 

865 """ 

866 return bool(futils.iszip(path)) 

867 

868def ping(server: str, count: int=1, timeout: int=5, verbose: bool=False) -> bool: 

869 r"""Ping an IP address, server or web address. 

870 

871 Args: 

872 server (str): IP address, server name or web address. 

873 count (int, optional): The number of ping attempts. Defaults to 1. 

874 timeout (int, optional): Number of seconds to wait for response. 

875 Defaults to 5. 

876 verbose (bool, optional): Display all stdout and/or stderr output, if 

877 the returned status code is non-zero. Defaults to False. 

878 

879 :Design: 

880 Using the platform's native ``ping`` command (via a ``subprocess`` 

881 call) the host is pinged, and a boolean value is returned to the 

882 caller to indicate if the ping was successful. 

883 

884 A ping status: 

885 

886 - 0 returns True 

887 - Non-zero returns False 

888 

889 If the server name is preceeded by ``\\`` or ``//``, these are 

890 stripped out using the built-in :func:`os.path.basename()` function. 

891 

892 :Example: 

893 

894 Ping the local PC at 127.0.0.1:: 

895 

896 >>> from utils4 import utils 

897 

898 >>> utils.ping(server='127.0.0.1') 

899 True 

900 

901 

902 Ping an unknown server:: 

903 

904 >>> from utils4 import utils 

905 

906 >>> utils.ping(server='//S3DHOST01', verbose=True) 

907 

908 [PingError]: 

909 ping: S3DHOST01: Temporary failure in name resolution 

910 False 

911 

912 

913 Ping an unreachable IP address:: 

914 

915 >>> from utils4 import utils 

916 

917 >>> utils.ping(server='192.168.0.99', count=3, verbose=True) 

918 

919 [PingError]: 

920 PING 192.168.0.99 (192.168.0.99) 56(84) bytes of data. 

921 From 192.168.0.XX icmp_seq=1 Destination Host Unreachable 

922 From 192.168.0.XX icmp_seq=2 Destination Host Unreachable 

923 From 192.168.0.XX icmp_seq=3 Destination Host Unreachable 

924 

925 --- 192.168.0.99 ping statistics --- 

926 3 packets transmitted, 0 received, +3 errors, 100% packet loss, time 2037ms 

927 False 

928 

929 Returns: 

930 bool: True if the ping was successful, otherwise False. 

931 

932 """ 

933 cmd = [] 

934 server = os.path.basename(server) 

935 status = 1 

936 stdout = None 

937 stderr = None 

938 _os = get_os() 

939 if 'win' in _os: # pragma: nocover # utils4 will *rarely* ever be tested on Windows. 

940 timeout *= 1000 # Windows timeout (-w) is in milliseconds. 

941 cmd = ['ping', '-n', str(count), '-w', str(timeout), server] 

942 elif 'lin' in _os: 

943 cmd = ['ping', f'-c{count}', f'-W{timeout}', server] 

944 else: # pragma: nocover 

945 ui.print_alert('\nProcess aborted, unsupported OS.\n' 

946 f'- OS identified as: {_os}\n') 

947 if cmd: 

948 with subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) as proc: 

949 stdout, stderr = proc.communicate() 

950 status = proc.returncode 

951 if ('win' in _os) & (b'Destination host unreachable' in stdout): # pragma nocover 

952 # Hard code status if host is unreachable. 

953 # Generally, this will return 0, so it must be overridden. 

954 status = 1 

955 if all([verbose, cmd, status != 0]): 

956 ui.print_alert('\n[PingError]:') 

957 if stdout: 

958 ui.print_alert(text=stdout.decode().strip()) 

959 if stderr: 

960 ui.print_alert(text=stderr.decode().strip()) 

961 return status == 0 

962 

963def strip_ansi_colour(text: str): 

964 r"""Strip ANSI colour sequences from a string. 

965 

966 Args: 

967 text (str): Text string to be cleaned. 

968 

969 Note: 

970 This method is *very* basic and only caters to colour sequences. 

971 

972 It is designed to yield all characters that are not part of the 

973 ``\x1b`` sequence start, and the ``m`` sequence end. In other 

974 words, all text before and after each ``\x1b[M;Nm`` sequence. 

975 

976 :Example: 

977 

978 Strip the colouring sequence from terminal text and return a 

979 single string:: 

980 

981 clean = ''.join(strip_ansi_colour(text)) 

982 

983 Strip the colouring sequence from terminal text and return a list 

984 of lines, with empty lines removed:: 

985 

986 lines = list(filter(None, ''.join(strip_ansi_colour(text)).split('\n'))) 

987 

988 Yields: 

989 str: Each character which not part of the ANSI escape sequence 

990 is yielded to the caller. Essentially, this is a generator 

991 method. 

992 

993 """ 

994 # pylint: disable=multiple-statements 

995 buff = io.StringIO(text) 

996 while (b := buff.read(1)): 

997 if b == '\x1b': 

998 while ( b := buff.read(1) ) != 'm': continue # Fast-forward from \x1b to m. 

999 else: 

1000 yield b 

1001 

1002def testimport(module_name: str, verbose: bool=True) -> bool: 

1003 """Test if a Python library is installed. 

1004 

1005 Args: 

1006 module_name (str): Exact name of the module to be found. 

1007 verbose (bool, optional): Notify if the library is not installed. 

1008 Defaults to True. 

1009 

1010 :Design: 

1011 This is a small helper function designed to test if a library is 

1012 installed before trying to import it. 

1013 

1014 If the library is not intalled the user is notified, if the ``verbose`` 

1015 argument is True. 

1016 

1017 :Internal Use: 

1018 For example, the :meth:`~utils.getdrivername` function uses this 

1019 function before attempting to import the ``pyodbc`` library. 

1020 

1021 :Example: 

1022 

1023 Execute a path only if ``mymodule`` is installed:: 

1024 

1025 >>> from utils4 import utils 

1026 

1027 >>> if utils.testimport('mymodule', verbose=True): 

1028 >>> import mymodule 

1029 >>> ... 

1030 >>> else: 

1031 >>> ... 

1032 

1033 Returns: 

1034 bool: True if the library is installed, otherwise False. 

1035 

1036 """ 

1037 found = False 

1038 if importlib.util.find_spec(module_name): 

1039 found = True 

1040 if (verbose) & (not found): 

1041 ui.print_warning(f'\nLibrary/module not installed: {module_name}') 

1042 return found 

1043 

1044def unidecode(string: str, **kwargs) -> str: 

1045 """Attempt to convert a Unicode string object into a 7-bit ASCII string. 

1046 

1047 Args: 

1048 string (str): The string to be decoded. 

1049 **kwargs (dict): Keyword arguments passed directly into the underlying 

1050 :func:`unidecode.unidecode` function. 

1051 

1052 :Design: 

1053 This function is a light wrapper around the :func:`unidecode.unidecode` 

1054 function. 

1055 

1056 **Per the** ``unicode`` **docstring:** 

1057 

1058 "Transliterate an Unicode object into an ASCII string." 

1059 

1060 Example:: 

1061 

1062 >>> unidecode(u"北亰") 

1063 "Bei Jing " 

1064 

1065 "This function first tries to convert the string using ASCII codec. 

1066 If it fails (because of non-ASCII characters), it falls back to 

1067 transliteration using the character tables." 

1068 

1069 "This is approx. five times faster if the string only contains ASCII 

1070 characters, but slightly slower than 

1071 :func:`unidecode.unicode_expect_nonascii` if non-ASCII characters are 

1072 present." 

1073 

1074 :Dependencies: 

1075 

1076 - ``unidecode`` library 

1077 

1078 :Example: 

1079 

1080 Convert a Polish address into pure ASCII:: 

1081 

1082 >>> from utils4 import utils 

1083 

1084 >>> addr = 'ul. Bałtów 8a 27-423 Bałtów, woj. świętokrzyskie' 

1085 >>> utils.unidecode(addr) 

1086 'ul. Baltow 8a 27-423 Baltow, woj. swietokrzyskie' 

1087 

1088 

1089 Convert the first line of 'The Seventh Letter', by Plato:: 

1090 

1091 >>> from utils4 import utils 

1092 

1093 >>> text = 'Πλάτων τοῖς Δίωνος οἰκείοις τε καὶ ἑταίροις εὖ πράττειν.' 

1094 >>> utils.unidecode(text) 

1095 'Platon tois Dionos oikeiois te kai etairois eu prattein.' 

1096 

1097 Returns: 

1098 str: If the ``unidecode`` library is installed and the passed 

1099 ``string`` value is a ``str`` data type, the decoded string is 

1100 returned, otherwise the original value is returned. 

1101 

1102 """ 

1103 # pylint: disable=redefined-outer-name # No adverse effects and keeps clear variable name. 

1104 if testimport(module_name='unidecode', verbose=True): 

1105 import unidecode as unidecode_ 

1106 decoded = unidecode_.unidecode(string, **kwargs) if isinstance(string, str) else string 

1107 else: # pragma: nocover 

1108 decoded = string 

1109 return decoded