• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pronovic / cedar-backup3 / 17835133915

18 Sep 2025 04:27PM UTC coverage: 73.362% (-0.03%) from 73.39%
17835133915

Pull #58

github

web-flow
Merge da02917f8 into a254a7c03
Pull Request #58: Exclude generated files in docs/_build from the Python sdist

7904 of 10774 relevant lines covered (73.36%)

0.73 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

81.4
/src/CedarBackup3/util.py
1
# vim: set ft=python ts=4 sw=4 expandtab:
2
# ruff: noqa: PLC1901
3
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
4
#
5
#              C E D A R
6
#          S O L U T I O N S       "Software done right."
7
#           S O F T W A R E
8
#
9
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
10
#
11
# Copyright (c) 2004-2008,2010,2015 Kenneth J. Pronovici.
12
# All rights reserved.
13
#
14
# Portions copyright (c) 2001, 2002 Python Software Foundation.
15
# All Rights Reserved.
16
#
17
# This program is free software; you can redistribute it and/or
18
# modify it under the terms of the GNU General Public License,
19
# Version 2, as published by the Free Software Foundation.
20
#
21
# This program is distributed in the hope that it will be useful,
22
# but WITHOUT ANY WARRANTY; without even the implied warranty of
23
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
24
#
25
# Copies of the GNU General Public License are available from
26
# the Free Software Foundation website, http://www.gnu.org/.
27
#
28
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
29
#
30
# Author   : Kenneth J. Pronovici <pronovic@ieee.org>
31
# Language : Python 3
32
# Project  : Cedar Backup, release 3
33
# Purpose  : Provides general-purpose utilities.
34
#
35
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
36

37
########################################################################
38
# Module documentation
39
########################################################################
40

41
"""
42
Provides general-purpose utilities.
43

44
Module Attributes
45
=================
46

47
Attributes:
48
   ISO_SECTOR_SIZE: Size of an ISO image sector, in bytes
49
   BYTES_PER_SECTOR: Number of bytes (B) per ISO sector
50
   BYTES_PER_KBYTE: Number of bytes (B) per kilobyte (kB)
51
   BYTES_PER_MBYTE: Number of bytes (B) per megabyte (MB)
52
   BYTES_PER_GBYTE: Number of bytes (B) per megabyte (GB)
53
   KBYTES_PER_MBYTE: Number of kilobytes (kB) per megabyte (MB)
54
   MBYTES_PER_GBYTE: Number of megabytes (MB) per gigabyte (GB)
55
   SECONDS_PER_MINUTE: Number of seconds per minute
56
   MINUTES_PER_HOUR: Number of minutes per hour
57
   HOURS_PER_DAY: Number of hours per day
58
   SECONDS_PER_DAY: Number of seconds per day
59
   UNIT_BYTES: Constant representing the byte (B) unit for conversion
60
   UNIT_KBYTES: Constant representing the kilobyte (kB) unit for conversion
61
   UNIT_MBYTES: Constant representing the megabyte (MB) unit for conversion
62
   UNIT_GBYTES: Constant representing the gigabyte (GB) unit for conversion
63
   UNIT_SECTORS: Constant representing the ISO sector unit for conversion
64

65
:author: Kenneth J. Pronovici <pronovic@ieee.org>
66
"""
67

68

69
########################################################################
70
# Imported modules
71
########################################################################
72

73
import logging
1✔
74
import math
1✔
75
import os
1✔
76
import platform
1✔
77
import posixpath
1✔
78
import re
1✔
79
import sys
1✔
80
import time
1✔
81
from decimal import Decimal
1✔
82
from functools import total_ordering
1✔
83
from numbers import Real
1✔
84
from subprocess import PIPE, STDOUT, Popen
1✔
85

86
from CedarBackup3.release import VERSION
1✔
87

88
try:
1✔
89
    import grp
1✔
90
    import pwd
1✔
91

92
    _UID_GID_AVAILABLE = True
1✔
93
except ImportError:
×
94
    _UID_GID_AVAILABLE = False
×
95

96

97
########################################################################
98
# Module-wide constants and variables
99
########################################################################
100

101
logger = logging.getLogger("CedarBackup3.log.util")
1✔
102
outputLogger = logging.getLogger("CedarBackup3.output")
1✔
103

104
ISO_SECTOR_SIZE = 2048.0  # in bytes
1✔
105
BYTES_PER_SECTOR = ISO_SECTOR_SIZE
1✔
106

107
BYTES_PER_KBYTE = 1024.0
1✔
108
KBYTES_PER_MBYTE = 1024.0
1✔
109
MBYTES_PER_GBYTE = 1024.0
1✔
110
BYTES_PER_MBYTE = BYTES_PER_KBYTE * KBYTES_PER_MBYTE
1✔
111
BYTES_PER_GBYTE = BYTES_PER_MBYTE * MBYTES_PER_GBYTE
1✔
112

113
SECONDS_PER_MINUTE = 60.0
1✔
114
MINUTES_PER_HOUR = 60.0
1✔
115
HOURS_PER_DAY = 24.0
1✔
116
SECONDS_PER_DAY = SECONDS_PER_MINUTE * MINUTES_PER_HOUR * HOURS_PER_DAY
1✔
117

118
UNIT_BYTES = 0
1✔
119
UNIT_KBYTES = 1
1✔
120
UNIT_MBYTES = 2
1✔
121
UNIT_GBYTES = 4
1✔
122
UNIT_SECTORS = 3
1✔
123

124
MTAB_FILE = "/etc/mtab"
1✔
125

126
MOUNT_COMMAND = ["mount"]
1✔
127
UMOUNT_COMMAND = ["umount"]
1✔
128

129
DEFAULT_LANGUAGE = "C"
1✔
130
LANG_VAR = "LANG"
1✔
131
LOCALE_VARS = [
1✔
132
    "LC_ADDRESS",
133
    "LC_ALL",
134
    "LC_COLLATE",
135
    "LC_CTYPE",
136
    "LC_IDENTIFICATION",
137
    "LC_MEASUREMENT",
138
    "LC_MESSAGES",
139
    "LC_MONETARY",
140
    "LC_NAME",
141
    "LC_NUMERIC",
142
    "LC_PAPER",
143
    "LC_TELEPHONE",
144
    "LC_TIME",
145
]
146

147

148
########################################################################
149
# UnorderedList class definition
150
########################################################################
151

152

153
class UnorderedList(list):
1✔
154
    """
155
    Class representing an "unordered list".
156

157
    An "unordered list" is a list in which only the contents matter, not the
158
    order in which the contents appear in the list.
159

160
    For instance, we might be keeping track of set of paths in a list, because
161
    it's convenient to have them in that form.  However, for comparison
162
    purposes, we would only care that the lists contain exactly the same
163
    contents, regardless of order.
164

165
    I have come up with two reasonable ways of doing this, plus a couple more
166
    that would work but would be a pain to implement.  My first method is to
167
    copy and sort each list, comparing the sorted versions.  This will only work
168
    if two lists with exactly the same members are guaranteed to sort in exactly
169
    the same order.  The second way would be to create two Sets and then compare
170
    the sets.  However, this would lose information about any duplicates in
171
    either list.  I've decided to go with option #1 for now.  I'll modify this
172
    code if I run into problems in the future.
173

174
    We override the original ``__eq__``, ``__ne__``, ``__ge__``, ``__gt__``,
175
    ``__le__`` and ``__lt__`` list methods to change the definition of the various
176
    comparison operators.  In all cases, the comparison is changed to return the
177
    result of the original operation *but instead comparing sorted lists*.
178
    This is going to be quite a bit slower than a normal list, so you probably
179
    only want to use it on small lists.
180
    """
181

182
    def __eq__(self, other):
1✔
183
        """
184
        Definition of ``==`` operator for this class.
185
        Args:
186
           other: Other object to compare to
187
        Returns:
188
            True/false depending on whether ``self == other``
189
        """
190
        if other is None:
1✔
191
            return False
×
192
        selfSorted = UnorderedList.mixedsort(self[:])
1✔
193
        otherSorted = UnorderedList.mixedsort(other[:])
1✔
194
        return selfSorted.__eq__(otherSorted)
1✔
195

196
    def __ne__(self, other):
1✔
197
        """
198
        Definition of ``!=`` operator for this class.
199
        Args:
200
           other: Other object to compare to
201
        Returns:
202
            True/false depending on whether ``self != other``
203
        """
204
        if other is None:
1✔
205
            return True
1✔
206
        selfSorted = UnorderedList.mixedsort(self[:])
1✔
207
        otherSorted = UnorderedList.mixedsort(other[:])
1✔
208
        return selfSorted.__ne__(otherSorted)
1✔
209

210
    def __ge__(self, other):
1✔
211
        """
212
        Definition of S{>=} operator for this class.
213
        Args:
214
           other: Other object to compare to
215
        Returns:
216
            True/false depending on whether ``self >= other``
217
        """
218
        if other is None:
×
219
            return True
×
220
        selfSorted = UnorderedList.mixedsort(self[:])
×
221
        otherSorted = UnorderedList.mixedsort(other[:])
×
222
        return selfSorted.__ge__(otherSorted)
×
223

224
    def __gt__(self, other):
1✔
225
        """
226
        Definition of ``>`` operator for this class.
227
        Args:
228
           other: Other object to compare to
229
        Returns:
230
            True/false depending on whether ``self > other``
231
        """
232
        if other is None:
1✔
233
            return True
1✔
234
        selfSorted = UnorderedList.mixedsort(self[:])
×
235
        otherSorted = UnorderedList.mixedsort(other[:])
×
236
        return selfSorted.__gt__(otherSorted)
×
237

238
    def __le__(self, other):
1✔
239
        """
240
        Definition of S{<=} operator for this class.
241
        Args:
242
           other: Other object to compare to
243
        Returns:
244
            True/false depending on whether ``self <= other``
245
        """
246
        if other is None:
×
247
            return False
×
248
        selfSorted = UnorderedList.mixedsort(self[:])
×
249
        otherSorted = UnorderedList.mixedsort(other[:])
×
250
        return selfSorted.__le__(otherSorted)
×
251

252
    def __lt__(self, other):
1✔
253
        """
254
        Definition of ``<`` operator for this class.
255
        Args:
256
           other: Other object to compare to
257
        Returns:
258
            True/false depending on whether ``self < other``
259
        """
260
        if other is None:
1✔
261
            return False
1✔
262
        selfSorted = UnorderedList.mixedsort(self[:])
1✔
263
        otherSorted = UnorderedList.mixedsort(other[:])
1✔
264
        return selfSorted.__lt__(otherSorted)
1✔
265

266
    @staticmethod
1✔
267
    def mixedsort(value):
1✔
268
        """
269
        Sort a list, making sure we don't blow up if the list happens to include mixed values.
270
        @see: http://stackoverflow.com/questions/26575183/how-can-i-get-2-x-like-sorting-behaviour-in-python-3-x
271
        """
272
        return sorted(value, key=UnorderedList.mixedkey)
1✔
273

274
    @staticmethod
1✔
275
    def mixedkey(value):
1✔
276
        """Provide a key for use by mixedsort()"""
277
        numeric = Real, Decimal
1✔
278
        if isinstance(value, numeric):
1✔
279
            typeinfo = numeric
1✔
280
        else:
281
            typeinfo = type(value)
1✔
282
        try:
1✔
283
            _ = value < value  # noqa: PLR0124
1✔
284
        except TypeError:
×
285
            value = repr(value)
×
286
        return repr(typeinfo), value
1✔
287

288

289
########################################################################
290
# AbsolutePathList class definition
291
########################################################################
292

293

294
class AbsolutePathList(UnorderedList):
1✔
295
    """
296
    Class representing a list of absolute paths.
297

298
    This is an unordered list.
299

300
    We override the ``append``, ``insert`` and ``extend`` methods to ensure that
301
    any item added to the list is an absolute path.
302

303
    Each item added to the list is encoded using :any:`encodePath`.  If we don't do
304
    this, we have problems trying certain operations between strings and unicode
305
    objects, particularly for "odd" filenames that can't be encoded in standard
306
    ASCII.
307
    """
308

309
    def append(self, item):
1✔
310
        """
311
        Overrides the standard ``append`` method.
312
        Raises:
313
           ValueError: If item is not an absolute path
314
        """
315
        if not (os.path.isabs(item) or posixpath.isabs(item)):  # Python 3.13 does not treat / as absolute on Windows
1✔
316
            raise ValueError("Not an absolute path: [%s]" % item)
1✔
317
        list.append(self, encodePath(item))
1✔
318

319
    def insert(self, index, item):
1✔
320
        """
321
        Overrides the standard ``insert`` method.
322
        Raises:
323
           ValueError: If item is not an absolute path
324
        """
325
        if not (os.path.isabs(item) or posixpath.isabs(item)):  # Python 3.13 does not treat / as absolute on Windows
1✔
326
            raise ValueError("Not an absolute path: [%s]" % item)
1✔
327
        list.insert(self, index, encodePath(item))
1✔
328

329
    def extend(self, seq):
1✔
330
        """
331
        Overrides the standard ``insert`` method.
332
        Raises:
333
           ValueError: If any item is not an absolute path
334
        """
335
        for item in seq:
1✔
336
            if not (os.path.isabs(item) or posixpath.isabs(item)):  # Python 3.13 does not treat / as absolute on Windows
1✔
337
                raise ValueError("Not an absolute path: [%s]" % item)
1✔
338
        for item in seq:
1✔
339
            list.append(self, encodePath(item))
1✔
340

341

342
########################################################################
343
# ObjectTypeList class definition
344
########################################################################
345

346

347
class ObjectTypeList(UnorderedList):
1✔
348
    """
349
    Class representing a list containing only objects with a certain type.
350

351
    This is an unordered list.
352

353
    We override the ``append``, ``insert`` and ``extend`` methods to ensure that
354
    any item added to the list matches the type that is requested.  The
355
    comparison uses the built-in ``isinstance``, which should allow subclasses of
356
    of the requested type to be added to the list as well.
357

358
    The ``objectName`` value will be used in exceptions, i.e. C{"Item must be a
359
    CollectDir object."} if ``objectName`` is ``"CollectDir"``.
360
    """
361

362
    def __init__(self, objectType, objectName):
1✔
363
        """
364
        Initializes a typed list for a particular type.
365
        Args:
366
           objectType: Type that the list elements must match
367
           objectName: Short string containing the "name" of the type
368
        """
369
        super().__init__()
1✔
370
        self.objectType = objectType
1✔
371
        self.objectName = objectName
1✔
372

373
    def append(self, item):
1✔
374
        """
375
        Overrides the standard ``append`` method.
376
        Raises:
377
           ValueError: If item does not match requested type
378
        """
379
        if not isinstance(item, self.objectType):
1✔
380
            raise ValueError("Item must be a %s object." % self.objectName)
1✔
381
        list.append(self, item)
1✔
382

383
    def insert(self, index, item):
1✔
384
        """
385
        Overrides the standard ``insert`` method.
386
        Raises:
387
           ValueError: If item does not match requested type
388
        """
389
        if not isinstance(item, self.objectType):
1✔
390
            raise ValueError("Item must be a %s object." % self.objectName)
1✔
391
        list.insert(self, index, item)
1✔
392

393
    def extend(self, seq):
1✔
394
        """
395
        Overrides the standard ``insert`` method.
396
        Raises:
397
           ValueError: If item does not match requested type
398
        """
399
        for item in seq:
1✔
400
            if not isinstance(item, self.objectType):
1✔
401
                raise ValueError("All items must be %s objects." % self.objectName)
1✔
402
        list.extend(self, seq)
1✔
403

404

405
########################################################################
406
# RestrictedContentList class definition
407
########################################################################
408

409

410
class RestrictedContentList(UnorderedList):
1✔
411
    """
412
    Class representing a list containing only object with certain values.
413

414
    This is an unordered list.
415

416
    We override the ``append``, ``insert`` and ``extend`` methods to ensure that
417
    any item added to the list is among the valid values.  We use a standard
418
    comparison, so pretty much anything can be in the list of valid values.
419

420
    The ``valuesDescr`` value will be used in exceptions, i.e. C{"Item must be
421
    one of values in VALID_ACTIONS"} if ``valuesDescr`` is ``"VALID_ACTIONS"``.
422

423
    *Note:*  This class doesn't make any attempt to trap for nonsensical
424
    arguments.  All of the values in the values list should be of the same type
425
    (i.e. strings).  Then, all list operations also need to be of that type
426
    (i.e. you should always insert or append just strings).  If you mix types --
427
    for instance lists and strings -- you will likely see AttributeError
428
    exceptions or other problems.
429
    """
430

431
    def __init__(self, valuesList, valuesDescr, prefix=None):
1✔
432
        """
433
        Initializes a list restricted to containing certain values.
434
        Args:
435
           valuesList: List of valid values
436
           valuesDescr: Short string describing list of values
437
           prefix: Prefix to use in error messages (None results in prefix "Item")
438
        """
439
        super().__init__()
1✔
440
        self.prefix = "Item"
1✔
441
        if prefix is not None:
1✔
442
            self.prefix = prefix
×
443
        self.valuesList = valuesList
1✔
444
        self.valuesDescr = valuesDescr
1✔
445

446
    def append(self, item):
1✔
447
        """
448
        Overrides the standard ``append`` method.
449
        Raises:
450
           ValueError: If item is not in the values list
451
        """
452
        if item not in self.valuesList:
1✔
453
            raise ValueError("%s must be one of the values in %s." % (self.prefix, self.valuesDescr))
1✔
454
        list.append(self, item)
1✔
455

456
    def insert(self, index, item):
1✔
457
        """
458
        Overrides the standard ``insert`` method.
459
        Raises:
460
           ValueError: If item is not in the values list
461
        """
462
        if item not in self.valuesList:
1✔
463
            raise ValueError("%s must be one of the values in %s." % (self.prefix, self.valuesDescr))
1✔
464
        list.insert(self, index, item)
1✔
465

466
    def extend(self, seq):
1✔
467
        """
468
        Overrides the standard ``insert`` method.
469
        Raises:
470
           ValueError: If item is not in the values list
471
        """
472
        for item in seq:
1✔
473
            if item not in self.valuesList:
1✔
474
                raise ValueError("%s must be one of the values in %s." % (self.prefix, self.valuesDescr))
1✔
475
        list.extend(self, seq)
1✔
476

477

478
########################################################################
479
# RegexMatchList class definition
480
########################################################################
481

482

483
class RegexMatchList(UnorderedList):
1✔
484
    """
485
    Class representing a list containing only strings that match a regular expression.
486

487
    If ``emptyAllowed`` is passed in as ``False``, then empty strings are
488
    explicitly disallowed, even if they happen to match the regular expression.
489
    (``None`` values are always disallowed, since string operations are not
490
    permitted on ``None``.)
491

492
    This is an unordered list.
493

494
    We override the ``append``, ``insert`` and ``extend`` methods to ensure that
495
    any item added to the list matches the indicated regular expression.
496

497
    *Note:* If you try to put values that are not strings into the list, you will
498
    likely get either TypeError or AttributeError exceptions as a result.
499
    """
500

501
    def __init__(self, valuesRegex, emptyAllowed=True, prefix=None):
1✔
502
        """
503
        Initializes a list restricted to containing certain values.
504
        Args:
505
           valuesRegex: Regular expression that must be matched, as a string
506
           emptyAllowed: Indicates whether empty or None values are allowed
507
           prefix: Prefix to use in error messages (None results in prefix "Item")
508
        """
509
        super().__init__()
1✔
510
        self.prefix = "Item"
1✔
511
        if prefix is not None:
1✔
512
            self.prefix = prefix
1✔
513
        self.valuesRegex = valuesRegex
1✔
514
        self.emptyAllowed = emptyAllowed
1✔
515
        self.pattern = re.compile(self.valuesRegex)
1✔
516

517
    def append(self, item):
1✔
518
        """
519
        Overrides the standard ``append`` method.
520

521
        Raises:
522
           ValueError: If item is None
523
           ValueError: If item is empty and empty values are not allowed
524
           ValueError: If item does not match the configured regular expression
525
        """
526
        if item is None or (not self.emptyAllowed and item == ""):
1✔
527
            raise ValueError("%s cannot be empty." % self.prefix)
1✔
528
        if not self.pattern.search(item):
1✔
529
            raise ValueError("%s is not valid: [%s]" % (self.prefix, item))
1✔
530
        list.append(self, item)
1✔
531

532
    def insert(self, index, item):
1✔
533
        """
534
        Overrides the standard ``insert`` method.
535

536
        Raises:
537
           ValueError: If item is None
538
           ValueError: If item is empty and empty values are not allowed
539
           ValueError: If item does not match the configured regular expression
540
        """
541
        if item is None or (not self.emptyAllowed and item == ""):
1✔
542
            raise ValueError("%s cannot be empty." % self.prefix)
1✔
543
        if not self.pattern.search(item):
1✔
544
            raise ValueError("%s is not valid [%s]" % (self.prefix, item))
1✔
545
        list.insert(self, index, item)
1✔
546

547
    def extend(self, seq):
1✔
548
        """
549
        Overrides the standard ``insert`` method.
550

551
        Raises:
552
           ValueError: If any item is None
553
           ValueError: If any item is empty and empty values are not allowed
554
           ValueError: If any item does not match the configured regular expression
555
        """
556
        for item in seq:
1✔
557
            if item is None or (not self.emptyAllowed and item == ""):
1✔
558
                raise ValueError("%s cannot be empty." % self.prefix)
1✔
559
            if not self.pattern.search(item):
1✔
560
                raise ValueError("%s is not valid: [%s]" % (self.prefix, item))
1✔
561
        list.extend(self, seq)
1✔
562

563

564
########################################################################
565
# RegexList class definition
566
########################################################################
567

568

569
class RegexList(UnorderedList):
1✔
570
    """
571
    Class representing a list of valid regular expression strings.
572

573
    This is an unordered list.
574

575
    We override the ``append``, ``insert`` and ``extend`` methods to ensure that
576
    any item added to the list is a valid regular expression.
577
    """
578

579
    def append(self, item):
1✔
580
        """
581
        Overrides the standard ``append`` method.
582
        Raises:
583
           ValueError: If item is not an absolute path
584
        """
585
        try:
1✔
586
            re.compile(item)
1✔
587
        except re.error:
1✔
588
            raise ValueError("Not a valid regular expression: [%s]" % item)
1✔
589
        list.append(self, item)
1✔
590

591
    def insert(self, index, item):
1✔
592
        """
593
        Overrides the standard ``insert`` method.
594
        Raises:
595
           ValueError: If item is not an absolute path
596
        """
597
        try:
1✔
598
            re.compile(item)
1✔
599
        except re.error:
1✔
600
            raise ValueError("Not a valid regular expression: [%s]" % item)
1✔
601
        list.insert(self, index, item)
1✔
602

603
    def extend(self, seq):
1✔
604
        """
605
        Overrides the standard ``insert`` method.
606
        Raises:
607
           ValueError: If any item is not an absolute path
608
        """
609
        for item in seq:
1✔
610
            try:
1✔
611
                re.compile(item)
1✔
612
            except re.error:
1✔
613
                raise ValueError("Not a valid regular expression: [%s]" % item)
1✔
614
        for item in seq:
1✔
615
            list.append(self, item)
1✔
616

617

618
########################################################################
619
# Directed graph implementation
620
########################################################################
621

622

623
class _Vertex:
1✔
624
    """
625
    Represents a vertex (or node) in a directed graph.
626
    """
627

628
    def __init__(self, name):
1✔
629
        """
630
        Constructor.
631
        Args:
632
           name (String value): Name of this graph vertex
633

634
        """
635
        self.name = name
1✔
636
        self.endpoints = []
1✔
637
        self.state = None
1✔
638

639

640
@total_ordering
1✔
641
class DirectedGraph:
1✔
642
    """
643
    Represents a directed graph.
644

645
    A graph **G=(V,E)** consists of a set of vertices **V** together with a set
646
    **E** of vertex pairs or edges.  In a directed graph, each edge also has an
647
    associated direction (from vertext **v1** to vertex **v2**).  A ``DirectedGraph``
648
    object provides a way to construct a directed graph and execute a depth-
649
    first search.
650

651
    This data structure was designed based on the graphing chapter in
652
    U{The Algorithm Design Manual<http://www2.toki.or.id/book/AlgDesignManual/>},
653
    by Steven S. Skiena.
654

655
    This class is intended to be used by Cedar Backup for dependency ordering.
656
    Because of this, it's not quite general-purpose.  Unlike a "general" graph,
657
    every vertex in this graph has at least one edge pointing to it, from a
658
    special "start" vertex.  This is so no vertices get "lost" either because
659
    they have no dependencies or because nothing depends on them.
660
    """
661

662
    _UNDISCOVERED = 0
1✔
663
    _DISCOVERED = 1
1✔
664
    _EXPLORED = 2
1✔
665

666
    def __init__(self, name):
1✔
667
        """
668
        Directed graph constructor.
669

670
        Args:
671
           name (String value): Name of this graph
672

673
        """
674
        if name is None or name == "":
1✔
675
            raise ValueError("Graph name must be non-empty.")
1✔
676
        self._name = name
1✔
677
        self._vertices = {}
1✔
678
        self._startVertex = _Vertex(None)  # start vertex is only vertex with no name
1✔
679

680
    def __repr__(self):
1✔
681
        """
682
        Official string representation for class instance.
683
        """
684
        return "DirectedGraph(%s)" % self.name
1✔
685

686
    def __str__(self):
1✔
687
        """
688
        Informal string representation for class instance.
689
        """
690
        return self.__repr__()
1✔
691

692
    def __eq__(self, other):
1✔
693
        """Equals operator, implemented in terms of original Python 2 compare operator."""
694
        return self.__cmp__(other) == 0
×
695

696
    def __lt__(self, other):
1✔
697
        """Less-than operator, implemented in terms of original Python 2 compare operator."""
698
        return self.__cmp__(other) < 0
×
699

700
    def __gt__(self, other):
1✔
701
        """Greater-than operator, implemented in terms of original Python 2 compare operator."""
702
        return self.__cmp__(other) > 0
×
703

704
    def __cmp__(self, other):
1✔
705
        """
706
        Original Python 2 comparison operator.
707
        Args:
708
           other: Other object to compare to
709
        Returns:
710
            -1/0/1 depending on whether self is ``<``, ``=`` or ``>`` other
711
        """
712
        if other is None:
×
713
            return 1
×
714
        if self.name != other.name:
×
715
            if str(self.name or "") < str(other.name or ""):
×
716
                return -1
×
717
            else:
718
                return 1
×
719
        if self._vertices != other._vertices:  # noqa: SLF001
×
720
            if self._vertices < other._vertices:  # noqa: SLF001
×
721
                return -1
×
722
            else:
723
                return 1
×
724
        return 0
×
725

726
    def _getName(self):
1✔
727
        """
728
        Property target used to get the graph name.
729
        """
730
        return self._name
1✔
731

732
    name = property(_getName, None, None, "Name of the graph.")
1✔
733

734
    def createVertex(self, name):
1✔
735
        """
736
        Creates a named vertex.
737
        Args:
738
           name: vertex name
739
        Raises:
740
           ValueError: If the vertex name is ``None`` or empty
741
        """
742
        if name is None or name == "":
1✔
743
            raise ValueError("Vertex name must be non-empty.")
×
744
        vertex = _Vertex(name)
1✔
745
        self._startVertex.endpoints.append(vertex)  # so every vertex is connected at least once
1✔
746
        self._vertices[name] = vertex
1✔
747

748
    def createEdge(self, start, finish):
1✔
749
        """
750
        Adds an edge with an associated direction, from ``start`` vertex to ``finish`` vertex.
751
        Args:
752
           start: Name of start vertex
753
           finish: Name of finish vertex
754
        Raises:
755
           ValueError: If one of the named vertices is unknown
756
        """
757
        try:
1✔
758
            startVertex = self._vertices[start]
1✔
759
            finishVertex = self._vertices[finish]
1✔
760
            startVertex.endpoints.append(finishVertex)
1✔
761
        except KeyError as e:
1✔
762
            raise ValueError("Vertex [%s] could not be found." % e)
1✔
763

764
    def topologicalSort(self):
1✔
765
        """
766
        Implements a topological sort of the graph.
767

768
        This method also enforces that the graph is a directed acyclic graph,
769
        which is a requirement of a topological sort.
770

771
        A directed acyclic graph (or "DAG") is a directed graph with no directed
772
        cycles.  A topological sort of a DAG is an ordering on the vertices such
773
        that all edges go from left to right.  Only an acyclic graph can have a
774
        topological sort, but any DAG has at least one topological sort.
775

776
        Since a topological sort only makes sense for an acyclic graph, this
777
        method throws an exception if a cycle is found.
778

779
        A depth-first search only makes sense if the graph is acyclic.  If the
780
        graph contains any cycles, it is not possible to determine a consistent
781
        ordering for the vertices.
782

783
        *Note:* If a particular vertex has no edges, then its position in the
784
        final list depends on the order in which the vertices were created in the
785
        graph.  If you're using this method to determine a dependency order, this
786
        makes sense: a vertex with no dependencies can go anywhere (and will).
787

788
        Returns:
789
            Ordering on the vertices so that all edges go from left to right
790

791
        Raises:
792
           ValueError: If a cycle is found in the graph
793
        """
794
        ordering = []
1✔
795
        for key in self._vertices:
1✔
796
            vertex = self._vertices[key]
1✔
797
            vertex.state = self._UNDISCOVERED
1✔
798
        for key in self._vertices:
1✔
799
            vertex = self._vertices[key]
1✔
800
            if vertex.state == self._UNDISCOVERED:
1✔
801
                self._topologicalSort(self._startVertex, ordering)
1✔
802
        return ordering
1✔
803

804
    def _topologicalSort(self, vertex, ordering):
1✔
805
        """
806
        Recursive depth first search function implementing topological sort.
807
        Args:
808
           vertex: Vertex to search
809
           ordering: List of vertices in proper order
810
        """
811
        vertex.state = self._DISCOVERED
1✔
812
        for endpoint in vertex.endpoints:
1✔
813
            if endpoint.state == self._UNDISCOVERED:
1✔
814
                self._topologicalSort(endpoint, ordering)
1✔
815
            elif endpoint.state != self._EXPLORED:
1✔
816
                raise ValueError("Cycle found in graph (found '%s' while searching '%s')." % (vertex.name, endpoint.name))
1✔
817
        if vertex.name is not None:
1✔
818
            ordering.insert(0, vertex.name)
1✔
819
        vertex.state = self._EXPLORED
1✔
820

821

822
########################################################################
823
# PathResolverSingleton class definition
824
########################################################################
825

826

827
class PathResolverSingleton:
1✔
828
    """
829
    Singleton used for resolving executable paths.
830

831
    Various functions throughout Cedar Backup (including extensions) need a way
832
    to resolve the path of executables that they use.  For instance, the image
833
    functionality needs to find the ``mkisofs`` executable, and the Subversion
834
    extension needs to find the ``svnlook`` executable.  Cedar Backup's original
835
    behavior was to assume that the simple name (``"svnlook"`` or whatever) was
836
    available on the caller's ``$PATH``, and to fail otherwise.   However, this
837
    turns out to be less than ideal, since for instance the root user might not
838
    always have executables like ``svnlook`` in its path.
839

840
    One solution is to specify a path (either via an absolute path or some sort
841
    of path insertion or path appending mechanism) that would apply to the
842
    ``executeCommand()`` function.  This is not difficult to implement, but it
843
    seem like kind of a "big hammer" solution.  Besides that, it might also
844
    represent a security flaw (for instance, I prefer not to mess with root's
845
    ``$PATH`` on the application level if I don't have to).
846

847
    The alternative is to set up some sort of configuration for the path to
848
    certain executables, i.e. "find ``svnlook`` in ``/usr/local/bin/svnlook``" or
849
    whatever.  This PathResolverSingleton aims to provide a good solution to the
850
    mapping problem.  Callers of all sorts (extensions or not) can get an
851
    instance of the singleton.  Then, they call the ``lookup`` method to try and
852
    resolve the executable they are looking for.  Through the ``lookup`` method,
853
    the caller can also specify a default to use if a mapping is not found.
854
    This way, with no real effort on the part of the caller, behavior can neatly
855
    degrade to something equivalent to the current behavior if there is no
856
    special mapping or if the singleton was never initialized in the first
857
    place.
858

859
    Even better, extensions automagically get access to the same resolver
860
    functionality, and they don't even need to understand how the mapping
861
    happens.  All extension authors need to do is document what executables
862
    their code requires, and the standard resolver configuration section will
863
    meet their needs.
864

865
    The class should be initialized once through the constructor somewhere in
866
    the main routine.  Then, the main routine should call the :any:`fill` method to
867
    fill in the resolver's internal structures.  Everyone else who needs to
868
    resolve a path will get an instance of the class using :any:`getInstance` and
869
    will then just call the :any:`lookup` method.
870

871
    Attributes:
872
       _instance: Holds a reference to the singleton
873
       _mapping: Internal mapping from resource name to path
874
    """
875

876
    _instance = None  # Holds a reference to singleton instance
1✔
877

878
    class _Helper:
1✔
879
        """Helper class to provide a singleton factory method."""
880

881
        def __init__(self):
1✔
882
            pass
1✔
883

884
        def __call__(self, *args, **kw):  # noqa: ARG002
1✔
885
            if PathResolverSingleton._instance is None:
1✔
886
                obj = PathResolverSingleton()
1✔
887
                PathResolverSingleton._instance = obj
1✔
888
            return PathResolverSingleton._instance
1✔
889

890
    getInstance = _Helper()  # Method that callers will use to get an instance
1✔
891

892
    def __init__(self):
1✔
893
        """Singleton constructor, which just creates the singleton instance."""
894
        PathResolverSingleton._instance = self
1✔
895
        self._mapping = {}
1✔
896

897
    def lookup(self, name, default=None):
1✔
898
        """
899
        Looks up name and returns the resolved path associated with the name.
900
        Args:
901
           name: Name of the path resource to resolve
902
           default: Default to return if resource cannot be resolved
903
        Returns:
904
            Resolved path associated with name, or default if name can't be resolved
905
        """
906
        value = default
1✔
907
        if name in list(self._mapping.keys()):
1✔
908
            value = self._mapping[name]
1✔
909
        logger.debug("Resolved command [%s] to [%s].", name, value)
1✔
910
        return value
1✔
911

912
    def fill(self, mapping):
1✔
913
        """
914
        Fills in the singleton's internal mapping from name to resource.
915
        Args:
916
           mapping (Dictionary mapping name to path, both as strings): Mapping from resource name to path
917

918
        """
919
        self._mapping = {}
1✔
920
        for key in list(mapping.keys()):
1✔
921
            self._mapping[key] = mapping[key]
1✔
922

923

924
########################################################################
925
# Pipe class definition
926
########################################################################
927

928

929
class Pipe(Popen):
1✔
930
    """
931
    Specialized pipe class for use by ``executeCommand``.
932

933
    The :any:`executeCommand` function needs a specialized way of interacting
934
    with a pipe.  First, ``executeCommand`` only reads from the pipe, and
935
    never writes to it.  Second, ``executeCommand`` needs a way to discard all
936
    output written to ``stderr``, as a means of simulating the shell
937
    ``2>/dev/null`` construct.
938
    """
939

940
    # noinspection PyArgumentList
941
    def __init__(self, cmd, bufsize=-1, ignoreStderr=False):
1✔
942
        stderr = STDOUT
1✔
943
        if ignoreStderr:
1✔
944
            devnull = nullDevice()
1✔
945
            stderr = os.open(devnull, os.O_RDWR)
1✔
946
        Popen.__init__(self, shell=False, args=cmd, bufsize=bufsize, stdin=None, stdout=PIPE, stderr=stderr)
1✔
947

948

949
########################################################################
950
# Diagnostics class definition
951
########################################################################
952

953

954
class Diagnostics:
1✔
955
    """
956
    Class holding runtime diagnostic information.
957

958
    Diagnostic information is information that is useful to get from users for
959
    debugging purposes.  I'm consolidating it all here into one object.
960

961
    """
962

963
    def __init__(self):
1✔
964
        """
965
        Constructor for the ``Diagnostics`` class.
966
        """
967

968
    def __repr__(self):
1✔
969
        """
970
        Official string representation for class instance.
971
        """
972
        return "Diagnostics()"
×
973

974
    def __str__(self):
1✔
975
        """
976
        Informal string representation for class instance.
977
        """
978
        return self.__repr__()
×
979

980
    def getValues(self):
1✔
981
        """
982
        Get a map containing all of the diagnostic values.
983
        Returns:
984
            Map from diagnostic name to diagnostic value
985
        """
986
        values = {}
1✔
987
        values["version"] = self.version
1✔
988
        values["interpreter"] = self.interpreter
1✔
989
        values["platform"] = self.platform
1✔
990
        values["encoding"] = self.encoding
1✔
991
        values["locale"] = self.locale
1✔
992
        values["timestamp"] = self.timestamp
1✔
993
        return values
1✔
994

995
    def printDiagnostics(self, fd=sys.stdout, prefix=""):
1✔
996
        """
997
        Pretty-print diagnostic information to a file descriptor.
998
        Args:
999
           fd: File descriptor used to print information
1000
           prefix: Prefix string (if any) to place onto printed lines
1001
        *Note:* The ``fd`` is used rather than ``print`` to facilitate unit testing.
1002
        """
1003
        lines = self._buildDiagnosticLines(prefix)
1✔
1004
        for line in lines:
1✔
1005
            fd.write("%s\n" % line)
1✔
1006

1007
    def logDiagnostics(self, method, prefix=""):
1✔
1008
        """
1009
        Pretty-print diagnostic information using a logger method.
1010
        Args:
1011
           method: Logger method to use for logging (i.e. logger.info)
1012
           prefix: Prefix string (if any) to place onto printed lines
1013
        """
1014
        lines = self._buildDiagnosticLines(prefix)
1✔
1015
        for line in lines:
1✔
1016
            method("%s" % line)
1✔
1017

1018
    def _buildDiagnosticLines(self, prefix=""):
1✔
1019
        """
1020
        Build a set of pretty-printed diagnostic lines.
1021
        Args:
1022
           prefix: Prefix string (if any) to place onto printed lines
1023
        Returns:
1024
            List of strings, not terminated by newlines
1025
        """
1026
        values = self.getValues()
1✔
1027
        keys = list(values.keys())
1✔
1028
        keys.sort()
1✔
1029
        tmax = Diagnostics._getMaxLength(keys) + 3  # three extra dots in output
1✔
1030
        lines = []
1✔
1031
        for key in keys:
1✔
1032
            title = key.title()
1✔
1033
            title += (tmax - len(title)) * "."
1✔
1034
            value = values[key]
1✔
1035
            line = "%s%s: %s" % (prefix, title, value)
1✔
1036
            lines.append(line)
1✔
1037
        return lines
1✔
1038

1039
    @staticmethod
1✔
1040
    def _getMaxLength(values):
1✔
1041
        """
1042
        Get the maximum length from among a list of strings.
1043
        """
1044
        tmax = 0
1✔
1045
        for value in values:
1✔
1046
            tmax = max(tmax, len(value))
1✔
1047
        return tmax
1✔
1048

1049
    def _getVersion(self):
1✔
1050
        """
1051
        Property target to get the Cedar Backup version.
1052
        """
1053
        return "Cedar Backup %s" % VERSION
1✔
1054

1055
    def _getInterpreter(self):
1✔
1056
        """
1057
        Property target to get the Python interpreter version.
1058
        """
1059
        version = sys.version_info
1✔
1060
        return "Python %d.%d.%d (%s)" % (version[0], version[1], version[2], version[3])
1✔
1061

1062
    def _getEncoding(self):
1✔
1063
        """
1064
        Property target to get the filesystem encoding.
1065
        """
1066
        return sys.getfilesystemencoding() or sys.getdefaultencoding()
1✔
1067

1068
    def _getPlatform(self):
1✔
1069
        """
1070
        Property target to get the operating system platform.
1071
        """
1072
        try:
1✔
1073
            if sys.platform == "win32":
1✔
1074
                sysname = "win32"
×
1075
                release = platform.platform()
×
1076
                machine = platform.machine()
×
1077
                return "%s (%s %s)" % (sysname, release, machine)
×
1078
            else:
1079
                uname = os.uname()
1✔
1080
                sysname = uname[0]  # i.e. Linux
1✔
1081
                release = uname[2]  # i.e. 2.16.18-2
1✔
1082
                machine = uname[4]  # i.e. i686
1✔
1083
                return "%s (%s %s %s)" % (sys.platform, sysname, release, machine)
1✔
1084
        except:
×
1085
            return sys.platform
×
1086

1087
    def _getLocale(self):
1✔
1088
        """
1089
        Property target to get the default locale that is in effect.
1090
        """
1091
        try:
1✔
1092
            import locale  # noqa: PLC0415
1✔
1093

1094
            try:
1✔
1095
                return locale.getlocale()[0]  # python >= 3.11 deprecates getdefaultlocale() in favor of getlocale()
1✔
1096
            except:
×
1097
                return locale.getdefaultlocale()[0]
×
1098
        except:
×
1099
            return "(unknown)"
×
1100

1101
    def _getTimestamp(self):
1✔
1102
        """
1103
        Property target to get a current date/time stamp.
1104
        """
1105
        try:
1✔
1106
            import datetime  # noqa: PLC0415
1✔
1107

1108
            if list(map(int, [sys.version_info[0], sys.version_info[1]])) < [3, 12]:
1✔
1109
                # Starting with Python 3.12, utcnow() is deprecated
1110
                return datetime.datetime.utcnow().ctime() + " UTC"
×
1111
            else:
1112
                return datetime.datetime.now(datetime.UTC).ctime() + " UTC"
1✔
1113
        except:
×
1114
            return "(unknown)"
×
1115

1116
    version = property(_getVersion, None, None, "Cedar Backup version.")
1✔
1117
    interpreter = property(_getInterpreter, None, None, "Python interpreter version.")
1✔
1118
    platform = property(_getPlatform, None, None, "Platform identifying information.")
1✔
1119
    encoding = property(_getEncoding, None, None, "Filesystem encoding that is in effect.")
1✔
1120
    locale = property(_getLocale, None, None, "Locale that is in effect.")
1✔
1121
    timestamp = property(_getTimestamp, None, None, "Current timestamp.")
1✔
1122

1123

1124
########################################################################
1125
# General utility functions
1126
########################################################################
1127

1128
######################
1129
# sortDict() function
1130
######################
1131

1132

1133
def sortDict(d):
1✔
1134
    """
1135
    Returns the keys of the dictionary sorted by value.
1136
    Args:
1137
       d: Dictionary to operate on
1138
    Returns:
1139
        List of dictionary keys sorted in order by dictionary value
1140
    """
1141
    items = list(d.items())
1✔
1142
    items.sort(key=lambda x: (x[1], x[0]))  # noqa: FURB118 # sort by value and then by key
1✔
1143
    return [key for key, value in items]
1✔
1144

1145

1146
########################
1147
# removeKeys() function
1148
########################
1149

1150

1151
def removeKeys(d, keys):
1✔
1152
    """
1153
    Removes all of the keys from the dictionary.
1154
    The dictionary is altered in-place.
1155
    Each key must exist in the dictionary.
1156
    Args:
1157
       d: Dictionary to operate on
1158
       keys: List of keys to remove
1159
    Raises:
1160
       KeyError: If one of the keys does not exist
1161
    """
1162
    for key in keys:
1✔
1163
        del d[key]
1✔
1164

1165

1166
#########################
1167
# convertSize() function
1168
#########################
1169

1170

1171
def convertSize(size, fromUnit, toUnit):
1✔
1172
    """
1173
    Converts a size in one unit to a size in another unit.
1174

1175
    This is just a convenience function so that the functionality can be
1176
    implemented in just one place.  Internally, we convert values to bytes and
1177
    then to the final unit.
1178

1179
    The available units are:
1180

1181
       - ``UNIT_BYTES`` - Bytes
1182
       - ``UNIT_KBYTES`` - Kilobytes, where 1 kB = 1024 B
1183
       - ``UNIT_MBYTES`` - Megabytes, where 1 MB = 1024 kB
1184
       - ``UNIT_GBYTES`` - Gigabytes, where 1 GB = 1024 MB
1185
       - ``UNIT_SECTORS`` - Sectors, where 1 sector = 2048 B
1186

1187
    Args:
1188
       size (Integer or float value in units of ``fromUnit``): Size to convert
1189
       fromUnit (One of the units listed above): Unit to convert from
1190
       toUnit (One of the units listed above): Unit to convert to
1191
    Returns:
1192
        Number converted to new unit, as a float
1193
    Raises:
1194
       ValueError: If one of the units is invalid
1195
    """
1196
    if size is None:
1✔
1197
        raise ValueError("Cannot convert size of None.")
1✔
1198
    if fromUnit == UNIT_BYTES:
1✔
1199
        byteSize = float(size)
1✔
1200
    elif fromUnit == UNIT_KBYTES:
1✔
1201
        byteSize = float(size) * BYTES_PER_KBYTE
1✔
1202
    elif fromUnit == UNIT_MBYTES:
1✔
1203
        byteSize = float(size) * BYTES_PER_MBYTE
1✔
1204
    elif fromUnit == UNIT_GBYTES:
1✔
1205
        byteSize = float(size) * BYTES_PER_GBYTE
1✔
1206
    elif fromUnit == UNIT_SECTORS:
1✔
1207
        byteSize = float(size) * BYTES_PER_SECTOR
1✔
1208
    else:
1209
        raise ValueError("Unknown 'from' unit %s." % fromUnit)
1✔
1210
    if toUnit == UNIT_BYTES:
1✔
1211
        return byteSize
1✔
1212
    elif toUnit == UNIT_KBYTES:
1✔
1213
        return byteSize / BYTES_PER_KBYTE
1✔
1214
    elif toUnit == UNIT_MBYTES:
1✔
1215
        return byteSize / BYTES_PER_MBYTE
1✔
1216
    elif toUnit == UNIT_GBYTES:
1✔
1217
        return byteSize / BYTES_PER_GBYTE
1✔
1218
    elif toUnit == UNIT_SECTORS:
1✔
1219
        return byteSize / BYTES_PER_SECTOR
1✔
1220
    else:
1221
        raise ValueError("Unknown 'to' unit %s." % toUnit)
1✔
1222

1223

1224
##########################
1225
# displayBytes() function
1226
##########################
1227

1228

1229
def displayBytes(bytes, digits=2):  # noqa: A002
1✔
1230
    """
1231
    Format a byte quantity so it can be sensibly displayed.
1232

1233
    It's rather difficult to look at a number like "72372224 bytes" and get any
1234
    meaningful information out of it.  It would be more useful to see something
1235
    like "69.02 MB".  That's what this function does.  Any time you want to display
1236
    a byte value, i.e.::
1237

1238
       print "Size: %s bytes" % bytes
1239

1240
    Call this function instead::
1241

1242
       print "Size: %s" % displayBytes(bytes)
1243

1244
    What comes out will be sensibly formatted.  The indicated number of digits
1245
    will be listed after the decimal point, rounded based on whatever rules are
1246
    used by Python's standard ``%f`` string format specifier. (Values less than 1
1247
    kB will be listed in bytes and will not have a decimal point, since the
1248
    concept of a fractional byte is nonsensical.)
1249

1250
    Args:
1251
       bytes (Integer number of bytes): Byte quantity
1252
       digits (Integer value, typically 2-5): Number of digits to display after the decimal point
1253
    Returns:
1254
        String, formatted for sensible display
1255
    """
1256
    if bytes is None:
1✔
1257
        raise ValueError("Cannot display byte value of None.")
1✔
1258
    bytes = float(bytes)  # noqa: A001
1✔
1259
    if math.fabs(bytes) < BYTES_PER_KBYTE:
1✔
1260
        fmt = "%.0f bytes"
1✔
1261
        value = bytes
1✔
1262
    elif math.fabs(bytes) < BYTES_PER_MBYTE:
1✔
1263
        fmt = "%." + "%d" % digits + "f kB"
1✔
1264
        value = bytes / BYTES_PER_KBYTE
1✔
1265
    elif math.fabs(bytes) < BYTES_PER_GBYTE:
1✔
1266
        fmt = "%." + "%d" % digits + "f MB"
1✔
1267
        value = bytes / BYTES_PER_MBYTE
1✔
1268
    else:
1269
        fmt = "%." + "%d" % digits + "f GB"
1✔
1270
        value = bytes / BYTES_PER_GBYTE
1✔
1271
    return fmt % value
1✔
1272

1273

1274
##################################
1275
# getFunctionReference() function
1276
##################################
1277

1278

1279
def getFunctionReference(module, function):
1✔
1280
    """
1281
    Gets a reference to a named function.
1282

1283
    This does some hokey-pokey to get back a reference to a dynamically named
1284
    function.  For instance, say you wanted to get a reference to the
1285
    ``os.path.isdir`` function.  You could use::
1286

1287
       myfunc = getFunctionReference("os.path", "isdir")
1288

1289
    Although we won't bomb out directly, behavior is pretty much undefined if
1290
    you pass in ``None`` or ``""`` for either ``module`` or ``function``.
1291

1292
    The only validation we enforce is that whatever we get back must be
1293
    callable.
1294

1295
    I derived this code based on the internals of the Python unittest
1296
    implementation.  I don't claim to completely understand how it works.
1297

1298
    Args:
1299
       module (Something like "os.path" or "CedarBackup3.util"): Name of module associated with function
1300
       function (Something like "isdir" or "getUidGid"): Name of function
1301
    Returns:
1302
        Reference to function associated with name
1303

1304
    Raises:
1305
       ImportError: If the function cannot be found
1306
       ValueError: If the resulting reference is not callable
1307

1308
    @copyright: Some of this code, prior to customization, was originally part
1309
    of the Python 2.3 codebase.  Python code is copyright (c) 2001, 2002 Python
1310
    Software Foundation; All Rights Reserved.
1311
    """
1312
    parts = []
1✔
1313
    if module is not None and module != "":
1✔
1314
        parts = module.split(".")
1✔
1315
    if function is not None and function != "":
1✔
1316
        parts.append(function)
1✔
1317
    copy = parts[:]
1✔
1318
    while copy:
1✔
1319
        try:
1✔
1320
            module = __import__(".".join(copy))
1✔
1321
            break
1✔
1322
        except ImportError:
1✔
1323
            del copy[-1]
1✔
1324
            if not copy:
1✔
1325
                raise
×
1326
        parts = parts[1:]
1✔
1327
    obj = module
1✔
1328
    for part in parts:
1✔
1329
        obj = getattr(obj, part)
1✔
1330
    if not callable(obj):
1✔
1331
        raise ValueError("Reference to %s.%s is not callable." % (module, function))
1✔
1332
    return obj
1✔
1333

1334

1335
#######################
1336
# getUidGid() function
1337
#######################
1338

1339

1340
def getUidGid(user, group):
1✔
1341
    """
1342
    Get the uid/gid associated with a user/group pair
1343

1344
    This is a no-op if user/group functionality is not available on the platform.
1345

1346
    Args:
1347
       user (User name as a string): User name
1348
       group (Group name as a string): Group name
1349
    Returns:
1350
        Tuple ``(uid, gid)`` matching passed-in user and group
1351
    Raises:
1352
       ValueError: If the ownership user/group values are invalid
1353
    """
1354
    if _UID_GID_AVAILABLE:
×
1355
        try:
×
1356
            uid = pwd.getpwnam(user)[2]
×
1357
            gid = grp.getgrnam(group)[2]
×
1358
            return (uid, gid)
×
1359
        except Exception as e:
×
1360
            logger.debug("Error looking up uid and gid for [%s:%s]: %s", user, group, e)
×
1361
            raise ValueError("Unable to lookup up uid and gid for passed in user/group.")
×
1362
    else:
1363
        return (0, 0)
×
1364

1365

1366
#############################
1367
# changeOwnership() function
1368
#############################
1369

1370

1371
def changeOwnership(path, user, group):
1✔
1372
    """
1373
    Changes ownership of path to match the user and group.
1374

1375
    This is a no-op if user/group functionality is not available on the
1376
    platform, or if the either passed-in user or group is ``None``.  Further, we
1377
    won't even try to do it unless running as root, since it's unlikely to work.
1378

1379
    Args:
1380
       path: Path whose ownership to change
1381
       user: User which owns file
1382
       group: Group which owns file
1383
    """
1384
    if _UID_GID_AVAILABLE:
1✔
1385
        if sys.platform == "win32":
1✔
1386
            logger.debug("Chown not supported on Windows platform")
×
1387
        elif user is None or group is None:
1✔
1388
            logger.debug("User or group is None, so not attempting to change owner on [%s].", path)
1✔
1389
        elif not isRunningAsRoot():
×
1390
            logger.debug("Not root, so not attempting to change owner on [%s].", path)
×
1391
        else:
1392
            try:
×
1393
                (uid, gid) = getUidGid(user, group)
×
1394
                os.chown(path, uid, gid)
×
1395
            except Exception as e:
×
1396
                logger.error("Error changing ownership of [%s]: %s", path, e)
×
1397

1398

1399
#############################
1400
# isRunningAsRoot() function
1401
#############################
1402

1403

1404
def isRunningAsRoot():
1✔
1405
    """
1406
    Indicates whether the program is running as the root user.
1407
    """
1408
    if sys.platform == "win32":
1✔
1409
        return False
×
1410
    return os.getuid() == 0
1✔
1411

1412

1413
##############################
1414
# splitCommandLine() function
1415
##############################
1416

1417

1418
def splitCommandLine(commandLine):
1✔
1419
    """
1420
    Splits a command line string into a list of arguments.
1421

1422
    Unfortunately, there is no "standard" way to parse a command line string,
1423
    and it's actually not an easy problem to solve portably (essentially, we
1424
    have to emulate the shell argument-processing logic).  This code only
1425
    respects double quotes (``"``) for grouping arguments, not single quotes
1426
    (``'``).  Make sure you take this into account when building your command
1427
    line.
1428

1429
    Incidentally, I found this particular parsing method while digging around in
1430
    Google Groups, and I tweaked it for my own use.
1431

1432
    Args:
1433
       commandLine (String, i.e. "cback3 --verbose stage store"): Command line string
1434
    Returns:
1435
        List of arguments, suitable for passing to ``popen2``
1436

1437
    Raises:
1438
       ValueError: If the command line is None
1439
    """
1440
    if commandLine is None:
1✔
1441
        raise ValueError("Cannot split command line of None.")
1✔
1442
    fields = re.findall(r'[^ "]+|"[^"]+"', commandLine)
1✔
1443
    return [field.replace('"', "") for field in fields]
1✔
1444

1445

1446
############################
1447
# resolveCommand() function
1448
############################
1449

1450

1451
def resolveCommand(command):
1✔
1452
    """
1453
    Resolves the real path to a command through the path resolver mechanism.
1454

1455
    Both extensions and standard Cedar Backup functionality need a way to
1456
    resolve the "real" location of various executables.  Normally, they assume
1457
    that these executables are on the system path, but some callers need to
1458
    specify an alternate location.
1459

1460
    Ideally, we want to handle this configuration in a central location.  The
1461
    Cedar Backup path resolver mechanism (a singleton called
1462
    :any:`PathResolverSingleton`) provides the central location to store the
1463
    mappings.  This function wraps access to the singleton, and is what all
1464
    functions (extensions or standard functionality) should call if they need to
1465
    find a command.
1466

1467
    The passed-in command must actually be a list, in the standard form used by
1468
    all existing Cedar Backup code (something like ``["svnlook", ]``).  The
1469
    lookup will actually be done on the first element in the list, and the
1470
    returned command will always be in list form as well.
1471

1472
    If the passed-in command can't be resolved or no mapping exists, then the
1473
    command itself will be returned unchanged.  This way, we neatly fall back on
1474
    default behavior if we have no sensible alternative.
1475

1476
    Args:
1477
       command (List form of command, i.e. ``["svnlook", ]``): Command to resolve
1478
    Returns:
1479
        Path to command or just command itself if no mapping exists
1480
    """
1481
    singleton = PathResolverSingleton.getInstance()
1✔
1482
    name = command[0]
1✔
1483
    result = command[:]
1✔
1484
    result[0] = singleton.lookup(name, name)
1✔
1485
    return result
1✔
1486

1487

1488
############################
1489
# executeCommand() function
1490
############################
1491

1492

1493
def executeCommand(command, args, returnOutput=False, ignoreStderr=False, doNotLog=False, outputFile=None):
1✔
1494
    """
1495
    Executes a shell command, hopefully in a safe way.
1496

1497
    This function exists to replace direct calls to ``os.popen`` in the Cedar
1498
    Backup code.  It's not safe to call a function such as ``os.popen()`` with
1499
    untrusted arguments, since that can cause problems if the string contains
1500
    non-safe variables or other constructs (imagine that the argument is
1501
    ``$WHATEVER``, but ``$WHATEVER`` contains something like C{"; rm -fR ~/;
1502
    echo"} in the current environment).
1503

1504
    Instead, it's safer to pass a list of arguments in the style supported bt
1505
    ``popen2`` or ``popen4``.  This function actually uses a specialized ``Pipe``
1506
    class implemented using either ``subprocess.Popen`` or ``popen2.Popen4``.
1507

1508
    Under the normal case, this function will return a tuple of C{(status,
1509
    None)} where the status is the wait-encoded return status of the call per
1510
    the ``popen2.Popen4`` documentation.  If ``returnOutput`` is passed in as
1511
    ``True``, the function will return a tuple of ``(status, output)`` where
1512
    ``output`` is a list of strings, one entry per line in the output from the
1513
    command.  Output is always logged to the ``outputLogger.info()`` target,
1514
    regardless of whether it's returned.
1515

1516
    By default, ``stdout`` and ``stderr`` will be intermingled in the output.
1517
    However, if you pass in ``ignoreStderr=True``, then only ``stdout`` will be
1518
    included in the output.
1519

1520
    The ``doNotLog`` parameter exists so that callers can force the function to
1521
    not log command output to the debug log.  Normally, you would want to log.
1522
    However, if you're using this function to write huge output files (i.e.
1523
    database backups written to ``stdout``) then you might want to avoid putting
1524
    all that information into the debug log.
1525

1526
    The ``outputFile`` parameter exists to make it easier for a caller to push
1527
    output into a file, i.e. as a substitute for redirection to a file.  If this
1528
    value is passed in, each time a line of output is generated, it will be
1529
    written to the file using ``outputFile.write()``.  At the end, the file
1530
    descriptor will be flushed using ``outputFile.flush()``.  The caller
1531
    maintains responsibility for closing the file object appropriately.
1532

1533
    *Note:* I know that it's a bit confusing that the command and the arguments
1534
    are both lists.  I could have just required the caller to pass in one big
1535
    list.  However, I think it makes some sense to keep the command (the
1536
    constant part of what we're executing, i.e. ``"scp -B"``) separate from its
1537
    arguments, even if they both end up looking kind of similar.
1538

1539
    *Note:* You cannot redirect output via shell constructs (i.e. ``>file``,
1540
    ``2>/dev/null``, etc.) using this function.  The redirection string would be
1541
    passed to the command just like any other argument.  However, you can
1542
    implement the equivalent to redirection using ``ignoreStderr`` and
1543
    ``outputFile``, as discussed above.
1544

1545
    *Note:* The operating system environment is partially sanitized before
1546
    the command is invoked.  See :any:`sanitizeEnvironment` for details.
1547

1548
    Args:
1549
       command (List of individual arguments that make up the command): Shell command to execute
1550
       args (List of additional arguments to the command): List of arguments to the command
1551
       returnOutput (Boolean ``True`` or ``False``): Indicates whether to return the output of the command
1552
       ignoreStderr (Boolean True or False): Whether stderr should be discarded
1553
       doNotLog (Boolean ``True`` or ``False``): Indicates that output should not be logged
1554
       outputFile (File as from ``open`` or ``file``, binary write): File that all output should be written to
1555
    Returns:
1556
        Tuple of ``(result, output)`` as described above
1557
    """
1558

1559
    # I refactored this in Oct 2020 when modernizing the packaging. Recent versions of
1560
    # Python gave a "ResourceWarning: unclosed file <_io.BufferedReader name=72>".  From
1561
    # StackOverflow (https://stackoverflow.com/a/58696973/2907667), I decided that the solution
1562
    # was to use the Pipe as a context manager, which helps ensure that all of its associated
1563
    # resources are cleaned up properly.  However, the error handling below is somewhat
1564
    # complicated, for reasons I am sure were important in 2004 but are not clear to me now.
1565
    # The error conditions are also hard to test.  So, there's a chance that my refactoring is
1566
    # not strictly equivalent to the original code.  If needed, the original code can be
1567
    # found at this commit:
1568
    #   https://github.com/pronovic/cedar-backup3/blob/370dbc9ea2b7a5ad9533605ead32d1e56746efd4/CedarBackup3/util.py#L1449
1569

1570
    logger.debug("Executing command %s with args %s.", command, args)
1✔
1571
    outputLogger.info("Executing command %s with args %s.", command, args)
1✔
1572
    if doNotLog:
1✔
1573
        logger.debug("Note: output will not be logged, per the doNotLog flag.")
×
1574
        outputLogger.info("Note: output will not be logged, per the doNotLog flag.")
×
1575
    output = []
1✔
1576
    fields = command[:]  # make sure to copy it so we don't destroy it
1✔
1577
    fields.extend(args)
1✔
1578
    try:
1✔
1579
        sanitizeEnvironment()  # make sure we have a consistent environment
1✔
1580
        with Pipe(fields, ignoreStderr=ignoreStderr) as pipe:
1✔
1581
            try:
1✔
1582
                while True:
1✔
1583
                    line = pipe.stdout.readline()
1✔
1584
                    if not line:
1✔
1585
                        break
1✔
1586
                    if returnOutput:
1✔
1587
                        output.append(line.decode("utf-8"))
1✔
1588
                    if outputFile is not None:
1✔
1589
                        outputFile.write(line)
1✔
1590
                    if not doNotLog:
1✔
1591
                        outputLogger.info(line.decode("utf-8")[:-1])  # this way the log will (hopefully) get updated in realtime
1✔
1592
                if outputFile is not None:
1✔
1593
                    try:  # note, not every file-like object can be flushed
1✔
1594
                        outputFile.flush()
1✔
1595
                    except:
×
1596
                        pass
×
1597
                if returnOutput:
1✔
1598
                    return (pipe.wait(), output)
1✔
1599
                else:
1600
                    return (pipe.wait(), None)
1✔
1601
            except OSError as e:
×
1602
                logger.debug("Command returned OSError: %s", e)
×
1603
                if returnOutput:
×
1604
                    if output:
×
1605
                        return (pipe.wait(), output)
×
1606
                    else:
1607
                        return (pipe.wait(), [e])
×
1608
                else:
1609
                    return (pipe.wait(), None)
×
1610
    except OSError as e:
×
1611
        logger.debug("Command returned OSError: %s", e)
×
1612
        if returnOutput:
×
1613
            return (256, output)
×
1614
        else:
1615
            return (256, None)
×
1616

1617

1618
##############################
1619
# calculateFileAge() function
1620
##############################
1621

1622

1623
def calculateFileAge(path):
1✔
1624
    """
1625
    Calculates the age (in days) of a file.
1626

1627
    The "age" of a file is the amount of time since the file was last used, per
1628
    the most recent of the file's ``st_atime`` and ``st_mtime`` values.
1629

1630
    Technically, we only intend this function to work with files, but it will
1631
    probably work with anything on the filesystem.
1632

1633
    Args:
1634
       path: Path to a file on disk
1635

1636
    Returns:
1637
        Age of the file in days (possibly fractional)
1638
    Raises:
1639
       OSError: If the file doesn't exist
1640
    """
1641
    currentTime = int(time.time())
1✔
1642
    fileStats = os.stat(path)
1✔
1643
    lastUse = max(fileStats.st_atime, fileStats.st_mtime)  # "most recent" is "largest"
1✔
1644
    ageInSeconds = currentTime - lastUse
1✔
1645
    return ageInSeconds / SECONDS_PER_DAY
1✔
1646

1647

1648
###################
1649
# mount() function
1650
###################
1651

1652

1653
def mount(devicePath, mountPoint, fsType):
1✔
1654
    """
1655
    Mounts the indicated device at the indicated mount point.
1656

1657
    For instance, to mount a CD, you might use device path ``/dev/cdrw``, mount
1658
    point ``/media/cdrw`` and filesystem type ``iso9660``.  You can safely use any
1659
    filesystem type that is supported by ``mount`` on your platform.  If the type
1660
    is ``None``, we'll attempt to let ``mount`` auto-detect it.  This may or may
1661
    not work on all systems.
1662

1663
    *Note:* This only works on platforms that have a concept of "mounting" a
1664
    filesystem through a command-line ``"mount"`` command, like UNIXes.  It
1665
    won't work on Windows.
1666

1667
    Args:
1668
       devicePath: Path of device to be mounted
1669
       mountPoint: Path that device should be mounted at
1670
       fsType: Type of the filesystem assumed to be available via the device
1671

1672
    Raises:
1673
       IOError: If the device cannot be mounted
1674
    """
1675
    if fsType is None:
×
1676
        args = [devicePath, mountPoint]
×
1677
    else:
1678
        args = ["-t", fsType, devicePath, mountPoint]
×
1679
    command = resolveCommand(MOUNT_COMMAND)
×
1680
    result = executeCommand(command, args, returnOutput=False, ignoreStderr=True)[0]
×
1681
    if result != 0:
×
1682
        raise OSError("Error [%d] mounting [%s] at [%s] as [%s]." % (result, devicePath, mountPoint, fsType))
×
1683

1684

1685
#####################
1686
# unmount() function
1687
#####################
1688

1689

1690
def unmount(mountPoint, removeAfter=False, attempts=1, waitSeconds=0):
1✔
1691
    """
1692
    Unmounts whatever device is mounted at the indicated mount point.
1693

1694
    Sometimes, it might not be possible to unmount the mount point immediately,
1695
    if there are still files open there.  Use the ``attempts`` and ``waitSeconds``
1696
    arguments to indicate how many unmount attempts to make and how many seconds
1697
    to wait between attempts.  If you pass in zero attempts, no attempts will be
1698
    made (duh).
1699

1700
    If the indicated mount point is not really a mount point per
1701
    ``os.path.ismount()``, then it will be ignored.  This seems to be a safer
1702
    check then looking through ``/etc/mtab``, since ``ismount()`` is already in
1703
    the Python standard library and is documented as working on all POSIX
1704
    systems.
1705

1706
    If ``removeAfter`` is ``True``, then the mount point will be removed using
1707
    ``os.rmdir()`` after the unmount action succeeds.  If for some reason the
1708
    mount point is not a directory, then it will not be removed.
1709

1710
    *Note:* This only works on platforms that have a concept of "mounting" a
1711
    filesystem through a command-line ``"mount"`` command, like UNIXes.  It
1712
    won't work on Windows.
1713

1714
    Args:
1715
       mountPoint: Mount point to be unmounted
1716
       removeAfter: Remove the mount point after unmounting it
1717
       attempts: Number of times to attempt the unmount
1718
       waitSeconds: Number of seconds to wait between repeated attempts
1719

1720
    Raises:
1721
       IOError: If the mount point is still mounted after attempts are exhausted
1722
    """
1723
    if os.path.ismount(mountPoint):
×
1724
        for attempt in range(attempts):
×
1725
            logger.debug("Making attempt %d to unmount [%s].", attempt, mountPoint)
×
1726
            command = resolveCommand(UMOUNT_COMMAND)
×
1727
            result = executeCommand(command, [mountPoint], returnOutput=False, ignoreStderr=True)[0]
×
1728
            if result != 0:
×
1729
                logger.error("Error [%d] unmounting [%s] on attempt %d.", result, mountPoint, attempt)
×
1730
            elif os.path.ismount(mountPoint):
×
1731
                logger.error("After attempt %d, [%s] is still mounted.", attempt, mountPoint)
×
1732
            else:
1733
                logger.debug("Successfully unmounted [%s] on attempt %d.", mountPoint, attempt)
×
1734
                break  # this will cause us to skip the loop else: clause
×
1735
            if attempt + 1 < attempts:  # i.e. this isn't the last attempt
×
1736
                if waitSeconds > 0:
×
1737
                    logger.info("Sleeping %d second(s) before next unmount attempt.", waitSeconds)
×
1738
                    time.sleep(waitSeconds)
×
1739
        else:
1740
            if os.path.ismount(mountPoint):
×
1741
                raise OSError("Unable to unmount [%s] after %d attempts." % (mountPoint, attempts))
×
1742
            logger.info("Mount point [%s] seems to have finally gone away.", mountPoint)
×
1743
        if os.path.isdir(mountPoint) and removeAfter:
×
1744
            logger.debug("Removing mount point [%s].", mountPoint)
×
1745
            os.rmdir(mountPoint)
×
1746

1747

1748
###########################
1749
# deviceMounted() function
1750
###########################
1751

1752

1753
def deviceMounted(devicePath):
1✔
1754
    """
1755
    Indicates whether a specific filesystem device is currently mounted.
1756

1757
    We determine whether the device is mounted by looking through the system's
1758
    ``mtab`` file.  This file shows every currently-mounted filesystem, ordered
1759
    by device.  We only do the check if the ``mtab`` file exists and is readable.
1760
    Otherwise, we assume that the device is not mounted.
1761

1762
    *Note:* This only works on platforms that have a concept of an mtab file
1763
    to show mounted volumes, like UNIXes.  It won't work on Windows.
1764

1765
    Args:
1766
       devicePath: Path of device to be checked
1767

1768
    Returns:
1769
        True if device is mounted, false otherwise
1770
    """
1771
    if os.path.exists(MTAB_FILE) and os.access(MTAB_FILE, os.R_OK):
×
1772
        realPath = os.path.realpath(devicePath)
×
1773
        with open(MTAB_FILE) as f:
×
1774
            lines = f.readlines()
×
1775
        for line in lines:
×
1776
            (mountDevice, mountPoint, _) = line.split(None, 2)
×
1777
            if mountDevice in [devicePath, realPath]:
×
1778
                logger.debug("Device [%s] is mounted at [%s].", devicePath, mountPoint)
×
1779
                return True
×
1780
    return False
×
1781

1782

1783
########################
1784
# encodePath() function
1785
########################
1786

1787

1788
def encodePath(path):
1✔
1789
    """
1790
    Safely encodes a filesystem path as a Unicode string, converting bytes to fileystem encoding if necessary.
1791
    Args:
1792
       path: Path to encode
1793
    Returns:
1794
        Path, as a string, encoded appropriately
1795
    Raises:
1796
       ValueError: If the path cannot be encoded properly
1797
    @see: http://lucumr.pocoo.org/2013/7/2/the-updated-guide-to-unicode/
1798
    """
1799
    if path is None:
1✔
1800
        return path
1✔
1801
    try:
1✔
1802
        if isinstance(path, bytes):
1✔
1803
            encoding = sys.getfilesystemencoding() or sys.getdefaultencoding()
1✔
1804
            path = path.decode(encoding, "surrogateescape")  # to match what os.listdir() does
1✔
1805
        return path
1✔
1806
    except UnicodeError as e:
×
1807
        raise ValueError("Path could not be safely encoded as %s: %s" % (encoding, str(e)))
×
1808

1809

1810
######################
1811
# pathJoin() function
1812
######################
1813

1814

1815
def pathJoin(path, *paths):
1✔
1816
    """
1817
    Wraps os.path.join(), normalizing slashes in the result.
1818

1819
    On Windows in particular, we often end up with mixed slashes, where parts of a path
1820
    have forward slash and parts have backward slash.  This makes it difficult to construct
1821
    exclusions in configuration, because you never know what part of a path will have
1822
    what kind of slash.  I've decided to standardize on forward slashes.
1823

1824
    Returns:
1825
        Result as from os.path.join() but always with forward slashes.
1826
    """
1827
    result = os.path.join(path, *paths)
1✔
1828
    return result.replace("\\", "/")
1✔
1829

1830

1831
########################
1832
# nullDevice() function
1833
########################
1834

1835

1836
def nullDevice():
1✔
1837
    """
1838
    Attempts to portably return the null device on this system.
1839

1840
    The null device is something like ``/dev/null`` on a UNIX system.  The name
1841
    varies on other platforms.
1842
    """
1843
    return os.devnull
1✔
1844

1845

1846
##############################
1847
# deriveDayOfWeek() function
1848
##############################
1849

1850

1851
def deriveDayOfWeek(dayName):
1✔
1852
    """
1853
    Converts English day name to numeric day of week as from ``time.localtime``.
1854

1855
    For instance, the day ``monday`` would be converted to the number ``0``.
1856

1857
    Args:
1858
       dayName (string, i.e. ``"monday"``, ``"tuesday"``, etc): Day of week to convert
1859
    Returns:
1860
        Integer, where Monday is 0 and Sunday is 6; or -1 if no conversion is possible
1861
    """
1862
    if dayName.lower() == "monday":
1✔
1863
        return 0
1✔
1864
    elif dayName.lower() == "tuesday":
1✔
1865
        return 1
1✔
1866
    elif dayName.lower() == "wednesday":
1✔
1867
        return 2
1✔
1868
    elif dayName.lower() == "thursday":
1✔
1869
        return 3
1✔
1870
    elif dayName.lower() == "friday":
1✔
1871
        return 4
1✔
1872
    elif dayName.lower() == "saturday":
1✔
1873
        return 5
1✔
1874
    elif dayName.lower() == "sunday":
1✔
1875
        return 6
1✔
1876
    else:
1877
        return -1  # What else can we do??  Thrown an exception, I guess.
1✔
1878

1879

1880
###########################
1881
# isStartOfWeek() function
1882
###########################
1883

1884

1885
def isStartOfWeek(startingDay):
1✔
1886
    """
1887
    Indicates whether "today" is the backup starting day per configuration.
1888

1889
    If the current day's English name matches the indicated starting day, then
1890
    today is a starting day.
1891

1892
    Args:
1893
       startingDay (string, i.e. ``"monday"``, ``"tuesday"``, etc): Configured starting day
1894
    Returns:
1895
        Boolean indicating whether today is the starting day
1896
    """
1897
    value = time.localtime().tm_wday == deriveDayOfWeek(startingDay)
1✔
1898
    if value:
1✔
1899
        logger.debug("Today is the start of the week.")
1✔
1900
    else:
1901
        logger.debug("Today is NOT the start of the week.")
1✔
1902
    return value
1✔
1903

1904

1905
#################################
1906
# buildNormalizedPath() function
1907
#################################
1908

1909

1910
def buildNormalizedPath(path):
1✔
1911
    """
1912
    Returns a "normalized" path based on a path name.
1913

1914
    A normalized path is a representation of a path that is also a valid file
1915
    name.  To make a valid file name out of a complete path, we have to convert
1916
    or remove some characters that are significant to the filesystem -- in
1917
    particular, the path separator, the Windows drive separator, and any
1918
    leading ``'.'`` character (which would cause the file to be hidden in
1919
    a file listing).
1920

1921
    Note that this is a one-way transformation -- you can't safely derive the
1922
    original path from the normalized path.
1923

1924
    To normalize a path, we begin by looking at the first character.  If the
1925
    first character is ``'/'`` or ``'\\'``, it gets removed.  If the first
1926
    character is ``'.'``, it gets converted to ``'_'``.  Then, we look through the
1927
    rest of the path and convert all remaining ``'/'`` or ``'\\'`` characters
1928
    ``'-'``, and all remaining whitespace or ``':'`` characters to ``'_'``.
1929

1930
    As a special case, a path consisting only of a single ``'/'`` or ``'\\'``
1931
    character will be converted to ``'_'``.  That's better than ``'-'``, because
1932
    a dash tends to get interpreted by shell commands as a switch.
1933

1934
    As a special case, anything that looks like a leading Windows drive letter
1935
    combination (like ``c:\\`` or ``c:/``) will be converted to something like
1936
    ``c-``.  This is needed because a colon isn't a valid filename character
1937
    on Windows.
1938

1939
    Args:
1940
       path: Path to normalize
1941

1942
    Returns:
1943
        Normalized path as described above
1944

1945
    Raises:
1946
       ValueError: If the path is None
1947
    """
1948
    if path is None:
1✔
1949
        raise ValueError("Cannot normalize path None.")
1✔
1950
    elif len(path) == 0:
1✔
1951
        return path
1✔
1952
    elif path in {"/", "\\"}:
1✔
1953
        return "_"
1✔
1954
    else:
1955
        normalized = path
1✔
1956
        normalized = re.sub(r"(^[A-Za-z])(:)([\\/])", r"\1-", normalized)  # normalize Windows drive letter
1✔
1957
        normalized = re.sub(r"^/", "", normalized)  # remove leading '/'
1✔
1958
        normalized = re.sub(r"^\\", "", normalized)  # remove leading '\'
1✔
1959
        normalized = re.sub(r"^\.", "_", normalized)  # convert leading '.' to '_' so file won't be hidden
1✔
1960
        normalized = re.sub(r"/", "-", normalized)  # convert all '/' characters to '-'
1✔
1961
        normalized = re.sub(r"\\", "-", normalized)  # convert all '\' characters to '-'
1✔
1962
        normalized = re.sub(r"\s", "_", normalized)  # convert all whitespace to '_'
1✔
1963
        return re.sub(r":", "_", normalized)  # convert all remaining colons to '_'
1✔
1964

1965

1966
#################################
1967
# sanitizeEnvironment() function
1968
#################################
1969

1970

1971
def sanitizeEnvironment():
1✔
1972
    """
1973
    Sanitizes the operating system environment.
1974

1975
    The operating system environment is contained in ``os.environ``.  This method
1976
    sanitizes the contents of that dictionary.
1977

1978
    Currently, all it does is reset the locale (removing ``$LC_*``) and set the
1979
    default language (``$LANG``) to ``DEFAULT_LANGUAGE``.  This way, we can count
1980
    on consistent localization regardless of what the end-user has configured.
1981
    This is important for code that needs to parse program output.
1982

1983
    The ``os.environ`` dictionary is modifed in-place.  If ``$LANG`` is already
1984
    set to the proper value, it is not re-set, so we can avoid the memory leaks
1985
    that are documented to occur on BSD-based systems.
1986

1987
    Returns:
1988
        Copy of the sanitized environment
1989
    """
1990
    for var in LOCALE_VARS:
1✔
1991
        if var in os.environ:
1✔
1992
            del os.environ[var]
×
1993
    if LANG_VAR in os.environ:
1✔
1994
        if os.environ[LANG_VAR] != DEFAULT_LANGUAGE:  # no need to reset if it exists (avoid leaks on BSD systems)
1✔
1995
            os.environ[LANG_VAR] = DEFAULT_LANGUAGE
1✔
1996
    return os.environ.copy()
1✔
1997

1998

1999
#############################
2000
# dereferenceLink() function
2001
#############################
2002

2003

2004
def dereferenceLink(path, absolute=True):
1✔
2005
    """
2006
    Deference a soft link, optionally normalizing it to an absolute path.
2007
    Args:
2008
       path: Path of link to dereference
2009
       absolute: Whether to normalize the result to an absolute path
2010
    Returns:
2011
        Dereferenced path, or original path if original is not a link
2012
    """
2013
    if os.path.islink(path):
1✔
2014
        result = os.readlink(path)
1✔
2015
        # Python 3.13+ does not treat / as absolute on Windows
2016
        if absolute and not (os.path.isabs(result) or posixpath.isabs(result)):
1✔
2017
            result = os.path.abspath(os.path.join(os.path.dirname(path), result))
1✔
2018
        return result
1✔
2019
    return path
1✔
2020

2021

2022
#########################
2023
# checkUnique() function
2024
#########################
2025

2026

2027
def checkUnique(prefix, values):
1✔
2028
    """
2029
    Checks that all values are unique.
2030

2031
    The values list is checked for duplicate values.  If there are
2032
    duplicates, an exception is thrown.  All duplicate values are listed in
2033
    the exception.
2034

2035
    Args:
2036
       prefix: Prefix to use in the thrown exception
2037
       values: List of values to check
2038

2039
    Raises:
2040
       ValueError: If there are duplicates in the list
2041
    """
2042
    values.sort()
1✔
2043
    duplicates = []
1✔
2044
    for i in range(1, len(values)):
1✔
2045
        if values[i - 1] == values[i]:
1✔
2046
            duplicates.append(values[i])
1✔
2047
    if duplicates:
1✔
2048
        raise ValueError("%s %s" % (prefix, duplicates))
1✔
2049

2050

2051
#######################################
2052
# parseCommaSeparatedString() function
2053
#######################################
2054

2055

2056
def parseCommaSeparatedString(commaString):
1✔
2057
    """
2058
    Parses a list of values out of a comma-separated string.
2059

2060
    The items in the list are split by comma, and then have whitespace
2061
    stripped.  As a special case, if ``commaString`` is ``None``, then ``None``
2062
    will be returned.
2063

2064
    Args:
2065
       commaString: List of values in comma-separated string format
2066
    Returns:
2067
        Values from commaString split into a list, or ``None``
2068
    """
2069
    if commaString is None:
1✔
2070
        return None
1✔
2071
    else:
2072
        pass1 = commaString.split(",")
1✔
2073
        pass2 = []
1✔
2074
        for item in pass1:
1✔
2075
            stripped = item.strip()
1✔
2076
            if len(stripped) > 0:
1✔
2077
                pass2.append(stripped)
1✔
2078
        return pass2
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc