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
« 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.
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.
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.
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.
21:Developer: J Berendt
22:Email: support@s3dev.uk
24Note:
25 Any libraries which are not built-in, are imported *only*
26 if/when the function which uses them is called.
28 This helps to reduce the packages required by ``utils4``.
30:Example:
32 For usage examples, please refer to the docstring for each method.
34"""
35# pylint: disable=import-error
36# pylint: disable=import-outside-toplevel # Keep required dependencies to a minimum.
37# pylint: disable=wrong-import-order
39from __future__ import annotations
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
60# OS-dependent imports
61try: # pragma: nocover
62 import win32api
63 import win32file
64except ImportError:
65 pass
68def clean_dataframe(df: pd.DataFrame): # noqa # pylint: disable=undefined-variable
69 """Clean a ``pandas.DataFrame`` data structure.
71 Args:
72 df (pd.DataFrame): DataFrame to be cleaned.
74 :Design:
75 The DataFrame is cleaned *in-place*. An object is *not* returned by
76 this function.
78 The following cleaning tasks are performed:
80 - Column names:
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.
88 - Data:
90 - All ``object`` (string) fields, are stripped of leading and
91 trailing whitespace.
93 :Example:
95 Example for cleaning a DataFrame::
97 >>> import pandas as pd # For demonstration only.
98 >>> from utils4 import utils
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
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()
137def direxists(path: str, create_path: bool=False) -> bool:
138 """Test if a directory exists. If not, create it, if instructed.
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.
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.
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``.
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``.
157 If a filename is passed with the path, the filename is automatically
158 stripped from the path before the test begins.
160 :Example:
162 Test if a directory exists, and create it if it does not exist::
164 >>> from utils4 import utils
166 >>> utils.direxists(path='/tmp/path/to_create/file.csv',
167 create_path=True)
169 Returns:
170 bool: True if the directory exists (or was created), otherwise False.
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
184def excludedirs(source: list[str], exclude: list[str]) -> list[str]:
185 """Exclude the listed directories from the source.
187 Args:
188 source (list[str]): List of source paths.
189 exclude (list[str]): List of directories to be excluded from
190 ``source``.
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.
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.
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.
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)]
212def fileexists(filepath: str, error: str='ignore') -> bool:
213 """Test if a file exists. If not, notify the user or raise an error.
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:
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.
226 :Design:
227 Function designed check if a file exists. A boolean value is
228 returned to the calling program.
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.
234 :Example:
236 Test if a file exists, using ``'ignore'``, the default action::
238 >>> from utils4 import utils
240 >>> if utils.fileexists(filepath='/tmp/path/to/file.csv'):
241 >>> ...
242 >>> else:
243 >>> ...
246 Test if a file exists, using ``'alert'``::
248 >>> from utils4 import utils
250 >>> if utils.fileexists(filepath='/tmp/path/to/file.csv',
251 error='alert'):
252 >>> ...
253 >>> else:
254 >>> ...
256 File not found: /tmp/path/to/file.csv
259 Test if a file exists, using ``'raise'``::
261 >>> from utils4 import utils
263 >>> if utils.fileexists(filepath='/tmp/path/to/file.csv',
264 error='raise'):
265 >>> ...
266 >>> else:
267 >>> ...
269 FileNotFoundError: File not found: /tmp/path/to/file.csv
271 Raises:
272 FileNotFoundError: If the filepath does not exist and the ``error``
273 parameter is ``'raise'``.
275 Returns:
276 bool: True if the file exists, otherwise False.
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
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.
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.
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.
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.
315 The default input mask is the standard exif capture datetime format.
317 :Example:
319 Convert the exif datetime to the default output string format::
321 >>> from utils4 import utils
323 >>> formatted = utils.format_exif_date('2010:01:31 12:31:18')
324 >>> formatted
325 '20100131123118'
328 Convert the exif datetime to a datetime object::
330 >>> from utils4 import utils
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)
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.
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)
351def get_os() -> str:
352 """Get the platform's OS.
354 This method is a very thin wrapper around the :func:`platform.system()`
355 function.
357 :Example:
358 ::
360 >>> from utils4 import utils
362 >>> utils.get_os()
363 'linux'
365 Returns:
366 str: A string of the platform's operating system, in lower case.
368 """
369 return platform.system().lower()
371def get_removable_drives() -> Generator[str, str, str]:
372 """Return a generator of removable drives.
374 .. important::
376 This is a Windows-only function.
378 Note:
379 A removable drive is identified by the constant 2, which is the
380 value of the enum ``win32con.DRIVE_REMOVABLE``.
382 This code uses the integer 2 to:
384 1) Save the extra import.
385 2) Help keep the code compact, concise and clear.
387 :Example:
389 To obtain a list of removable drives from a Windows system::
391 >>> from utils4 import utils
393 >>> list(utils.get_removable_drives())
394 ['E:', 'H:']
396 Raises:
397 NotImplementedError: Raised if the OS is not Windows.
399 Yields:
400 Generator[str]: Each removable drive letter as a
401 string. For example: ``'E:'``
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.')
410def getdrivername(driver: str, return_all: bool=False) -> list: # pragma: nocover
411 """Return a list of ODBC driver names, matching the regex pattern.
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.
419 :Design:
420 This is a helper function designed to get and return the names
421 of ODBC drivers.
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.
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.
434 :Dependencies:
435 - ``pyodbc`` library
437 :Example:
439 Get the driver name for the SQL Server ODBC driver::
441 >>> from utils4 import utils
442 >>> driver = utils.getdrivername(driver='SQL Server.*')
444 :Troubleshooting:
446 - On Unix-like systems, the following error message::
448 ImportError: libodbc.so.2: cannot open shared object file: No such file or directory
450 can be resolved by installing the ``unixodbc-dev`` package as::
452 $ sudo apt install unixodbc-dev
454 Returns:
455 list: A list of ODBC drivers, if any were found.
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
466def getsitepackages() -> str:
467 """Return the Python installation's site packages directory.
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.
476 If the OS is not accounted for, or fails the test, a value of
477 'unknown' is returned.
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.
487 :Example:
489 Get the location of the ``site-packages`` directory::
491 >>> from utils4 import utils
493 >>> utils.getsitepackages()
494 '/home/<username>/venvs/py38/lib/python3.8/site-packages'
496 Returns:
497 str: Full path to the ``site-packages`` directory.
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
508def gzip_compress(in_path: str, out_path: str=None, size: int=None) -> str:
509 """Compress a file using ``gzip``.
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.
521 :Example:
523 Compress a text file::
525 >>> from utils4 import utils
527 >>> utils.gzip_compress(in_path='/tmp/rand.txt')
528 '/tmp/rand.txt.gz'
531 Compress a text file, specifying the output path::
533 >>> from utils4 import utils
535 >>> utils.gzip_compress(in_path='/tmp/rand.txt', out_path='/tmp/rand2.txt.gz')
536 '/tmp/rand2.txt.gz'
538 Returns:
539 str: Full path to the output file.
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
555def gzip_decompress(path: str, encoding: str='utf-8', size: int=None) -> bool:
556 """Decompress a ``.gz`` file using ``gzip``.
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.
566 Note:
567 The output path is simply the ``path`` value with *last* file
568 extension removed.
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``.
574 Note:
575 **Newline Characters:**
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.
583 This method is used to ensure the checksum hash on the original
584 (unzipped) and decompressed file can be compared.
586 :Example:
588 Decompress a text file::
590 >>> from utils4 import utils
592 >>> utils.gzip_decompress(path='/tmp/rand.txt.gz')
593 True
595 Returns:
596 bool: True if the decompression was successful, otherwise False.
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
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.
621 Args:
622 path (str): Full path to the file to be tested.
624 :Example:
626 Test if a file is a 7-zip archive::
628 >>> from utils4 import utils
630 >>> utils.is7zip('/path/to/file.7z')
631 True
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.
638 :Design:
639 This method calls the :func:`futils.is7zip` function with the
640 given arguments.
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.
647 - ``0x37 0x7a 0xbc 0xaf 0x27 0x1c``
649 """
650 return bool(futils.is7zip(path))
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).
656 A file is deemed non-binary if *all* of the characters in the file
657 are within ASCII's printable range.
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).
664 :Example:
666 Test if a file is a plain-text (ASCII-only) file::
668 >>> from utils4 import utils
670 >>> utils.isascii('/usr/local/bin/python3.12-config')
671 True
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.
678 This method calls the :func:`futils.isascii` function with the
679 given arguments.
681 Returns:
682 bool: True if *all* characters in the file are plain-text
683 (ASCII only), otherwise False.
685 """
686 return bool(futils.isascii(path, size))
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.
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.
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).
701 :Example:
703 Test if a file is a binary file or executable::
705 >>> from utils4 import utils
707 >>> utils.isbinary('/usr/bin/python3')
708 True
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).
718 This method calls the :func:`futils.isbinary` function with the
719 given arguments.
721 :References:
723 - `How to detect if a file is binary <so_ref1_>`_
724 - `ASCII printable character reference <so_ref2_>`_
726 .. _so_ref1: https://stackoverflow.com/a/7392391/6340496
727 .. _so_ref2: https://stackoverflow.com/a/32184831/6340496
729 Returns:
730 bool: True if *any* of the characters in the file are outside
731 ASCII's printable range. Otherwise, False.
733 """
734 return bool(futils.isbinary(path, size))
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.
740 Args:
741 path (str): Full path to the file to be tested.
743 :Example:
745 Test if a file is a GZIP compressed file::
747 >>> from utils4 import utils
749 >>> utils.isgzip('/path/to/file.gz')
750 True
753 Test if a file is a GZIP archive::
755 >>> from utils4 import utils
757 >>> utils.isgzip('/path/to/file.tar.gz')
758 True
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.
765 :Design:
766 This method calls the :func:`futils.isgzip` function with the
767 given arguments.
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.
774 - ``0x1f 0x8b``
776 """
777 return bool(futils.isgzip(path))
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.
783 Args:
784 path (str): Full path to the file to be tested.
786 :Example:
788 Test if a file is a PDF file::
790 >>> from utils4 import utils
792 >>> utils.ispdf('/path/to/document.pdf')
793 True
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.
799 :Design:
800 This method calls the :func:`futils.ispdf` function with the
801 given arguments.
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.
808 - ``0x25 0x50 0x44 0x46 0x2d``
810 """
811 return bool(futils.ispdf(path))
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.
817 Args:
818 path (str): Full path to the file to be tested.
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.
825 :Example:
827 Test if a file is a ZIP archive::
829 >>> from utils4 import utils
831 >>> utils.iszip('/path/to/file.zip')
832 True
834 Test if a file is a true Python wheel::
836 >>> from utils4 import utils
838 >>> utils.iszip('/path/to/sphinx-8.1.3-py3-none-any.whl')
839 True
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.
846 It is up to the caller to handle empty or spanned ZIP
847 archives appropriately.
849 :Design:
850 This method calls the :func:`futils.iszip` function with the
851 given arguments.
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.
858 - ``0x50 0x4b 0x03 0x04``: 'Standard' archive
859 - ``0x50 0x4b 0x05 0x06``: Empty archive
860 - ``0x50 0x4b 0x07 0x08``: Spanned archive
862 .. _zip-format: https://en.wikipedia.org/wiki/ZIP_(file_format)#Local_file_header
863 .. _zip-wheel: https://peps.python.org/pep-0491/#abstract
865 """
866 return bool(futils.iszip(path))
868def ping(server: str, count: int=1, timeout: int=5, verbose: bool=False) -> bool:
869 r"""Ping an IP address, server or web address.
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.
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.
884 A ping status:
886 - 0 returns True
887 - Non-zero returns False
889 If the server name is preceeded by ``\\`` or ``//``, these are
890 stripped out using the built-in :func:`os.path.basename()` function.
892 :Example:
894 Ping the local PC at 127.0.0.1::
896 >>> from utils4 import utils
898 >>> utils.ping(server='127.0.0.1')
899 True
902 Ping an unknown server::
904 >>> from utils4 import utils
906 >>> utils.ping(server='//S3DHOST01', verbose=True)
908 [PingError]:
909 ping: S3DHOST01: Temporary failure in name resolution
910 False
913 Ping an unreachable IP address::
915 >>> from utils4 import utils
917 >>> utils.ping(server='192.168.0.99', count=3, verbose=True)
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
925 --- 192.168.0.99 ping statistics ---
926 3 packets transmitted, 0 received, +3 errors, 100% packet loss, time 2037ms
927 False
929 Returns:
930 bool: True if the ping was successful, otherwise False.
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
963def strip_ansi_colour(text: str):
964 r"""Strip ANSI colour sequences from a string.
966 Args:
967 text (str): Text string to be cleaned.
969 Note:
970 This method is *very* basic and only caters to colour sequences.
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.
976 :Example:
978 Strip the colouring sequence from terminal text and return a
979 single string::
981 clean = ''.join(strip_ansi_colour(text))
983 Strip the colouring sequence from terminal text and return a list
984 of lines, with empty lines removed::
986 lines = list(filter(None, ''.join(strip_ansi_colour(text)).split('\n')))
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.
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
1002def testimport(module_name: str, verbose: bool=True) -> bool:
1003 """Test if a Python library is installed.
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.
1010 :Design:
1011 This is a small helper function designed to test if a library is
1012 installed before trying to import it.
1014 If the library is not intalled the user is notified, if the ``verbose``
1015 argument is True.
1017 :Internal Use:
1018 For example, the :meth:`~utils.getdrivername` function uses this
1019 function before attempting to import the ``pyodbc`` library.
1021 :Example:
1023 Execute a path only if ``mymodule`` is installed::
1025 >>> from utils4 import utils
1027 >>> if utils.testimport('mymodule', verbose=True):
1028 >>> import mymodule
1029 >>> ...
1030 >>> else:
1031 >>> ...
1033 Returns:
1034 bool: True if the library is installed, otherwise False.
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
1044def unidecode(string: str, **kwargs) -> str:
1045 """Attempt to convert a Unicode string object into a 7-bit ASCII string.
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.
1052 :Design:
1053 This function is a light wrapper around the :func:`unidecode.unidecode`
1054 function.
1056 **Per the** ``unicode`` **docstring:**
1058 "Transliterate an Unicode object into an ASCII string."
1060 Example::
1062 >>> unidecode(u"北亰")
1063 "Bei Jing "
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."
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."
1074 :Dependencies:
1076 - ``unidecode`` library
1078 :Example:
1080 Convert a Polish address into pure ASCII::
1082 >>> from utils4 import utils
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'
1089 Convert the first line of 'The Seventh Letter', by Plato::
1091 >>> from utils4 import utils
1093 >>> text = 'Πλάτων τοῖς Δίωνος οἰκείοις τε καὶ ἑταίροις εὖ πράττειν.'
1094 >>> utils.unidecode(text)
1095 'Platon tois Dionos oikeiois te kai etairois eu prattein.'
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.
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