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

4dn-dcic / utils / 10312717954

09 Aug 2024 02:36AM UTC coverage: 75.606% (-0.06%) from 75.67%
10312717954

Pull #313

github

web-flow
Merge 887b0cc1b into 6b05b6655
Pull Request #313: Minor updates to the view-portal-object dev/troubleshooting utility s…

74 of 96 new or added lines in 7 files covered. (77.08%)

5 existing lines in 1 file now uncovered.

11415 of 15098 relevant lines covered (75.61%)

0.76 hits per line

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

90.96
/dcicutils/misc_utils.py
1
"""
2
This file contains functions that might be generally useful.
3
"""
4

5
from collections import namedtuple
1✔
6
from typing import Iterable  # would prefer this but error from Python 3.8: from collections.abc import Iterable
1✔
7
import appdirs
1✔
8
from copy import deepcopy
1✔
9
import concurrent.futures
1✔
10
import contextlib
1✔
11
import datetime
1✔
12
import functools
1✔
13
import hashlib
1✔
14
import inspect
1✔
15
import io
1✔
16
import json
1✔
17
import logging
1✔
18
import math
1✔
19
import os
1✔
20
import platform
1✔
21
import pytz
1✔
22
import re
1✔
23
import rfc3986.validators
1✔
24
import rfc3986.exceptions
1✔
25
import shortuuid
1✔
26
import time
1✔
27
import uuid
1✔
28
import warnings
1✔
29
import webtest  # importing the library makes it easier to mock testing
1✔
30

31
from collections import defaultdict
1✔
32
from datetime import datetime as datetime_type
1✔
33
from dateutil.parser import parse as dateutil_parse
1✔
34
from typing import Any, Callable, List, Optional, Tuple, Union
1✔
35

36

37
# Is this the right place for this? I feel like this should be done in an application, not a library.
38
# -kmp 27-Apr-2020
39
logging.basicConfig()
1✔
40

41

42
class NamedObject(object):
1✔
43

44
    def __init__(self, name):
1✔
45
        self.name = name
1✔
46

47
    def __str__(self):
1✔
48
        return f"<{self.name}>"
1✔
49

50
    def __repr__(self):
1✔
51
        return f"<{self.name}@{id(self):x}>"
1✔
52

53

54
# Using PRINT(...) for debugging, rather than its more familiar lowercase form) for intended programmatic output,
55
# makes it easier to find stray print statements that were left behind in debugging. -kmp 30-Mar-2020
56

57
class _MOCKABLE_IO:
1✔
58

59
    def __init__(self, wrapped_action):
1✔
60
        self.wrapped_action = wrapped_action  # necessary indirection for sake of qa_utils.printed_output
1✔
61

62
    def __call__(self, *args, timestamped=False, **kwargs):
1✔
63
        """
64
        Prints its args space-separated, as 'print' would, possibly with an hh:mm:ss timestamp prepended.
65

66
        :param args: an object to be printed
67
        :param with_time: a boolean specifying whether to prepend a timestamp
68
        """
69
        if timestamped:
1✔
70
            hh_mm_ss = str(datetime.datetime.now().strftime("%H:%M:%S"))
1✔
71
            return self.wrapped_action(' '.join(map(str, (hh_mm_ss, *args))), **kwargs)
1✔
72
        else:
73
            return self.wrapped_action(' '.join(map(str, args)), **kwargs)
1✔
74

75

76
def _mockable_input(*args):
1✔
77
    return input(*args)
1✔
78

79

80
builtin_print = print
1✔
81

82
PRINT = _MOCKABLE_IO(wrapped_action=print)
1✔
83
PRINT.__name__ = 'PRINT'
1✔
84

85
INPUT = _MOCKABLE_IO(wrapped_action=_mockable_input)
1✔
86
INPUT.__name__ = 'INPUT'
1✔
87

88
prompt_for_input = INPUT  # In Python 3, input does 'safe' input reading. INPUT is our mockable alternative
1✔
89

90

91
@contextlib.contextmanager
1✔
92
def lines_printed_to(file):
1✔
93
    """
94
    This context manager opens a file and returns a function that can be called repeatedly during the body context
95
    to do do line-by-line output to that file. It uses PRINT to do that output, so the unit test tools for PRINT
96
    can be used to test this.
97
    """
98
    with io.open(file, 'w') as fp:
1✔
99
        def write_line(s=""):
1✔
100
            PRINT(s, file=fp)
1✔
101
        yield write_line
1✔
102

103

104
def print_error_message(exception, full=False):
1✔
105
    """
106
    Prints an error message (using dcicutils.misc_utils.PRINT) in the conventional way, as:
107
      <error-type>: <error-message>
108
    With full=True, the error-type can be made to use dcicutils.misc_utils.full_class_name to get a module name, so:
109
      <module-qualified-error-type>: <error-message>
110
    """
111
    PRINT(get_error_message(exception, full=full))
1✔
112

113

114
def get_error_message(exception, full=False):
1✔
115
    """
116
    Returns an error message (using dcicutils.misc_utils.PRINT) formatted in the conventional way, as:
117
      "<error-type>: <error-message>"
118
    With full=True, the error-type can be made to use dcicutils.misc_utils.full_class_name to get a module name, so:
119
      "<module-qualified-error-type>: <error-message>"
120
    """
121
    exception_type_name = full_class_name(exception) if full else exception.__class__.__name__
1✔
122
    error_message = f"{exception_type_name}: {exception}"
1✔
123
    return error_message
1✔
124

125

126
absolute_uri_validator = (
1✔
127
    rfc3986.validators.Validator()
128
    # Validation qualifiers
129
    .allow_schemes('http', 'https')
130
    # TODO: We might want to consider the possibility of forbidding the use of a password. -kmp 20-Apr-2021
131
    # .forbid_use_of_password()
132
    .require_presence_of('scheme', 'host')
133
    .check_validity_of('scheme', 'host', 'path'))
134

135

136
def is_valid_absolute_uri(text):
1✔
137
    """
138
    Returns True if the given text is a string in the proper format to be an 'absolute' URI,
139
    by which we mean the URI has a scheme (http or https) and a host specification.
140

141
    For more info, see "Uniform Resource Identifier (URI): Generic Syntax" at https://tools.ietf.org/html/rfc3986
142
    """
143
    # Technically something like 'foo/bar.html' is also a URI, but it is a relative one, and
144
    # the intended use of this function is to verify the URI specification of a resource on the web,
145
    # independent of browser context, so a relative specification would be meaningless. We can add
146
    # a separate operation for that later if we need one.
147
    #
148
    # We don't use rfc3987 (IRIs) both because it allows some resource locators we're not sure we're
149
    # committed to accepting. Wikipedia, in https://en.wikipedia.org/wiki/Internationalized_Resource_Identifier,
150
    # hints that there is some controversy about whether IRIs are even a good idea. We can revisit the idea if
151
    # someone is demanding it. (And, as a practical matter, the rfc3987 library has a problematic license.)
152
    # -kmp 21-Apr-2021
153
    try:
1✔
154
        uri_ref = rfc3986.uri_reference(text)
1✔
155
    except Exception:
1✔
156
        return False
1✔
157
    try:
1✔
158
        absolute_uri_validator.validate(uri_ref)
1✔
159
        return True
1✔
160
    except rfc3986.exceptions.ValidationError:
1✔
161
        return False
1✔
162

163

164
class VirtualAppError(Exception):
1✔
165
    """ Special Exception to be raised by VirtualApp that contains some additional info """
166

167
    def __init__(self, msg, url, body, raw_exception):
1✔
168
        super(VirtualAppError, self).__init__(msg)
1✔
169
        self.msg = msg
1✔
170
        self.query_url = url
1✔
171
        self.query_body = body
1✔
172
        self.raw_exception = raw_exception
1✔
173

174
    def __repr__(self):
1✔
175
        return ("Exception encountered on VirtualApp\n"
1✔
176
                "URL: %s\n"
177
                "BODY: %s\n"
178
                "MSG: %s\n"
179
                "Raw Exception: %s\n" % (self.query_url, self.query_body, self.msg, self.raw_exception))
180

181
    def __str__(self):
1✔
182
        return self.__repr__()
1✔
183

184

185
# So whether people import this from webtest or here, they will get the same object.
186
TestApp = webtest.TestApp
1✔
187

188
# This ("monkey patch") side-effect will affect webtest.TestApp, too, but that's OK for us.
189
# https://en.wikipedia.org/wiki/Monkey_patch
190
# We want any use of WebTest.TestApp to NOT be seen as a test for the purposes of PyTest.
191

192
TestApp.__test__ = False
1✔
193

194

195
class _VirtualAppHelper(webtest.TestApp):
1✔
196
    """
197
    A helper class equivalent to webtest.TestApp, except that it isn't intended for test use.
198
    """
199

200
    pass
1✔
201

202

203
class AbstractVirtualApp:
1✔
204
    pass
1✔
205

206

207
class VirtualApp(AbstractVirtualApp):
1✔
208
    """
209
    Wrapper class for TestApp, to allow custom control over submitting Encoded requests,
210
    simulating a number of conditions, including permissions.
211

212
    IMPORTANT: We use webtest.TestApp is used as substrate technology here, but use of this class
213
        occurs in the main application, not just in testing. Among other things, we have
214
        renamed the app here in order to avoid confusions created by the name when it is used
215
        in production settings.
216
    """
217
    HELPER_CLASS = _VirtualAppHelper
1✔
218

219
    def __init__(self, app, environ):
1✔
220
        """
221
        Builds an encoded application, allowing you to submit requests to an encoded application
222

223
        :param app: return value of get_app(config_uri, app_name)
224
        :param environ: options to pass to the application. Usually permissions.
225
        """
226
        #  NOTE: The TestApp class that we're wrapping takes a richer set of initialization parameters
227
        #        (including relative_to, use_unicode, cookiejar, parser_features, json_encoder, and lint),
228
        #        but we'll add them conservatively here. If there is a need for any of them, we should add
229
        #        them explicitly here one-by-one as the need is shown so we have tight control of what
230
        #        we're depending on and what we're not. -kmp 27-Apr-2020
231
        self.wrapped_app = self.HELPER_CLASS(app, environ)
1✔
232

233
    def get(self, url, **kwargs):
1✔
234
        """ Wrapper for TestApp.get that logs the outgoing GET
235

236
        :param url: url to GET
237
        :param kwargs: args to pass to the GET
238
        :return: result of GET
239
        """
240
        logging.info('OUTGOING HTTP GET: %s' % url)
1✔
241
        try:
1✔
242
            return self.wrapped_app.get(url, **kwargs)
1✔
243
        except webtest.AppError as e:
1✔
244
            raise VirtualAppError(msg='HTTP GET failed.', url=url, body='<empty>', raw_exception=e)
1✔
245

246
    def post(self, url, obj, **kwargs):
1✔
247
        """ Wrapper for TestApp.post that logs the outgoing POST
248

249
        :param url: url to POST to
250
        :param obj: object body to POST
251
        :param kwargs: args to pass to the POST
252
        :return: result of POST
253
        """
254
        logging.info('OUTGOING HTTP POST on url: %s with object: %s' % (url, obj))
1✔
255
        try:
1✔
256
            return self.wrapped_app.post(url, obj, **kwargs)
1✔
257
        except webtest.AppError as e:
1✔
258
            raise VirtualAppError(msg='HTTP POST failed.', url=url, body=obj, raw_exception=e)
1✔
259

260
    def post_json(self, url, obj, **kwargs):
1✔
261
        """ Wrapper for TestApp.post_json that logs the outgoing POST
262

263
        :param url: url to POST to
264
        :param obj: object body to POST
265
        :param kwargs: args to pass to the POST
266
        :return: result of POST
267
        """
268
        logging.info('OUTGOING HTTP POST on url: %s with object: %s' % (url, obj))
1✔
269
        try:
1✔
270
            return self.wrapped_app.post_json(url, obj, **kwargs)
1✔
271
        except webtest.AppError as e:
1✔
272
            raise VirtualAppError(msg='HTTP POST failed.', url=url, body=obj, raw_exception=e)
1✔
273

274
    def put_json(self, url, obj, **kwargs):
1✔
275
        """ Wrapper for TestApp.put_json that logs the outgoing PUT
276

277
        :param url: url to PUT to
278
        :param obj: object body to PUT
279
        :param kwargs: args to pass to the PUT
280
        :return: result of PUT
281
        """
282
        logging.info('OUTGOING HTTP PUT on url: %s with object: %s' % (url, obj))
1✔
283
        try:
1✔
284
            return self.wrapped_app.put_json(url, obj, **kwargs)
1✔
285
        except webtest.AppError as e:
1✔
286
            raise VirtualAppError(msg='HTTP PUT failed.', url=url, body=obj, raw_exception=e)
1✔
287

288
    def patch_json(self, url, fields, **kwargs):
1✔
289
        """ Wrapper for TestApp.patch_json that logs the outgoing PATCH
290

291
        :param url: url to PATCH to, should contain an object uuid
292
        :param fields: fields to PATCH on uuid in URL
293
        :param kwargs: args to pass to the PATCH
294
        :return: result of PATCH
295
        """
296
        logging.info('OUTGOING HTTP PATCH on url: %s with changes: %s' % (url, fields))
1✔
297
        try:
1✔
298
            return self.wrapped_app.patch_json(url, fields, **kwargs)
1✔
299
        except webtest.AppError as e:
1✔
300
            raise VirtualAppError(msg='HTTP PATCH failed.', url=url, body=fields, raw_exception=e)
1✔
301

302
    @property
1✔
303
    def app(self):
1✔
304
        """ Returns the .app of the wrapped_app.
305

306
            For example, this allows one to refer to myapp.app.registry without having to know
307
            if myapp is a TestApp or a VirtualApp.
308
        """
309
        return self.wrapped_app.app
1✔
310

311

312
VirtualAppResponse = webtest.response.TestResponse  # NoQA - PyCharm sees a problem, but none occurs in practice
1✔
313

314

315
def exported(*variables):
1✔
316
    """
317
    This function does nothing but is used for declaration purposes.
318
    It is useful for the situation where one module imports names from another module merely to allow
319
    functions in another module to import them, usually for legacy compatibility.
320
    Otherwise, the import might look unnecessary.
321
    e.g.,
322

323
    ---file1.py---
324
    def identity(x):
325
        return x
326

327
    ---file2.py---
328
    from .file1 import identity
329
    from dcicutils.misc_utils import exported
330

331
    # This function used to be defined here, but now is defined in file1.py
332
    exported(identity)
333

334
    ---file3.py---
335
    # This file has not been updated to realize that file1.py is the new home of identity.
336
    from .file2 import identity
337
    print("one=", identity(1))  # noQA - code example
338
    """
339
    ignored(variables)
1✔
340

341

342
def ignored(*args, **kwargs):
1✔
343
    """
344
    This is useful for defeating flake warnings.
345
    Call this function to use values that really should be ignored.
346
    This is intended as a declaration that variables are intentionally ignored,
347
    but no enforcement of that is done. Some sample uses:
348

349
    def foo(x, y):
350
        ignored(x, y)  # so flake8 won't complain about x and y being unused.
351
        return 3
352

353
    def map_action(action, data, options, precheck=ignored):
354
        precheck(data, **options)
355
        action(data, **options)
356
    """
357
    return args, kwargs
1✔
358

359

360
def ignorable(*args, **kwargs):
1✔
361
    """
362
    This is useful for defeating flake warnings.
363
    Call this function to use values that really might be ignored.
364
    This is intended as a declaration that variables are or might be intentionally ignored,
365
    but no enforcement of that is done. Some sample uses:
366

367
    def foo(x, y):
368
        ignorable(x, y)  # so flake8 won't complain about unused vars, whether or not next line is commented out.
369
        # print(x, y)
370
        return 3
371

372
    foo_synonym = foo
373
    ignorable(foo_synonym)  # We might or might not use foo_synonym, but we don't want it reported as unused
374
    """
375
    return args, kwargs
1✔
376

377

378
def get_setting_from_context(settings, ini_var, env_var=None, default=None):
1✔
379
    """
380
    This gets a value from either an environment variable or a config file.
381

382
    The environment variable overrides, since it is more dynamic in nature than a config file,
383
    which might be checked into source control.
384

385
    If the value of env_var is None, it will default to a name similar to ini_var,
386
    but in uppercase and with '.' replaced by '_'. So a 'foo.bar' ini file setting
387
    will defaultly correspond to a 'FOO_BAR' environment variable. This can be overridden
388
    by using an string argument for env_var to specify the environment variable, or using False
389
    to indicate that no env_var is allowed.
390
    """
391
    if env_var is not False:  # False specially means don't allow an environ variable, in case that's ever needed.
1✔
392
        if env_var is None:
1✔
393
            # foo.bar.baz in config file corresponds to FOO_BAR_BAZ as an environment variable setting.
394
            env_var = ini_var.upper().replace(".", "_")
1✔
395
        # NOTE WELL: An implication of this is that an environment variable of an empty string
396
        #            will override a config file setting that is non-empty. This uses 'principle of least surprise',
397
        #            that if environment variable settings appear to set a null string, that's what should prevail.
398
        if env_var in os.environ:
1✔
399
            return os.environ.get(env_var)
1✔
400
    return settings.get(ini_var, default)
1✔
401

402

403
@contextlib.contextmanager
1✔
404
def filtered_warnings(action, message="", category=None, module="", lineno=0, append=False):
1✔
405
    """
406
    Context manager temporarily filters deprecation messages for the duration of the body.
407

408
    Except for its dynamic scope, this is used otherwise the same as warnings.filterwarnings would be used.
409

410
    If category is unsupplied, it should be a class object that is Warning (the default) or one of its subclasses.
411

412
    For example:
413

414
           with filtered_warnings('ignore', category=DeprecationWarning):
415
               ... use something that's obsolete without a lot of fuss ...
416

417
    Note: This is not threadsafe. It's OK while loading system and during testing,
418
          but not in worker threads.
419
    """
420
    if category is None:
1✔
421
        category = Warning
1✔
422
    with warnings.catch_warnings():
1✔
423
        warnings.filterwarnings(action, message=message, category=category, module=module,
1✔
424
                                lineno=lineno, append=append)
425
        yield
1✔
426

427

428
class Retry:
1✔
429

430
    """
431
    This class exists primarily to hold onto data relevant to the Retry.retry_allowed decorator.
432
    There is no need to instantiate the class in order for it to work.
433

434
    This class also has a subclass qa_utils.RetryManager that adds the ability to locally bind data
435
    that has been declared with this decorator.
436
    """
437

438
    class RetryOptions:
1✔
439
        """
440
        A helper class used internally by the Retry class.
441

442
        One of these objects is created and registered for each decorated function unless the name_key is 'anonymous'.
443
        See Retry._RETRY_OPTIONS_CATALOG.
444
        """
445

446
        def __init__(self, retries_allowed=None, wait_seconds=None, wait_increment=None, wait_multiplier=None):
1✔
447
            self.retries_allowed = retries_allowed
1✔
448
            self.wait_seconds = wait_seconds or 0  # None or False mean 0 seconds
1✔
449
            self.wait_increment = wait_increment
1✔
450
            self.wait_multiplier = wait_multiplier
1✔
451
            self.wait_adjustor = self.make_wait_adjustor(wait_increment=wait_increment, wait_multiplier=wait_multiplier)
1✔
452

453
        @staticmethod
1✔
454
        def make_wait_adjustor(wait_increment=None, wait_multiplier=None):
1✔
455
            """
456
            Returns a function that can be called to adjust wait_seconds based on wait_increment or wait_multiplier
457
            before doing a retry at each step.
458
            """
459
            if wait_increment and wait_multiplier:
1✔
460
                raise SyntaxError("You may not specify both wait_increment and wait_multiplier.")
1✔
461

462
            if wait_increment:
1✔
463
                return lambda x: x + wait_increment
1✔
464
            elif wait_multiplier:
1✔
465
                return lambda x: x * wait_multiplier
1✔
466
            else:
467
                return lambda x: x
1✔
468

469
        @property
1✔
470
        def tries_allowed(self):
1✔
471
            return 1 + self.retries_allowed
1✔
472

473
    _RETRY_OPTIONS_CATALOG = {}
1✔
474

475
    DEFAULT_RETRIES_ALLOWED = 1
1✔
476
    DEFAULT_WAIT_SECONDS = 0
1✔
477
    DEFAULT_WAIT_INCREMENT = None
1✔
478
    DEFAULT_WAIT_MULTIPLIER = None
1✔
479

480
    @classmethod
1✔
481
    def _defaulted(cls, value, default):
1✔
482
        """ Triages between argument values and class-declared defaults. """
483
        return default if value is None else value
1✔
484

485
    @classmethod
1✔
486
    def retry_allowed(cls, name_key=None, retries_allowed=None, wait_seconds=None,
1✔
487
                      wait_increment=None, wait_multiplier=None):
488
        """
489
        Used as a decorator on a function definition, makes that function do retrying before really failing.
490
        For example:
491

492
            @Retry.retry_allowed(retries_allowed=4, wait_seconds=2, wait_multiplier=1.25)
493
            def something_that_fails_a_lot(...):
494
                ... flaky code ...
495

496
        will cause the something_that_fails_a_lot(...) code to retry several times before giving up,
497
        either using the same wait each time or, if given a wait_multiplier or wait_increment, using
498
        that advice to adjust the wait time upward on each time.
499

500
        Args:
501

502
            name_key: An optional key that can be used by qa_utils.RetryManager to adjust these parameters in testing.
503
                      If the argument is 'anonymous', no record will be created.
504
            retries_allowed: The number of retries allowed. Default is cls.DEFAULT_RETRIES_ALLOWED.
505
            wait_seconds: The number of wait_seconds between retries. Default is cls.DEFAULT_WAIT_SECONDS.
506
            wait_increment: A fixed increment by which the number of wait_seconds is adjusted on each retry.
507
            wait_multiplier: A multiplier by which the number of wait_seconds is adjusted on each retry.
508
        """
509

510
        def _decorator(function):
1✔
511
            function_name = name_key or function.__name__
1✔
512
            function_profile = cls.RetryOptions(
1✔
513
                retries_allowed=cls._defaulted(retries_allowed, cls.DEFAULT_RETRIES_ALLOWED),
514
                wait_seconds=cls._defaulted(wait_seconds, cls.DEFAULT_WAIT_SECONDS),
515
                wait_increment=cls._defaulted(wait_increment, cls.DEFAULT_WAIT_INCREMENT),
516
                wait_multiplier=cls._defaulted(wait_multiplier, cls.DEFAULT_WAIT_MULTIPLIER),
517
            )
518

519
            check_true(isinstance(retries_allowed, int) and retries_allowed >= 0,
1✔
520
                       "The retries_allowed must be a non-negative integer.",
521
                       error_class=ValueError)
522

523
            # See the 'retrying' method to understand what this is about. -kmp 8-Jul-2020
524
            if function_name != 'anonymous':
1✔
525
                cls._RETRY_OPTIONS_CATALOG[function_name] = function_profile  # Only for debugging.
1✔
526

527
            @functools.wraps(function)
1✔
528
            def wrapped_function(*args, **kwargs):
1✔
529
                tries_allowed = function_profile.tries_allowed
1✔
530
                wait_seconds = function_profile.wait_seconds or 0
1✔
531
                last_error = None
1✔
532
                for i in range(tries_allowed):
1✔
533
                    if i > 0:
1✔
534
                        if i > 1:
1✔
535
                            wait_seconds = function_profile.wait_adjustor(wait_seconds)
1✔
536
                        if wait_seconds > 0:
1✔
537
                            time.sleep(wait_seconds)
1✔
538
                    try:
1✔
539
                        success = function(*args, **kwargs)
1✔
540
                        return success
1✔
541
                    except Exception as e:
1✔
542
                        last_error = e
1✔
543
                if last_error is not None:
1✔
544
                    raise last_error
1✔
545

546
            return wrapped_function
1✔
547

548
        return _decorator
1✔
549

550
    @classmethod
1✔
551
    def retrying(cls, fn, retries_allowed=None, wait_seconds=None, wait_increment=None, wait_multiplier=None):
1✔
552
        """
553
        Similar to the @Retry.retry_allowed decorator, but used around individual calls. e.g.,
554

555
            res = Retry.retrying(testapp.get)(url)
556

557
        If you don't like the defaults, you can override them with arguments:
558

559
            res = Retry.retrying(testapp.get, retries_allowed=5, wait_seconds=1)(url)
560

561
        but if you need to do it a lot, you can make a subclass:
562

563
            class MyRetry(Retry):
564
                DEFAULT_RETRIES_ALLOWED = 5
565
                DEFAULT_WAIT_SECONDS = 1
566
            retrying = MyRetry.retrying  # Avoids saying MyRetry.retrying(...) everywhere
567
            ...
568
            res1 = retrying(testapp.get)(url)
569
            res2 = retrying(testapp.get)(url)
570
            ...etc.
571

572
        Args:
573

574
            fn: A function that will be retried on failure.
575
            retries_allowed: The number of retries allowed. Default is cls.DEFAULT_RETRIES_ALLOWED.
576
            wait_seconds: The number of wait_seconds between retries. Default is cls.DEFAULT_WAIT_SECONDS.
577
            wait_increment: A fixed increment by which the number of wait_seconds is adjusted on each retry.
578
            wait_multiplier: A multiplier by which the number of wait_seconds is adjusted on each retry.
579

580
        Returns: whatever the fn returns, assuming it returns normally/successfully.
581
        """
582
        # A special name_key of 'anonymous' is the default, which causes there not to be a name key.
583
        # This cannot work in conjunction with RetryManager because different calls may result in different
584
        # function values at the same point in code. -kmp 8-Jul-2020
585
        decorator_function = Retry.retry_allowed(
1✔
586
            name_key='anonymous', retries_allowed=retries_allowed, wait_seconds=wait_seconds,
587
            wait_increment=wait_increment, wait_multiplier=wait_multiplier
588
        )
589
        return decorator_function(fn)
1✔
590

591

592
def apply_dict_overrides(dictionary: dict, **overrides) -> dict:
1✔
593
    """
594
    Assigns a given set of overrides to a dictionary, ignoring any entries with None values, which it leaves alone.
595
    """
596
    # I'm not entirely sure the treatment of None is the right thing. Need to look into that.
597
    # Then again, if None were stored, then apply_dict_overrides(d, var1=1, var2=2, var3=None)
598
    # would be no different than (dict(d, var1=1, var2=2, var3=None). It might be more useful
599
    # and/or interesting if it would actually remove the key instead. -kmp 18-Jul-2020
600
    for k, v in overrides.items():
1✔
601
        if v is not None:
1✔
602
            dictionary[k] = v
1✔
603
    # This function works by side effect, but getting back the changed dict may be sometimes useful.
604
    return dictionary
1✔
605

606

607
def utc_now_str():
1✔
608
    # from jsonschema_serialize_fork date-time format requires a timezone
609
    return datetime.datetime.utcnow().isoformat() + '+00:00'
1✔
610

611

612
def utc_today_str():
1✔
613
    """Returns a YYYY-mm-dd date string, relative to the UTC timezone."""
614
    return datetime.datetime.strftime(datetime.datetime.utcnow(), "%Y-%m-%d")
1✔
615

616

617
def as_seconds(*, seconds=0, minutes=0, hours=0, days=0, weeks=0, milliseconds=0, as_type=None):
1✔
618
    """
619
    Coerces a relative amount of time (keyword arguments seconds, minutes, etc. like timedelta) into seconds.
620

621
    If the number of seconds is an integer, it will be coerced to an integer. Otherwise, it will be a float.
622
    If as_float is given and not None, it will be applied as a function to the result, allowing it to be coerced
623
    to another value than an integer or float.  For example,
624
      >>> as_seconds(seconds=1, minutes=1)
625
      61
626
      >>> as_seconds(seconds=1, minutes=1, as_type=str)
627
      '61'
628
    """
629
    delta = datetime.timedelta(seconds=seconds, minutes=minutes, hours=hours,
1✔
630
                               days=days, weeks=weeks, milliseconds=milliseconds)
631
    seconds = delta.total_seconds()
1✔
632
    frac, intpart = math.modf(seconds)
1✔
633
    if frac == 0.0:
1✔
634
        seconds = int(intpart)
1✔
635
    if as_type is not None:
1✔
636
        seconds = as_type(seconds)
1✔
637
    return seconds
1✔
638

639

640
MIN_DATETIME = datetime.datetime(datetime.MINYEAR, 1, 1, 0, 0, 0)
1✔
641
MIN_DATETIME_UTC = datetime.datetime(datetime.MINYEAR, 1, 1, 0, 0, 0, tzinfo=pytz.UTC)
1✔
642

643

644
def future_datetime(*, now=None, seconds=0, minutes=0, hours=0, days=0, weeks=0, milliseconds=0):
1✔
645
    delta = datetime.timedelta(seconds=seconds, minutes=minutes, hours=hours,
1✔
646
                               days=days, weeks=weeks, milliseconds=milliseconds)
647
    return (now or datetime.datetime.now()) + delta
1✔
648

649

650
REF_TZ = pytz.timezone(os.environ.get("REF_TZ") or "US/Eastern")
1✔
651

652

653
class DatetimeCoercionFailure(ValueError):
1✔
654

655
    def __init__(self, timespec, timezone):
1✔
656
        self.timespec = timespec
1✔
657
        self.timezone = timezone
1✔
658
        extra = ""
1✔
659
        if timezone:
1✔
660
            extra = " (for timezone %s)" % timezone
1✔
661
        super().__init__("Cannot coerce to datetime: %s%s" % (timespec, extra))
1✔
662

663

664
def as_datetime(timespec, tz=None, raise_error=True):
1✔
665
    """
666
    Parses the given date/time (which may be a string or a datetime.datetime), returning a datetime.datetime object.
667

668
    If the given datetime is already such an object, it is just returned (not necessarily in the given timezone).
669
    If the datetime to be returned has no timezone and a timezone argument has been given, that timezone is applied.
670
    If it is a string, it should be in a format such as 'yyyy-mm-dd hh:mm:ss' or 'yyyy-mm-dd hh:mm:ss-nnnn'
671
    (with -nnnn being a timezone specification).
672
    If the given time is not a datetime, and cannot be coerced to be done, an error is raised
673
    unless raise_error (default True) is False.
674
    """
675
    try:
1✔
676
        # This type check has to work even if datetime is mocked, so we use it under another variable name to
677
        # make it harder to mock out. -kmp 6-Nov-2020
678
        dt = timespec
1✔
679
        if not isinstance(dt, datetime_type):
1✔
680
            dt = dateutil_parse(dt)
1✔
681
        if tz and not dt.tzinfo:
1✔
682
            dt = tz.localize(dt)
1✔
683
        return dt
1✔
684
    except Exception:
1✔
685
        # I decided to treat the returning None case as a bug. It was not advertised and not used.
686
        # Throwing an error by default will make this more consistent with as_ref_datetime and as_utc_datetime.
687
        # But just in case there is a use that wanted None, so it's easy to fix, raise_error=False can be supplied.
688
        # -kmp 29-Nov-2020
689
        if raise_error:
1✔
690
            raise DatetimeCoercionFailure(timespec=timespec, timezone=tz)
1✔
691
        else:
692
            return None
1✔
693

694

695
def as_ref_datetime(timespec):
1✔
696
    """
697
    Parses a given datetime, returning a rendition of that tie in the reference timezone (US/Eastern by default).
698

699
    If the input time is a string or a naive datetime with no timezone, it is assumed to be in the reference timezone
700
    (which is US/Eastern by default).
701
    If the time is already a datetime, no parsing occurs, but the time is still adjusted to use the reference timeszone.
702
    If the given time is not a datetime, and cannot be coerced to be done, an error is raised.
703
    """
704
    try:
1✔
705
        dt = as_datetime(timespec, tz=REF_TZ)
1✔
706
        hms_dt = dt.astimezone(REF_TZ)
1✔
707
        return hms_dt
1✔
708
    except Exception:
1✔
709
        raise DatetimeCoercionFailure(timespec=timespec, timezone=REF_TZ)
1✔
710

711

712
def as_utc_datetime(timespec):
1✔
713
    """
714
    Parses a given datetime, returning a rendition of that tie in UTC.
715

716
    If the input time is a string or a naive datetime with no timezone, it is assumed to be in the reference timezone
717
    (which is US/Eastern by default). UTC is only used as the output format, not as an assumption about the input.
718
    If the time is already a datetime, no parsing occurs, but the time is still adjusted to use UTC.
719
    If the given time is not a datetime, and cannot be coerced to be done, an error is raised.
720
    """
721
    try:
1✔
722
        dt = as_datetime(timespec, tz=REF_TZ)
1✔
723
        utc_dt = dt.astimezone(pytz.UTC)
1✔
724
        return utc_dt
1✔
725
    except Exception:
1✔
726
        raise DatetimeCoercionFailure(timespec=timespec, timezone=pytz.UTC)
1✔
727

728

729
def in_datetime_interval(when, *, start=None, end=None):
1✔
730
    """
731
    Returns true if the first argument ('when') is in the range given by the other arguments.
732

733
    The comparison is upper- and lower-inclusive.
734
    The string will be parsed as a datetime in the reference timezone (REF_TZ) if it doesn't have an explicit timezone.
735
    """
736
    when = as_ref_datetime(when)  # This is not allowed to be None, but could be str and we need datetimes to compare.
1✔
737
    start = start and as_ref_datetime(start)
1✔
738
    end = end and as_ref_datetime(end)
1✔
739
    return (not start or start <= when) and (not end or end >= when)
1✔
740

741

742
def ref_now():
1✔
743
    """Returns the current time in the portal's reference timezone, as determined by REF_TZ.
744

745
       Because this software originates at Harvard Medical School, the reference timezone defaults to US/Eastern.
746
       It can be set to another value by binding the REF_TZ environment variable."""
747
    return as_datetime(datetime.datetime.now(), REF_TZ)
1✔
748

749

750
class LockoutManager:
1✔
751
    """
752
    This class is used as a guard of a critical operation that can only be called within a certain frequency.
753
    e.g.,
754

755
        class Foo:
756
            def __init__(self):
757
                # 60 seconds required between calls, with a 1 second margin of error (overhead, clocks varying, etc)
758
                self.lockout_manager = LockoutManager(action="foo", lockout_seconds=1, safety_seconds=1)
759
            def foo():
760
                self.lockout_manager.wait_if_needed()
761
                do_guarded_action()
762

763
        f = Foo()     # make a Foo
764
        v1 = f.foo()  # will immediately get a value
765
        time.sleep(58)
766
        v2 = f.foo()  # will wait about 2 seconds, then get a value
767
        v3 = f.foo()  # will wait about 60 seconds, then get a value
768

769
    Conceptually this is a special case of RateManager for n=1, though in practice it arose differently and
770
    the supplementary methods (which we happen to use mostly for testing) differ because the n=1 case is simpler
771
    and admits more questions. So, for now at least, this is not a subclass of RateManager but a separate
772
    implementation.
773
    """
774

775
    EARLIEST_TIMESTAMP = datetime.datetime(datetime.MINYEAR, 1, 1)  # maybe useful for testing
1✔
776

777
    def __init__(self, *, lockout_seconds, safety_seconds=0, action="metered action", enabled=True, log=None):
1✔
778
        """
779
        Creates a LockoutManager that cooperates in assuring a guarded operation is only happens at a certain rate.
780

781
        The rate is once person lockout_seconds. This is a special case of RateManager and might get phased out
782
        as redundant, but has slightly different operations available for testing.
783

784
        Args:
785

786
        lockout_seconds int: A theoretical number of seconds allowed between calls to the guarded operation.
787
        safety_seconds int: An amount added to interval_seconds to accommodate real world coordination fuzziness.
788
        action str: A noun or noun phrase describing the action being guarded.
789
        enabled bool: A boolean controlling whether this facility is enabled. If False, waiting is disabled.
790
        log object: A logger object (supporting operations like .debug, .info, .warning, and .error).
791
        """
792

793
        # This makes it easy to turn off the feature
794
        self.lockout_enabled = enabled
1✔
795
        self.lockout_seconds = lockout_seconds
1✔
796
        self.safety_seconds = safety_seconds
1✔
797
        self.action = action
1✔
798
        self._timestamp = self.EARLIEST_TIMESTAMP
1✔
799
        self.log = log or logging
1✔
800

801
    @property
1✔
802
    def timestamp(self):
1✔
803
        """The timestamp is read-only. Use update_timestamp() to set it."""
804
        return self._timestamp
1✔
805

806
    @property
1✔
807
    def effective_lockout_seconds(self):
1✔
808
        """
809
        The effective time between calls
810

811
        Returns: the sum of the lockout and the safety seconds
812
        """
813
        return self.lockout_seconds + self.safety_seconds
1✔
814

815
    def wait_if_needed(self):
1✔
816
        """
817
        This function is intended to be called immediately prior to each guarded operation.
818

819
        This function will wait (using time.sleep) only if necessary, and for the amount necessary,
820
        to comply with rate-limiting declared in the creation of this LockoutManager.
821

822
        NOTE WELL: It is presumed that all calls are coming from this source. This doesn't have ESP that would
823
        detect or otherwise accommodate externally generated calls, so violations of rate-limiting can still
824
        happen that way. This should be sufficient for sequential testing, and better than nothing for
825
        production operation.  This is not a substitute for responding to server-initiated throttling protocols.
826
        """
827
        now = datetime.datetime.now()
1✔
828
        # Note that this quantity is always positive because now is always bigger than the timestamp.
829
        seconds_since_last_attempt = (now - self._timestamp).total_seconds()
1✔
830
        # Note again that because seconds_since_last_attempt is positive, the wait seconds will
831
        # never exceed self.effective_lockout_seconds, so
832
        #   0 <= wait_seconds <= self.effective_lockout_seconds
833
        wait_seconds = max(0.0, self.effective_lockout_seconds - seconds_since_last_attempt)
1✔
834
        if wait_seconds > 0.0:
1✔
835
            shared_message = ("Last %s attempt was at %s (%s seconds ago)."
1✔
836
                              % (self.action, self._timestamp, seconds_since_last_attempt))
837
            if self.lockout_enabled:
1✔
838
                action_message = "Waiting %s seconds before attempting another." % wait_seconds
1✔
839
                self.log.warning("%s %s" % (shared_message, action_message))
1✔
840
                time.sleep(wait_seconds)
1✔
841
            else:
842
                action_message = "Continuing anyway because lockout is disabled."
1✔
843
                self.log.warning("%s %s" % (shared_message, action_message))
1✔
844
        self.update_timestamp()
1✔
845

846
    def update_timestamp(self):
1✔
847
        """
848
        Explicitly sets the reference time point for computation of our lockout.
849
        This is called implicitly by .wait_if_needed(), and for some situations that may be sufficient.
850
        """
851
        self._timestamp = datetime.datetime.now()
1✔
852

853

854
class RateManager:
1✔
855
    """
856
    This class is used for functions that can only be called at a certain rate, described by calls per unit time.
857
    e.g.,
858

859
        class Foo:
860
            def __init__(self):
861
                # 60 seconds required between calls, with a 1 second margin of error (overhead, clocks varying, etc)
862
                self.rate_manager = RateManager(action="foo", interval_seconds=1, safety_seconds=1)
863
            def foo():
864
                self.lockout_manager.wait_if_needed()
865
                do_guarded_action()
866

867
        f = Foo()     # make a Foo
868
        v1 = f.foo()  # will immediately get a value
869
        time.sleep(58)
870
        v2 = f.foo()  # will wait about 2 seconds, then get a value
871
        v3 = f.foo()  # will wait about 60 seconds, then get a value
872

873
    Conceptually this is a special case of RateManager for n=1, though in practice it arose differently and
874
    the supplementary methods (which we happen to use mostly for testing) differ because the n=1 case is simpler
875
    and admits more questions. So, for now at least, this is not a subclass of RateManager but a separate
876
    implementation.
877
    """
878

879
    EARLIEST_TIMESTAMP = datetime.datetime(datetime.MINYEAR, 1, 1)  # maybe useful for testing
1✔
880

881
    def __init__(self, *, interval_seconds, safety_seconds=0, allowed_attempts=1,
1✔
882
                 action="metered action", enabled=True, log=None, wait_hook=None):
883
        """
884
        Creates a RateManager that cooperates in assuring that a guarded operation happens only at a certain rate.
885

886
        The rate is measured as allowed_attempts per interval_seconds.
887

888
        Args:
889

890
        interval_seconds int: A number of seconds (the theoretical denominator of the allowed rate)
891
        safety_seconds int: An amount added to interval_seconds to accommodate real world coordination fuzziness.
892
        allowed_attempts int: A number of attempts allowed for every interval_seconds.
893
        action str: A noun or noun phrase describing the action being guarded.
894
        enabled bool: A boolean controlling whether this facility is enabled. If False, waiting is disabled.
895
        log object: A logger object (supporting operations like .debug, .info, .warning, and .error).
896
        wait_hook: A hook not recommended for production, but intended for testing to know when waiting happens.
897

898
        """
899
        if not (isinstance(allowed_attempts, int) and allowed_attempts >= 1):
1✔
900
            raise TypeError("The allowed_attempts must be a positive integer: %s" % allowed_attempts)
1✔
901
        # This makes it easy to turn off the feature
902
        self.enabled = enabled
1✔
903
        self.interval_seconds = interval_seconds
1✔
904
        self.safety_seconds = safety_seconds
1✔
905
        self.allowed_attempts = allowed_attempts
1✔
906
        self.action = action
1✔
907
        self.timestamps = [self.EARLIEST_TIMESTAMP] * allowed_attempts
1✔
908
        self.log = log or logging
1✔
909
        self.wait_hook = wait_hook
1✔
910

911
    def set_wait_hook(self, wait_hook):
1✔
912
        """
913
        Use this to set the wait hook, which will be a function that notices we had to wait.
914

915
        Args:
916

917
        wait_hook: a function of two arguments (wait_seconds and next_expiration)
918
        """
919
        self.wait_hook = wait_hook
1✔
920

921
    def wait_if_needed(self):
1✔
922
        """
923
        This function is intended to be called immediately prior to each guarded operation.
924

925
        This function will wait (using time.sleep) only if necessary, and for the amount necessary,
926
        to comply with rate-limiting declared in the creation of this RateManager.
927

928
        NOTE WELL: It is presumed that all calls are coming from this source. This doesn't have ESP that would
929
        detect or otherwise accommodate externally generated calls, so violations of rate-limiting can still
930
        happen that way. This should be sufficient for sequential testing, and better than nothing for
931
        production operation.  This is not a substitute for responding to server-initiated throttling protocols.
932
        """
933
        now = datetime.datetime.now()
1✔
934
        expiration_delta = datetime.timedelta(seconds=self.interval_seconds)
1✔
935
        latest_expiration = now + expiration_delta
1✔
936
        soonest_expiration = latest_expiration
1✔
937
        # This initial value of soonest_expiration_pos is arbitrarily chosen, but it will normally be superseded.
938
        # The only case where it's not overridden is where there were no better values than the latest_expiration,
939
        # so if we wait that amount, all of the slots will be ready to be reused and we might as well use 0 as any.
940
        # -kmp 19-Jul-2020
941
        soonest_expiration_pos = 0
1✔
942
        for i, expiration_time in enumerate(self.timestamps):
1✔
943
            if expiration_time <= now:  # This slot was unused or has expired
1✔
944
                self.timestamps[i] = latest_expiration
1✔
945
                return
1✔
946
            elif expiration_time <= soonest_expiration:
1✔
947
                soonest_expiration = expiration_time
1✔
948
                soonest_expiration_pos = i
1✔
949
        sleep_time_needed = (soonest_expiration - now).total_seconds() + self.safety_seconds
1✔
950
        if self.enabled:
1✔
951
            if self.wait_hook:  # Hook primarily for testing
1✔
952
                self.wait_hook(wait_seconds=sleep_time_needed, next_expiration=soonest_expiration)
1✔
953
            self.log.warning("Waiting %s seconds before attempting %s." % (sleep_time_needed, self.action))
1✔
954
            time.sleep(sleep_time_needed)
1✔
955
        # It will have expired now, so grab that slot. We have to recompute the 'now' time because we slept in between.
956
        self.timestamps[soonest_expiration_pos] = datetime.datetime.now() + expiration_delta
1✔
957

958

959
def environ_bool(var, default=False):
1✔
960
    """
961
    Returns True if the named environment variable is set to 'true' (in any alphabetic case), False if something else.
962

963
    If the variable value is not set, the default is returned. False is the default default.
964
    This function is intended to allow boolean parameters to be initialized from environment variables.
965
    e.g.,
966
        DEBUG_FOO = environ_bool("FOO")
967
    or. if a special value is desired when the variable is not set:
968
        DEBUG_FOO = environ_bool("FOO", default=None)
969

970
    Args:
971
        var str: The name of an environment variable.
972
        default object: Any object.
973
    """
974
    if var not in os.environ:
1✔
975
        return default
1✔
976
    else:
977
        return str_to_bool(os.environ[var])
1✔
978

979

980
def str_to_bool(x: Optional[str]) -> Optional[bool]:
1✔
981
    if x is None:
1✔
982
        return None
1✔
983
    elif isinstance(x, str):
1✔
984
        return x.lower() == "true"
1✔
985
    else:
986
        raise ValueError(f"An argument to str_or_bool must be a string or None: {x!r}")
1✔
987

988

989
def to_integer(value: str,
1✔
990
               allow_commas: bool = False,
991
               allow_multiplier_suffix: bool = False,
992
               fallback: Optional[Union[int, float]] = None) -> Optional[int]:
993
    return to_number(value, fallback=fallback, as_float=False,
1✔
994
                     allow_commas=allow_commas,
995
                     allow_multiplier_suffix=allow_multiplier_suffix)
996

997

998
def to_float(value: str,
1✔
999
             allow_commas: bool = False,
1000
             allow_multiplier_suffix: bool = False,
1001
             fallback: Optional[Union[int, float]] = None) -> Optional[int]:
1002
    return to_number(value, fallback=fallback, as_float=True,
1✔
1003
                     allow_commas=allow_commas,
1004
                     allow_multiplier_suffix=allow_multiplier_suffix)
1005

1006

1007
_TO_NUMBER_MULTIPLIER_K = 1000
1✔
1008
_TO_NUMBER_MULTIPLIER_M = 1000 * _TO_NUMBER_MULTIPLIER_K
1✔
1009
_TO_NUMBER_MULTIPLIER_G = 1000 * _TO_NUMBER_MULTIPLIER_M
1✔
1010
_TO_NUMBER_MULTIPLIER_T = 1000 * _TO_NUMBER_MULTIPLIER_G
1✔
1011

1012
_TO_NUMBER_MULTIPLIER_SUFFIXES = {
1✔
1013
    "K": _TO_NUMBER_MULTIPLIER_K,
1014
    "KB": _TO_NUMBER_MULTIPLIER_K,
1015
    "M": _TO_NUMBER_MULTIPLIER_M,
1016
    "MB": _TO_NUMBER_MULTIPLIER_M,
1017
    "G": _TO_NUMBER_MULTIPLIER_G,
1018
    "GB": _TO_NUMBER_MULTIPLIER_G,
1019
    "T": _TO_NUMBER_MULTIPLIER_T,
1020
    "TB": _TO_NUMBER_MULTIPLIER_T
1021
}
1022

1023

1024
def to_number(value: str,
1✔
1025
              as_float: bool = False,
1026
              allow_commas: bool = False,
1027
              allow_multiplier_suffix: bool = False,
1028
              fallback: Optional[Union[int, float]] = None) -> Optional[Union[int, float]]:
1029
    """
1030
    Converts the given string value to an int, or float if as_float is True, or None if malformed.
1031
    If allow_commas is True then allow commas (only) every three digits. If allow_multiplier_suffix
1032
    is True  allow any of K, KB; or M, MB; or G, or GB; or T, TB (case-insensitive), to mean multiply
1033
    value by one thousand; one million; one billion; or one trillion; respectively. If as_float is True
1034
    then value is parsed and returned as a float rather than int. Note that even if as_float is False,
1035
    values that might look like a float, can be an int, for example, "1.5K", returns 1500 as an int;
1036
    but "1.5002K" returns None, i.e. since 1.5002K is 1500.2 which is not an int.
1037
    """
1038

1039
    if not (isinstance(value, str) and (value := value.strip())):
1✔
1040
        if as_float is True:
1✔
1041
            return float(value) if isinstance(value, (float, int)) else fallback
1✔
1042
        else:
1043
            return value if isinstance(value, int) else fallback
1✔
1044

1045
    value_multiplier = 1
1✔
1046
    value_negative = False
1✔
1047
    value_fraction = None
1✔
1048

1049
    if value.startswith("-"):
1✔
1050
        if not (value := value[1:].strip()):
1✔
NEW
1051
            return fallback
×
1052
        value_negative = True
1✔
1053
    elif value.startswith("+"):
1✔
NEW
1054
        if not (value := value[1:].strip()):
×
NEW
1055
            return fallback
×
1056

1057
    if allow_multiplier_suffix is True:
1✔
1058
        value_upper = value.upper()
1✔
1059
        for suffix in _TO_NUMBER_MULTIPLIER_SUFFIXES:
1✔
1060
            if value_upper.endswith(suffix):
1✔
1061
                value_multiplier *= _TO_NUMBER_MULTIPLIER_SUFFIXES[suffix]
1✔
1062
                if not (value := value[:-len(suffix)].strip()):
1✔
NEW
1063
                    return fallback
×
1064
                break
1✔
1065

1066
    if (allow_multiplier_suffix is True) or (as_float is True):
1✔
1067
        # Allow for example "1.5K" to mean 1500 (integer).
1068
        if (dot_index := value.rfind(".")) >= 0:
1✔
1069
            if value_fraction := value[dot_index + 1:].strip():
1✔
1070
                try:
1✔
1071
                    value_fraction = float(f"0.{value_fraction}")
1✔
1072
                except Exception:
1✔
1073
                    return fallback
1✔
1074
            if not (value := value[:dot_index].strip()):
1✔
NEW
1075
                if not value_fraction:
×
NEW
1076
                    return fallback
×
NEW
1077
                value = "0"
×
1078
    elif (as_float is not True) and (value_dot_zero_suffix := re.search(r"\.0*$", value)):
1✔
1079
        # Allow for example "123.00" to mean 123 (integer).
1080
        value = value[:value_dot_zero_suffix.start()]
1✔
1081

1082
    if (allow_commas is True) and ("," in value):
1✔
1083
        if not re.fullmatch(r"(-?\d{1,3}(,\d{3})*)", value):
1✔
1084
            return fallback
1✔
1085
        value = value.replace(",", "")
1✔
1086

1087
    if not value.isdigit():
1✔
1088
        return fallback
1✔
1089

1090
    value = float(value) if as_float is True else int(value)
1✔
1091

1092
    if value_fraction:
1✔
1093
        value_float = (float(value) + value_fraction) * float(value_multiplier)
1✔
1094
        if as_float is True:
1✔
1095
            value = value_float
1✔
1096
        else:
NEW
1097
            value = int(value_float)
×
NEW
1098
            if value_float != value:
×
NEW
1099
                return fallback
×
1100
    else:
1101
        value *= value_multiplier
1✔
1102

1103
    if value_negative:
1✔
1104
        value = -value
1✔
1105

1106
    return value
1✔
1107

1108

1109
def to_boolean(value: str, fallback: Optional[Any]) -> Optional[Any]:
1✔
1110
    if isinstance(value, str) and (value := value.strip().lower()):
1✔
1111
        if (lower_value := value.lower()) in ["true", "t"]:
1✔
1112
            return True
1✔
1113
        elif lower_value in ["false", "f"]:
1✔
1114
            return False
1✔
1115
    return fallback
×
1116

1117

1118
def to_enum(value: str, enumerators: List[str], fuzzy: bool = False) -> Optional[str]:
1✔
1119
    matches = []
1✔
1120
    if isinstance(value, str) and (value := value.strip()) and isinstance(enumerators, List):
1✔
1121
        enum_specifiers = {str(enum).lower(): enum for enum in enumerators}
1✔
1122
        if (enum_value := enum_specifiers.get(lower_value := value.lower())) is not None:
1✔
1123
            return enum_value
1✔
1124
        if fuzzy is not True:
1✔
1125
            return value
1✔
UNCOV
1126
        for enum_canonical, _ in enum_specifiers.items():
×
UNCOV
1127
            if enum_canonical.startswith(lower_value):
×
UNCOV
1128
                matches.append(enum_canonical)
×
UNCOV
1129
        if len(matches) == 1:
×
UNCOV
1130
            return enum_specifiers[matches[0]]
×
1131
    return enum_specifiers[matches[0]] if len(matches) == 1 else value
1✔
1132

1133

1134
@contextlib.contextmanager
1✔
1135
def override_environ(**overrides):
1✔
1136
    """
1137
    Overrides os.environ for the dynamic extent of the call, using the specified values.
1138
    A value of None means to delete the property temporarily.
1139
    (This uses override_dict to do the actual overriding. See notes for that function about lack of thread safety.)
1140
    """
1141
    with override_dict(os.environ, **overrides):
1✔
1142
        yield
1✔
1143

1144

1145
@contextlib.contextmanager
1✔
1146
def override_dict(d, **overrides):
1✔
1147
    """
1148
    Overrides the given dictionary for the dynamic extent of the call, using the specified values.
1149
    A value of None means to delete the property temporarily.
1150

1151
    This function is not threadsafe because it dynamically assigns and de-assigns parts of a dictionary.
1152
    It should be reserved for use in test functions or command line tools or other contexts that are known
1153
    to be single-threaded, or at least not competing for the resource of the dictionary. (It would be threadsafe
1154
    to use a dictionary that is only owned by the current process.)
1155
    """
1156
    to_delete = []
1✔
1157
    to_restore = {}
1✔
1158
    try:
1✔
1159
        for k, v in overrides.items():
1✔
1160
            if k in d:
1✔
1161
                to_restore[k] = d[k]
1✔
1162
            else:
1163
                to_delete.append(k)
1✔
1164
            if v is None:
1✔
1165
                d.pop(k, None)  # Delete key k, tolerating it being already gone
1✔
1166
            else:
1167
                d[k] = v
1✔
1168
        yield
1✔
1169
    finally:
1170
        for k in to_delete:
1✔
1171
            d.pop(k, None)  # Delete key k, tolerating it being already gone
1✔
1172
        for k, v in to_restore.items():
1✔
1173
            d[k] = v
1✔
1174

1175

1176
@contextlib.contextmanager
1✔
1177
def local_attrs(obj, **kwargs):
1✔
1178
    """
1179
    This binds the named attributes of the given object.
1180
    This is only allowed for an object that directly owns the indicated attributes.
1181

1182
    """
1183
    keys = kwargs.keys()
1✔
1184
    for key in keys:
1✔
1185
        if key not in obj.__dict__:
1✔
1186
            # This works only for objects that directly have the indicated property.
1187
            # That happens for
1188
            #  (a) an instance where its instance variables are in keys.
1189
            #  (b) an uninstantiated class where its class variables (but not inherited class variables) are in keys.
1190
            # So the error happens for these cases:
1191
            #  (c) an instance where any of the keys come from its class instead of the instance itself
1192
            #  (d) an uninstantiated class being used for keys that are inherited rather than direct class variables
1193
            raise ValueError("%s inherits property %s. Treating it as dynamic could affect other objects."
1✔
1194
                             % (obj, key))
1195
    saved = {
1✔
1196
        key: getattr(obj, key)
1197
        for key in keys
1198
    }
1199
    try:
1✔
1200
        for key in keys:
1✔
1201
            setattr(obj, key, kwargs[key])
1✔
1202
        yield
1✔
1203
    finally:
1204
        for key in keys:
1✔
1205
            setattr(obj, key, saved[key])
1✔
1206

1207

1208
def check_true(test_value, message, error_class=None):
1✔
1209
    """
1210
    If the first argument does not evaluate to a true value, an error is raised.
1211

1212
    The error, if one is raised, will be of type error_class, and its message will be given by message.
1213
    The error_class defaults to RuntimeError, but may be any Exception class.
1214
    """
1215

1216
    __tracebackhide__ = True
1✔
1217

1218
    if error_class is None:
1✔
1219
        error_class = RuntimeError
1✔
1220
    if not test_value:
1✔
1221
        raise error_class(message)
1✔
1222

1223

1224
def remove_element(elem, lst, raise_error=True):
1✔
1225
    """
1226
    Returns a shallow copy of the given list with the first occurrence of the given element removed.
1227

1228
    If the element doesn't occur in the list, an error is raised unless given raise_error=False,
1229
    in which case a shallow copy of the original list is returned (with no elements removed).
1230

1231
    :param elem: an object
1232
    :param lst: a list
1233
    :param raise_error: a boolean (default True)
1234
    """
1235

1236
    result = lst.copy()
1✔
1237
    try:
1✔
1238
        result.remove(elem)
1✔
1239
    except ValueError:
1✔
1240
        if raise_error:
1✔
1241
            raise
1✔
1242
    return result
1✔
1243

1244

1245
def remove_prefix(prefix: str, text: str, required: bool = False):
1✔
1246
    if not text.startswith(prefix):
1✔
1247
        if required:
1✔
1248
            raise ValueError('Prefix %s is not the initial substring of %s' % (prefix, text))
1✔
1249
        else:
1250
            return text
1✔
1251
    return text[len(prefix):]
1✔
1252

1253

1254
def remove_suffix(suffix: str, text: str, required: bool = False):
1✔
1255
    if not text.endswith(suffix):
1✔
1256
        if required:
1✔
1257
            raise ValueError('Suffix %s is not the final substring of %s' % (suffix, text))
1✔
1258
        else:
1259
            return text
1✔
1260
    return text[:len(text)-len(suffix)]
1✔
1261

1262

1263
def remove_empty_properties(data: Optional[Union[list, dict]],
1✔
1264
                            isempty: Optional[Callable] = None,
1265
                            isempty_array_element: Optional[Callable] = None,
1266
                            raise_exception_on_nonempty_array_element_after_empty: bool = False) -> None:
1267
    def _isempty(value: Any) -> bool:  # noqa
1✔
1268
        return isempty(value) if callable(isempty) else value in [None, "", {}, []]
1✔
1269
    if isinstance(data, dict):
1✔
1270
        for key in list(data.keys()):
1✔
1271
            if _isempty(value := data[key]):
1✔
1272
                del data[key]
1✔
1273
            else:
1274
                remove_empty_properties(value, isempty=isempty, isempty_array_element=isempty_array_element,
1✔
1275
                                        raise_exception_on_nonempty_array_element_after_empty=  # noqa
1276
                                        raise_exception_on_nonempty_array_element_after_empty)
1277
    elif isinstance(data, list):
1✔
1278
        for item in data:
1✔
1279
            remove_empty_properties(item, isempty=isempty, isempty_array_element=isempty_array_element,
1✔
1280
                                    raise_exception_on_nonempty_array_element_after_empty=  # noqa
1281
                                    raise_exception_on_nonempty_array_element_after_empty)
1282
        if callable(isempty_array_element):
1✔
1283
            if raise_exception_on_nonempty_array_element_after_empty is True:
1✔
1284
                empty_element_seen = False
1✔
1285
                for item in data:
1✔
1286
                    if not empty_element_seen and isempty_array_element(item):
1✔
1287
                        empty_element_seen = True
1✔
1288
                    elif empty_element_seen and not isempty_array_element(item):
1✔
1289
                        raise Exception("Non-empty element found after empty element.")
×
1290
            data[:] = [item for item in data if not isempty_array_element(item)]
1✔
1291

1292

1293
class ObsoleteError(Exception):
1✔
1294
    pass
1✔
1295

1296

1297
def obsolete(func, fail=True):
1✔
1298
    """ Decorator that allows you to mark methods as obsolete and raise an exception if called.
1299
        You can also pass fail=False to the decorator to just emit an error log statement.
1300
    """
1301

1302
    def inner(*args, **kwargs):
1✔
1303
        if not fail:
1✔
1304
            logging.error('Called obsolete function %s' % func.__name__)
1✔
1305
            return func(*args, **kwargs)
1✔
1306
        raise ObsoleteError('Tried to call function %s but it is marked as obsolete' % func.__name__)
1✔
1307

1308
    return inner
1✔
1309

1310

1311
def ancestor_classes(cls, reverse=False):
1✔
1312
    result = list(cls.__mro__[1:])
1✔
1313
    if reverse:
1✔
1314
        result.reverse()
1✔
1315
    return result
1✔
1316

1317

1318
def is_proper_subclass(cls, maybe_proper_superclass):
1✔
1319
    """
1320
    Returns true of its first argument is a subclass of the second argument, but is not that class itself.
1321
    (Every class is a subclass of itself, but no class is a 'proper subclass' of itself.)
1322
    """
1323
    return cls is not maybe_proper_superclass and issubclass(cls, maybe_proper_superclass)
1✔
1324

1325

1326
def full_class_name(obj):
1✔
1327
    """
1328
    Returns the fully-qualified name of the class of the given obj (an object).
1329

1330
    For built-in classes, just the class name is returned.
1331
    For other classes, the class name with the module name prepended (separated by a dot) is returned.
1332
    """
1333

1334
    # Source: https://stackoverflow.com/questions/2020014/get-fully-qualified-class-name-of-an-object-in-python
1335
    return full_object_name(obj.__class__)
1✔
1336

1337

1338
def full_object_name(obj):
1✔
1339
    """
1340
    Returns the fully-qualified name the given obj, if it has a name, or None otherwise.
1341

1342
    For built-in classes, just the class name is returned.
1343
    For other objects, the name with the module name prepended (separated by a dot) is returned.
1344
    If the object has no __module__ or __name__ attribute, None is returned.
1345
    """
1346

1347
    try:
1✔
1348
        module = obj.__module__
1✔
1349
        if module is None or module == str.__class__.__module__:
1✔
1350
            return obj.__name__  # Avoid reporting __builtin__
1✔
1351
        else:
1352
            return module + '.' + obj.__name__
1✔
1353
    except Exception:
1✔
1354
        return None
1✔
1355

1356

1357
def constantly(value):
1✔
1358
    def fn(*args, **kwargs):
1✔
1359
        ignored(args, kwargs)
1✔
1360
        return value
1✔
1361
    return fn
1✔
1362

1363

1364
def identity(x):
1✔
1365
    """Returns its argument."""
1366
    return x
1✔
1367

1368

1369
def count_if(filter, seq):  # noQA - that's right, we're shadowing the built-in Python function 'filter'.
1✔
1370
    return sum(1 for x in seq if filter(x))
1✔
1371

1372

1373
def count(seq, filter=None):  # noQA - that's right, we're shadowing the built-in Python function 'filter'.
1✔
1374
    return count_if(filter or identity, seq)
1✔
1375

1376

1377
def find_associations(data, **kwargs):
1✔
1378
    found = []
1✔
1379
    for datum in data:
1✔
1380
        mismatch = False
1✔
1381
        for k, v in kwargs.items():
1✔
1382
            defaulted_val = datum.get(k)
1✔
1383
            if not (v(defaulted_val) if callable(v) else (v == defaulted_val)):
1✔
1384
                mismatch = True
1✔
1385
                break
1✔
1386
        if not mismatch:
1✔
1387
            found.append(datum)
1✔
1388
    return found
1✔
1389

1390

1391
def find_association(data, **kwargs):
1✔
1392
    results = find_associations(data, **kwargs)
1✔
1393
    n = len(results)
1✔
1394
    if n == 0:
1✔
1395
        return None
1✔
1396
    elif n == 1:
1✔
1397
        return results[0]
1✔
1398
    else:
1399
        raise ValueError("Got %s results when 1 was expected." % n)
1✔
1400

1401

1402
def keys_and_values_to_dict(keys_and_values: list, key_name: str = "Key", value_name: str = "Value") -> dict:
1✔
1403
    """
1404
    Transforms the given list of key/value objects, each containing a "Key" and "Value" property,
1405
    or alternately named via the key_name and/or value_name arguments, into a simple
1406
    dictionary of keys/values, and returns this value. For example, given this:
1407

1408
      [
1409
        { "Key": "env",
1410
          "Value": "prod"
1411
        },
1412
        { "Key": "aws:cloudformation:stack-name",
1413
          "Value": "c4-network-main-stack"
1414
        }
1415
      ]
1416

1417
    This function would return this:
1418

1419
      {
1420
        "env": "prod",
1421
        "aws:cloudformation:stack-name": "c4-network-main-stack"
1422
      }
1423

1424
    :param keys_and_values: List of key/value objects as described above.
1425
    :param key_name: Name of the given key property in the given list of key/value objects; default to "Key".
1426
    :param value_name: Name of the given value property in the given list of key/value objects; default to "Value".
1427
    :returns: Dictionary of keys/values from given list of key/value object as described above.
1428
    :raises ValueError: if item in list does not contain key or value name; or on duplicate key name in list.
1429
    """
1430
    result = {}
1✔
1431
    for item in keys_and_values:
1✔
1432
        if key_name not in item:
1✔
1433
            raise ValueError(f"Key {key_name} is not in {item}.")
1✔
1434
        if value_name not in item:
1✔
1435
            raise ValueError(f"Key {value_name} is not in {item}.")
1✔
1436
        if item[key_name] in result:
1✔
1437
            raise ValueError(f"Key {key_name} is duplicated in {keys_and_values}.")
1✔
1438
        result[item[key_name]] = item[value_name]
1✔
1439
    return result
1✔
1440

1441

1442
def dict_to_keys_and_values(dictionary: dict, key_name: str = "Key", value_name: str = "Value") -> list:
1✔
1443
    """
1444
    Transforms the keys/values in the given dictionary to a list of key/value objects, each containing
1445
    a "Key" and "Value" property, or alternately named via the key_name and/or value_name arguments,
1446
    and returns this value. For example, given this:
1447

1448
      {
1449
        "env": "prod",
1450
        "aws:cloudformation:stack-name": "c4-network-main-stack"
1451
      }
1452

1453
    This function would return this:
1454

1455
      [
1456
        { "Key": "env",
1457
          "Value": "prod"
1458
        },
1459
        { "Key": "aws:cloudformation:stack-name",
1460
          "Value": "c4-network-main-stack"
1461
        }
1462
      ]
1463

1464
    :param dictionary: Dictionary of keys/values described above.
1465
    :param key_name: Name of the given key property in the result list of key/value objects; default to "Key".
1466
    :param value_name: Name of the given value property in the result list of key/value objects; default to "Value".
1467
    :returns: List of key/value objects from the given dictionary as described above.
1468
    """
1469
    result = [{key_name: key, value_name: dictionary[key]} for key in dictionary]
1✔
1470
    return result
1✔
1471

1472

1473
def keyword_as_title(keyword):
1✔
1474
    """
1475
    Given a dictionary key or other token-like keyword, return a prettier form of it use as a display title.
1476

1477
    Underscores are replaced by spaces, but hyphens are not.
1478
    It is assumed that underscores are word-separators but a hyphenated word is still a hyphenated word.
1479

1480
    Examples:
1481

1482
        >>> keyword_as_title('foo')
1483
        'Foo'
1484
        >>> keyword_as_title('some_text')
1485
        'Some Text'
1486
        >>> keyword_as_title('mary_smith-jones')
1487
        'Mary Smith-Jones'
1488

1489
    :param keyword: a string to be used as a keyword, for example a dictionary key
1490
    :return: a string to be used in a title: text in title case with underscores replaced by spaces.
1491
    """
1492

1493
    return keyword.replace("_", " ").title()
1✔
1494

1495

1496
def file_contents(filename, binary=False):
1✔
1497
    with io.open(filename, 'rb' if binary else 'r') as fp:
1✔
1498
        return fp.read()
1✔
1499

1500

1501
def json_file_contents(filename):
1✔
1502
    with io.open(filename, 'r') as fp:
1✔
1503
        return json.load(fp)
1✔
1504

1505

1506
def load_json_if(s: str, is_array: bool = False, is_object: bool = False,
1✔
1507
                 fallback: Optional[Any] = None) -> Optional[Any]:
1508
    if (isinstance(s, str) and
1✔
1509
        ((is_object and s.startswith("{") and s.endswith("}")) or
1510
         (is_array and s.startswith("[") and s.endswith("]")))):
1511
        try:
1✔
1512
            return json.loads(s)
1✔
1513
        except Exception:
×
1514
            pass
×
1515
    return fallback
1✔
1516

1517

1518
def camel_case_to_snake_case(s, separator='_'):
1✔
1519
    """
1520
    Converts CamelCase to snake_case.
1521
    With a separator argument (default '_'), use that character instead for snake_case.
1522
    e.g., with separator='-', you'll get snake-case.
1523

1524
    :param s: a string to convert
1525
    :param separator: the snake-case separator character (default '_')
1526
    """
1527
    return ''.join(separator + c.lower() if c.isupper() else c for c in s).lstrip(separator)
1✔
1528

1529

1530
def snake_case_to_camel_case(s, separator='_'):
1✔
1531
    """
1532
    Converts snake_case to CamelCase. (Note that "our" CamelCase always capitalizes the first character.)
1533
    With a separator argument (default '_'), expect that character instead for snake_case.
1534
    e.g., with separator='-', you'll expect snake-case.
1535

1536
    :param s: a string to convert
1537
    :param separator: the snake-case separator character (default '_')
1538
    """
1539
    return s.title().replace(separator, '')
1✔
1540

1541

1542
def to_camel_case(s):
1✔
1543
    """
1544
    Converts a string that might be in snake_case or CamelCase into CamelCase.
1545
    """
1546
    hyphen_found = False
1✔
1547
    if '-' in s:
1✔
1548
        hyphen_found = True
1✔
1549
        s = s.replace('-', '_')
1✔
1550
    if not hyphen_found and s[:1].isupper() and '_' not in s:
1✔
1551
        return s
1✔
1552
    else:
1553
        return snake_case_to_camel_case(s)
1✔
1554

1555

1556
def to_snake_case(s):
1✔
1557
    """
1558
    Converts a string that might be in snake_case or CamelCase into CamelCase.
1559
    """
1560
    return camel_case_to_snake_case(to_camel_case(s))
1✔
1561

1562

1563
def capitalize1(s):
1✔
1564
    """
1565
    Capitalizes the first letter of a string and leaves the others alone.
1566
    This is in contrast to the string's .capitalize() method, which would force the rest of the string to lowercase.
1567
    """
1568
    return s[:1].upper() + s[1:]
1✔
1569

1570

1571
"""
1572
Python's UUID ignores all dashes, whereas Postgres is more strict
1573
http://www.postgresql.org/docs/9.2/static/datatype-uuid.html
1574
See also http://www.postgresql.org/docs/9.2/static/datatype-uuid.html
1575
And, anyway, this pattern is what our portals have been doing
1576
for quite a while, so it's the most stable choice for us now.
1577
"""
1578

1579
uuid_re = re.compile(r'(?i)[{]?(?:[0-9a-f]{4}-?){8}[}]?')
1✔
1580

1581

1582
def is_uuid(instance):
1✔
1583
    """
1584
    Predicate returns true for any group of 32 hex characters with optional hyphens every four characters.
1585
    We insist on lowercase to make matching faster. See other notes on this design choice above.
1586
    """
1587
    return bool(uuid_re.match(instance))
1✔
1588

1589

1590
def string_list(s):
1✔
1591
    """
1592
    Turns a comma-separated list into an actual list, trimming whitespace and ignoring nulls.
1593
        >>> string_list('foo, bar,,baz ')
1594
        ['foo', 'bar', 'baz']
1595
    """
1596

1597
    if not isinstance(s, str):
1✔
1598
        raise ValueError(f"Not a string: {s!r}")
1✔
1599
    return [p for p in [part.strip() for part in s.split(",")] if p]
1✔
1600

1601

1602
def split_string(value: str, delimiter: str, escape: Optional[str] = None, unique: bool = False) -> List[str]:
1✔
1603
    """
1604
    Splits the given string into an array of string based on the given delimiter, and an optional escape character.
1605
    If the given unique flag is True then duplicate values will not be included.
1606
    """
1607
    if not isinstance(value, str) or not (value := value.strip()):
1✔
1608
        return []
1✔
1609
    result = []
1✔
1610
    if not isinstance(escape, str) or not escape:
1✔
1611
        for item in value.split(delimiter):
1✔
1612
            if (item := item.strip()) and (unique is not True or item not in result):
1✔
1613
                result.append(item)
1✔
1614
        return result
1✔
1615
    item = r""
1✔
1616
    escaped = False
1✔
1617
    for c in value:
1✔
1618
        if c == delimiter and not escaped:
1✔
1619
            if (item := item.strip()) and (unique is not True or item not in result):
1✔
1620
                result.append(item)
1✔
1621
            item = r""
1✔
1622
        elif c == escape and not escaped:
1✔
1623
            escaped = True
1✔
1624
        else:
1625
            item += c
1✔
1626
            escaped = False
1✔
1627
    if (item := item.strip()) and (unique is not True or item not in result):
1✔
1628
        result.append(item)
1✔
1629
    return result
1✔
1630

1631

1632
def right_trim(list_or_tuple: Union[List[Any], Tuple[Any]],
1✔
1633
               remove: Optional[Callable] = None) -> Union[List[Any], Tuple[Any]]:
1634
    """
1635
    Removes training None (or emptry string) values from the give tuple or list arnd returns;
1636
    does NOT change the given value.
1637
    """
1638
    i = len(list_or_tuple) - 1
1✔
1639
    while i >= 0 and ((remove and remove(list_or_tuple[i])) or (not remove and list_or_tuple[i] in (None, ""))):
1✔
1640
        i -= 1
1✔
1641
    return list_or_tuple[:i + 1]
1✔
1642

1643

1644
def create_dict(**kwargs) -> dict:
1✔
1645
    result = {}
1✔
1646
    for name in kwargs:
1✔
1647
        if not (kwargs[name] is None):
1✔
1648
            result[name] = kwargs[name]
1✔
1649
    return result
1✔
1650

1651

1652
def create_readonly_object(**kwargs):
1✔
1653
    """
1654
    Returns a new/unique object instance with readonly properties equal to the give kwargs.
1655
    """
1656
    readonly_class_name = "readonlyclass_" + str(uuid.uuid4()).replace("-", "")
1✔
1657
    readonly_class_args = " ".join(kwargs.keys())
1✔
1658
    readonly_class = namedtuple(readonly_class_name, readonly_class_args)
1✔
1659
    readonly_object = readonly_class(**kwargs)
1✔
1660
    return readonly_object
1✔
1661

1662

1663
def is_c4_arn(arn: str) -> bool:
1✔
1664
    """
1665
    Returns True iff the given (presumed) AWS ARN string value looks like it
1666
    refers to a CGAP or Fourfront Cloudformation entity, otherwise returns False.
1667
    :param arn: String representing an AWS ARN.
1668
    :return: True if the given ARN refers to a CGAP or Fourfront Cloudformation entity, else False.
1669
    """
1670
    pattern = r"(fourfront|cgap|[:/]c4-)"
1✔
1671
    return True if re.search(pattern, arn) else False
1✔
1672

1673

1674
def string_md5(unicode_string):
1✔
1675
    """
1676
    Returns the md5 signature for the given u unicode string.
1677
    """
1678
    return hashlib.md5(unicode_string.encode('utf-8')).hexdigest()
1✔
1679

1680

1681
class CachedField:
1✔
1682
    def __init__(self, name, update_function, timeout=600):
1✔
1683
        """ Provides a named field that is cached for a certain period of time. The value is computed
1684
            on calls to __init__, after which the get() method should be used.
1685

1686
        :param name: name of property
1687
        :param update_function: lambda to be invoked to update the value
1688
        :param timeout: TTL of this field, in seconds
1689
        """
1690
        self.name = name
1✔
1691
        self._update_function = update_function
1✔
1692
        self.timeout = timeout
1✔
1693
        self.value = update_function()
1✔
1694
        self.time_of_next_update = datetime.datetime.utcnow() + datetime.timedelta(seconds=timeout)
1✔
1695

1696
    def _update_timestamp(self):
1✔
1697
        self.time_of_next_update = datetime.datetime.utcnow() + datetime.timedelta(seconds=self.timeout)
1✔
1698

1699
    def _update_value(self):
1✔
1700
        self.value = self._update_function()
1✔
1701
        self._update_timestamp()
1✔
1702

1703
    def get(self):
1✔
1704
        """ Intended for normal use - to get the value subject to the given TTL on creation. """
1705
        now = datetime.datetime.utcnow()
1✔
1706
        if now > self.time_of_next_update:
1✔
1707
            self._update_value()
1✔
1708
        return self.value
1✔
1709

1710
    def get_updated(self, push_ttl=False):
1✔
1711
        """ Intended to force an update to the value and potentially push back the timeout from now. """
1712
        self.value = self._update_function()
1✔
1713
        if push_ttl:
1✔
1714
            self.time_of_next_update = datetime.datetime.utcnow() + datetime.timedelta(seconds=self.timeout)
1✔
1715
        return self.value
1✔
1716

1717
    def set_timeout(self, new_timeout):
1✔
1718
        """ Sets a new value for timeout and restarts the timeout counter."""
1719
        self.timeout = new_timeout
1✔
1720
        self._update_timestamp()
1✔
1721

1722
    def __repr__(self):
1✔
1723
        return str(self)
1✔
1724

1725
    def __str__(self):
1✔
1726
        updater_name = self._update_function.__name__
1✔
1727
        return f"CachedField(name={self.name!r},update_function={updater_name},timeout={self.timeout!r})"
1✔
1728

1729

1730
class StorageCell:
1✔
1731

1732
    def __init__(self, initial_value=None):
1✔
1733
        self.value = initial_value
1✔
1734

1735

1736
def make_counter(start=0, step=1):
1✔
1737
    """
1738
    Creates a counter that generates values counting from a given start (default 0) by a given step (default 1).
1739
    """
1740
    storage = StorageCell(start)
1✔
1741

1742
    def counter():
1✔
1743
        old_value = storage.value
1✔
1744
        storage.value += step
1✔
1745
        return old_value
1✔
1746

1747
    return counter
1✔
1748

1749

1750
def copy_json(obj):
1✔
1751
    """ This function is taken and renamed from ENCODE's snovault quick_deepcopy
1752

1753
    Deep copy an object consisting of dicts, lists, and primitives.
1754
    This is faster than Python's `copy.deepcopy` because it doesn't
1755
    do bookkeeping to avoid duplicating objects in a cyclic graph.
1756
    This is intended to work fine for data deserialized from JSON,
1757
    but won't work for everything.
1758
    """
1759
    if isinstance(obj, dict):
1✔
1760
        obj = {k: copy_json(v) for k, v in obj.items()}
1✔
1761
    elif isinstance(obj, list):
1✔
1762
        obj = [copy_json(v) for v in obj]
1✔
1763
    return obj
1✔
1764

1765

1766
class UncustomizedInstance(Exception):
1✔
1767
    """
1768
    Reports a helpful error for access to a CustomizableProperty that has not been properly set.
1769
    """
1770

1771
    def __init__(self, instance, *, field):
1✔
1772
        self.instance = instance
1✔
1773
        self.field = field
1✔
1774
        declaration_class, declaration = self._find_field_declared_class(instance, field)
1✔
1775
        self.declaration_class = declaration_class
1✔
1776
        context = ""
1✔
1777
        if declaration_class == 'instance':
1✔
1778
            context = " from instance"
1✔
1779
        elif declaration_class:
1✔
1780
            context = " from class %s" % full_object_name(declaration_class)
1✔
1781
        message = ("Attempt to access field %s%s."
1✔
1782
                   " It was expected to be given a custom value in a subclass: %s."
1783
                   % (field, context, declaration.description))
1784
        super().__init__(message)
1✔
1785

1786
    @staticmethod
1✔
1787
    def _find_field_declared_class(instance, field):
1✔
1788
        instance_value = instance.__dict__.get(field)
1✔
1789
        is_class = isinstance(instance, type)
1✔
1790
        if instance_value:
1✔
1791
            return instance if is_class else 'instance', instance_value
1✔
1792
        else:
1793
            for cls in instance.__mro__ if is_class else instance.__class__.__mro__:
1✔
1794
                cls_value = cls.__dict__.get(field)
1✔
1795
                if cls_value:
1✔
1796
                    return cls, cls_value
1✔
1797
            raise RuntimeError("%s does not have a field %s." % (instance, field))
1✔
1798

1799

1800
class CustomizableProperty(property):
1✔
1801
    """
1802
    Declares a class variable to require customization. See help on getattr_customized for details.
1803
    """
1804

1805
    def __init__(self, field, *, description):
1✔
1806

1807
        self.field = field
1✔
1808
        self.description = description
1✔
1809

1810
        def uncustomized(instance):
1✔
1811
            raise UncustomizedInstance(instance=instance, field=field)
1✔
1812

1813
        super().__init__(uncustomized)
1✔
1814

1815
    def __str__(self):
1✔
1816
        return "<%s %s>" % (self.__class__.__name__, self.field)
1✔
1817

1818

1819
def getattr_customized(thing, key):
1✔
1820
    """
1821
    Like getattr, but if the value is a CustomizableProperty, gives a helpful error explaining need to customize.
1822

1823
    This avoids inscrutible errors or even dangerous confusions that happen when an abstract class requires setting
1824
    of variables that the user of the class might forget to set or not realize they're supposed to set.
1825

1826
    So, for example, one might write:
1827

1828
    class AbstractFileClass:
1829
        ALLOW_SOFT_DELETE = CustomizableProperty('ALLOW_SOFT_DELETE',
1830
                                                 description='a boolean saying whether soft delete is allowed')
1831
        def delete(self, file):
1832
            if getattr_customized(cls, 'ALLOW_SOFT_DELETE'):
1833
                self.soft_delete(file)
1834
            else:
1835
                self.hard_delete(file)
1836

1837
    Note that there may not be a reasonable default for ALLOW_SOFT_DELETE. Any value would be taken as a boolean.
1838
    It would be possible to leave the variable unset, but then linters would complain about referring to it.
1839
    And it would be confusing if accessed without setting it.
1840
    """
1841
    # This will raise an error if the attribute is a CustomizableProperty living in the class part of the dict,
1842
    # but will return the object if it's in the instance.
1843
    value = getattr(thing, key)
1✔
1844
    if isinstance(value, CustomizableProperty):
1✔
1845
        # This is an uncustomized instance variable, not a class variable.
1846
        # That's not an intended use case, but just report it without involving mention of the class.
1847
        raise UncustomizedInstance(instance=thing, field=key)
1✔
1848
    else:
1849
        return value
1✔
1850

1851

1852
def url_path_join(*fragments):
1✔
1853
    """
1854
    Concatenates its arguments, returning a string with exactly one slash ('/') separating each of the path fragments.
1855

1856
    So, whether the path_fragments are ('foo', 'bar') or ('foo/', 'bar') or ('foo', '/bar') or ('foo/', '/bar')
1857
    or even ('foo//', '///bar'), the result will be 'foo/bar'. The left side of the first thing and the
1858
    right side of the last thing are unaffected.
1859

1860
    :param fragments: a list of URL path fragments
1861
    :return: a slash-separated concatentation of the given path fragments
1862
    """
1863
    fragments = fragments or ("",)
1✔
1864
    result = fragments[0]  # Tolerate an empty list
1✔
1865
    for thing in fragments[1:]:
1✔
1866
        result = result.rstrip("/") + "/" + thing.lstrip("/")
1✔
1867
    return result
1✔
1868

1869

1870
def _is_function_of_exactly_one_required_arg(x):
1✔
1871
    if not callable(x):
1✔
1872
        return False
1✔
1873
    argspec = inspect.getfullargspec(x)
1✔
1874
    return len(argspec.args) == 1 and not argspec.varargs and not argspec.defaults and not argspec.kwonlyargs
1✔
1875

1876

1877
def _apply_decorator(fn, *args, **kwargs):
1✔
1878
    """
1879
    This implements a fix to the decorator syntax where it gets fussy about whether @foo and @foo() are synonyms.
1880
    The price to be paid is you can't use it for decorators that take positional arguments.
1881
    """
1882
    if args and (kwargs or len(args) > 1):
1✔
1883
        # Case 1
1884
        # If both args and kwargs are in play, they have to have been passed explicitly like @foo(a1, k2=v2).
1885
        # If more than one positional is given, that has to be something like @foo(a1, a2, ...)
1886
        # Decorators using this function need to agree to only accept keyword arguments, so those cases can't happen.
1887
        # They can do this by using an optional first positional argument, as in 'def foo(x=3):',
1888
        # or they can do it by using a * as in 'def foo(*, x)' or if no arguments are desired, obviously, 'def foo():'.
1889
        raise SyntaxError("Positional arguments to decorator (@%s) not allowed here." % fn.__name__)
1✔
1890
    elif args:  # i.e., there is 1 positional arg (an no keys)
1✔
1891
        # Case 2
1892
        arg0 = args[0]  # At this point, we know there is a single positional argument.
1✔
1893
        #
1894
        # Here there are two cases.
1895
        #
1896
        # (a) The user may have done @foo, in which case we will have a fn which is the value of foo,
1897
        #     but not the result of applying it.
1898
        #
1899
        # (b) Otherwise, the user has done @foo(), in which case what we'll have the function of one
1900
        #     argument that does the wrapping of the subsequent function or class.
1901
        #
1902
        # So since case (a) expects fn to be a function that tolerates zero arguments
1903
        # while case (b) expects fn to be a function that rejects positional arguments,
1904
        # we can call fn with the positional argument, arg0. If that argument is rejected with a TypeError,
1905
        # we know that it's really case (a) and that we need to call fn once with no arguments
1906
        # before retrying on arg0.
1907
        if _is_function_of_exactly_one_required_arg(fn):
1✔
1908
            # Case 2A
1909
            # We are ready to wrap the function or class in arg0
1910
            return fn(arg0)
1✔
1911
        else:
1912
            # Case 2B
1913
            # We are ALMOST ready to wrap the function or class in arg0,
1914
            # but first we have to call ourselves with no arguments as in case (a) described above.
1915
            return fn()(arg0)
1✔
1916
    else:
1917
        # Case 3
1918
        # Here we have kwargs = {...} from @foo(x=3, y=4, ...) or maybe no kwargs either @foo().
1919
        # Either way, we've already evaluated the foo(...) call, so all that remains is to call on our kwargs.
1920
        # (There are no args to call it on because we tested that above.)
1921
        return fn(**kwargs)
1✔
1922

1923

1924
def _decorator(decorator_function):
1✔
1925
    """See documentation for decorator."""
1926
    @functools.wraps(decorator_function)
1✔
1927
    def _wrap_decorator(*args, **kwargs):
1✔
1928
        return _apply_decorator(decorator_function, *args, **kwargs)
1✔
1929
    return _wrap_decorator
1✔
1930

1931

1932
@_decorator
1✔
1933
def decorator():
1✔
1934
    """
1935
    This defines a decorator, such that is can be used as either @foo or @foo()
1936
    PROVIDED THAT the function doing the decorating is not a function a single required argument,
1937
    since that would create an ambiguity that would inhibit the auto-correction this will do.
1938

1939
    @decorator
1940
    def foo(...):
1941
        ...
1942
    """
1943
    return _decorator
1✔
1944

1945

1946
def dict_zip(dict1, dict2):
1✔
1947
    """
1948
    This is like the zip operator that zips two lists, but it takes two dictionaries and pairs matching elements.
1949
    e.g.,
1950

1951
        >>> dict_zip({'a': 'one', 'b': 'two'}, {'a': 1, 'b': 2})
1952
        [('one', 1), ('two', 2)]
1953

1954
    In Python 3.6+, the order of the result list is the same as the order of the keys in the first dict.
1955
    If the two dictionaries do not have exactly the same set of keys, an error will be raised.
1956
    """
1957
    res = []
1✔
1958
    for key1 in dict1:
1✔
1959
        if key1 not in dict2:
1✔
1960
            raise ValueError(f"Key {key1!r} is in dict1, but not dict2."
1✔
1961
                             f" dict1.keys()={list(dict1.keys())}"
1962
                             f" dict2.keys()={list(dict2.keys())}")
1963

1964
        res.append((dict1[key1], dict2[key1]))
1✔
1965
    for key2 in dict2:
1✔
1966
        if key2 not in dict1:
1✔
1967
            raise ValueError(f"Key {key2!r} is in dict1, but not dict2."
1✔
1968
                             f" dict1.keys()={list(dict1.keys())}"
1969
                             f" dict2.keys()={list(dict2.keys())}")
1970
    return res
1✔
1971

1972

1973
def json_leaf_subst(exp, substitutions):
1✔
1974
    """
1975
    Given an expression and some substitutions, substitutes all occurrences of the given substitutions.
1976
    For example:
1977

1978
    >>> json_leaf_subst({'xxx': ['foo', 'bar', 'baz']}, {'foo': 'fu', 'bar': 'bah', 'xxx': 'yyy'})
1979
    {'yyy': ['fu', 'bah', 'baz']}
1980

1981
    :param exp: a JSON expression, represented in Python as a string, a number, a list, or a dict
1982
    :param substitutions: a dictionary of replacements from keys to values
1983
    """
1984
    def do_subst(e):
1✔
1985
        return json_leaf_subst(e, substitutions)
1✔
1986
    if isinstance(exp, dict):
1✔
1987
        return {do_subst(k): do_subst(v) for k, v in exp.items()}
1✔
1988
    elif isinstance(exp, list):
1✔
1989
        return [do_subst(e) for e in exp]
1✔
1990
    elif exp in substitutions:  # Something atomic like a string or number
1✔
1991
        return substitutions[exp]
1✔
1992
    return exp
1✔
1993

1994

1995
class SingletonManager:
1✔
1996

1997
    def __init__(self, singleton_class, *singleton_args, **singleton_kwargs):
1✔
1998
        self._singleton = None
1✔
1999
        self._singleton_class = singleton_class
1✔
2000
        self._singleton_args = singleton_args
1✔
2001
        self._singleton_kwargs = singleton_kwargs
1✔
2002

2003
    @property
1✔
2004
    def singleton(self):
1✔
2005
        if not self._singleton:
1✔
2006
            self._singleton = self._singleton_class(*self._singleton_args or (), **self._singleton_kwargs or {})
1✔
2007
        return self._singleton
1✔
2008

2009
    @property
1✔
2010
    def singleton_class(self):
1✔
2011
        return self._singleton_class
1✔
2012

2013

2014
class classproperty(object):
1✔
2015
    """
2016
    This decorator is like 'classproperty', but the function is run only on first use, not every time, and then cached.
2017

2018
    Example:
2019

2020
        import time
2021
        class Clock:
2022
            @classproperty
2023
            def sample():
2024
                return time.time()
2025

2026
        # Different results each time, just like an instance method, but without having to instantiate the class.
2027
        Clock.sample
2028
        1665600812.008385
2029
        Clock.sample
2030
        1665600812.760394
2031
    """
2032

2033
    def __init__(self, getter):
1✔
2034
        self.getter = getter
1✔
2035

2036
    def __get__(self, instance, instance_class):
1✔
2037
        ignored(instance)
1✔
2038
        return self.getter(instance_class)
1✔
2039

2040

2041
class managed_property(object):
1✔
2042
    """
2043
    Sample use:
2044

2045
        class Temperature:
2046

2047
            def __init__(self, fahrenheit=32):
2048
                self.fahrenheit = fahrenheit
2049

2050
            @managed_property
2051
            def centigrade(self, degrees):
2052
                if degrees == managed_property.MISSING:
2053
                    return (self.fahrenheit - 32) * 5 / 9.0
2054
                else:
2055
                    self.fahrenheit = degrees * 9 / 5.0 + 32
2056

2057
        t1 = Temperature()
2058
        assert t1.fahrenheit == 32
2059
        assert t1.centigrade == 0.0
2060

2061
        t2 = Temperature(fahrenheit=68)
2062
        assert t2.fahrenheit == 68.0
2063
        assert t2.centigrade == 20.0
2064
        t2.centigrade = 5
2065
        assert t2.centigrade == 5.0
2066
        assert t2.fahrenheit == 41.0
2067

2068
        t2.fahrenheit = -40
2069
        assert t2.centigrade == -40.0
2070

2071
    """
2072

2073
    MISSING = NamedObject("missing")
1✔
2074

2075
    def __init__(self, handler):
1✔
2076

2077
        self.handler = handler
1✔
2078

2079
    def __get__(self, instance, instance_class):
1✔
2080
        ignored(instance_class)
1✔
2081
        return self.handler(instance, self.MISSING)
1✔
2082

2083
    def __set__(self, instance, value):
1✔
2084
        self.handler(instance, value)
1✔
2085

2086

2087
class classproperty_cached(object):
1✔
2088
    """
2089
    This decorator is like 'classproperty', but the function is run only on first use, not every time, and then cached.
2090

2091
    Such a property returns the same value each time, just like any class property,
2092
    but initialization is delayed until first use and determined by a call to the decorated function.
2093

2094
    Example:
2095

2096
        import time
2097
        class Freeze:
2098
            @classproperty_cached
2099
            def sample():
2100
                return time.time()
2101

2102
        Freeze.sample
2103
        1665600374.4801269
2104
        Freeze.sample
2105
        1665600374.4801269
2106

2107
        class SubFreeze(Freeze):
2108
            pass
2109

2110
        SubFreeze.sample
2111
        1665600540.1467211
2112
        SubFreeze.sample
2113
        1665600540.1467211
2114

2115
        Freeze.sample
2116
        1665600374.4801269
2117

2118
        SubFreeze.sample
2119
        1665600540.1467211
2120
    """
2121

2122
    ATTRIBUTE_CACHE_MAP = defaultdict(lambda: {})
1✔
2123

2124
    _USES_PER_SUBCLASS_CACHES = False
1✔
2125

2126
    def __init__(self, initializer):
1✔
2127
        self.initializer = initializer
1✔
2128
        self.name = initializer.__name__
1✔
2129
        self.attribute_cache = {}
1✔
2130

2131
    def __get__(self, instance, instance_class):
1✔
2132
        ignored(instance)
1✔
2133
        reference_class = self._find_reference_class(instance_class, self.name)
1✔
2134
        if reference_class not in self.attribute_cache:  # If there is no exact class matching, init it.
1✔
2135
            initial_value = self.initializer(reference_class)
1✔
2136
            self.attribute_cache[reference_class] = initial_value
1✔
2137
        return self.attribute_cache[reference_class]
1✔
2138

2139
    @classmethod
1✔
2140
    def _find_reference_class(cls, instance_class, attribute_name):
1✔
2141
        if cls._USES_PER_SUBCLASS_CACHES:
1✔
2142
            return instance_class
1✔
2143
        else:
2144
            return cls._find_cache_class(instance_class, attribute_name)
1✔
2145

2146
    @classmethod
1✔
2147
    def _find_cache_class_and_attribute(cls, instance_class, attribute_name):
1✔
2148
        for superclass in instance_class.__mro__:
1✔
2149
            superclass_attributes = superclass.__dict__
1✔
2150
            if attribute_name in superclass_attributes:
1✔
2151
                attribute_value = superclass_attributes[attribute_name]
1✔
2152
                if isinstance(attribute_value, cls):
1✔
2153
                    return superclass, attribute_value
1✔
2154
                raise ValueError(f"The slot {instance_class.__name__}.{attribute_name}"
1✔
2155
                                 f" does not contain a cached value.")
2156
        raise ValueError(f"The slot {instance_class.__name__}.{attribute_name} is not defined.")
1✔
2157

2158
    @classmethod
1✔
2159
    def _find_cache_class(cls, instance_class, attribute_name):
1✔
2160
        return cls._find_cache_class_and_attribute(instance_class, attribute_name)[0]
1✔
2161

2162
    @classmethod
1✔
2163
    def _find_cache_attribute(cls, instance_class, attribute_name):
1✔
2164
        return cls._find_cache_class_and_attribute(instance_class, attribute_name)[1]
1✔
2165

2166
    @classmethod
1✔
2167
    def _find_cache_map(cls, instance_class, attribute_name) -> dict:
1✔
2168
        attribute = cls._find_cache_attribute(instance_class, attribute_name)
1✔
2169
        return attribute.attribute_cache
1✔
2170

2171
    @classmethod
1✔
2172
    def reset(cls, *, instance_class, attribute_name, subclasses=True):
1✔
2173
        """
2174
        Clears the cache for the given attribute name on the given instance_class.
2175

2176
        Having done:
2177

2178
            import random
2179
            class Foo:
2180
                @classproperty_cached
2181
                def something(cls):
2182
                    return random.randint(100)
2183

2184
        one can clear the cache by doing:
2185

2186
            classproperty_cached.reset(instance_class=Foo, attribute_name='something')
2187

2188
        :param instance_class: a class with an attribute whose value is managed by '@classproperty_cached'.
2189
        :param attribute_name: the name of the attribute that has a cached value.
2190
        :param subclasses: a bool indicating whether the cache reset requests applies to
2191
             all subclasses (subclasses=True) or only the exact class given (subclasses=False).
2192
             Using subclasses=False is not allowed if CACHE_EACH_SUBCLASS is False.
2193
        """
2194

2195
        if not cls._USES_PER_SUBCLASS_CACHES and not subclasses:
1✔
2196
            raise ValueError(f"The subclasses= argument to {cls.__name__}.reset must not be False"
1✔
2197
                             f" because {cls.__name__} does not use per-subclass caches.")
2198
        if not isinstance(instance_class, type):
1✔
2199
            raise ValueError(f"The instance_class= argument to {cls.__name__}.reset must be a class.")
1✔
2200
        reference_class = cls._find_reference_class(instance_class, attribute_name)
1✔
2201
        attribute_cache = cls._find_cache_map(reference_class, attribute_name)
1✔
2202
        keys_to_remove = []
1✔
2203
        predicate = issubclass if subclasses else equals
1✔
2204
        for cache_class, cache_value in attribute_cache.items():
1✔
2205
            if predicate(cache_class, reference_class):
1✔
2206
                keys_to_remove.append(cache_class)
1✔
2207
        for cache_class in keys_to_remove:
1✔
2208
            del attribute_cache[cache_class]
1✔
2209
        return bool(keys_to_remove)
1✔
2210

2211

2212
class classproperty_cached_each_subclass(classproperty_cached):
1✔
2213
    """
2214
    This decorator is like 'classproperty_cached', but a separate cache is maintained per-subclass.
2215

2216
    Such a property returns the same value each time, just like any class property,
2217
    but initialization is delayed until first use and determined by a call to the decorated function.
2218

2219
    Example:
2220

2221
        import time
2222
        class Freeze:
2223
            @classproperty_cached
2224
            def sample():
2225
                return time.time()
2226

2227
        Freeze.sample
2228
        1665600374.4801269
2229
        Freeze.sample
2230
        1665600374.4801269
2231

2232
        class SubFreeze(Freeze):
2233
            pass
2234

2235
        SubFreeze.sample
2236
        1665600540.1467211
2237
        SubFreeze.sample
2238
        1665600540.1467211
2239

2240
        Freeze.sample
2241
        1665600374.4801269
2242

2243
        SubFreeze.sample
2244
        1665600540.1467211
2245
    """
2246

2247
    _USES_PER_SUBCLASS_CACHES = True
1✔
2248

2249

2250
def equals(x, y):
1✔
2251
    """
2252
    A functional form of the '==' equality predicate so that it can be handled as a functional value.
2253
    """
2254
    return x == y
1✔
2255

2256

2257
class Singleton:
1✔
2258
    """
2259
    A class witn a cached class property 'singleton' that holds an instance of the class (created with no arguments).
2260

2261
    The .singleton instance is created on demand (so will not be created at all if .singleton is never accessed).
2262

2263
    Example:
2264

2265
        class Foo(Singleton):
2266
            pass
2267

2268
        # Regular instantiation of the class works like normal, giving a new class each time.
2269
        Foo()
2270
        <__main__.Foo object at 0x10e8aed90>
2271
        Foo()
2272
        <__main__.Foo object at 0x10e8b0150>
2273

2274
        # The .singleton property gives the same instance every time.
2275
        Foo.singleton
2276
        <__main__.Foo object at 0x10e8aefd0>
2277
        Foo.singleton
2278
        <__main__.Foo object at 0x10e8aefd0>
2279
    """
2280

2281
    @classproperty_cached_each_subclass
1✔
2282
    def singleton(cls):  # noQA - PyCharm wrongly thinks the argname should be 'self'
1✔
2283
        return cls()     # noQA - PyCharm flags a bogus warning for this
1✔
2284

2285

2286
def key_value_dict(key, value):
1✔
2287
    """
2288
    Given a key k and a value v, returns the dictionary {"Key": k, "Value": v}, which is a format AWS is fond of.
2289
    """
2290
    return {'Key': key, 'Value': value}
1✔
2291

2292

2293
def merge_key_value_dict_lists(x, y):
1✔
2294
    """
2295
    Merges two lists of the form [{"Key": k1, "Value": v1}, {"Key": k2, "Value": v2}].
2296
    It is assumed that neither list has repeated keys, and that the resulting list also should not.
2297
    Entries in the resulting list will be in the same order as the original two lists except when both
2298
    lists contain entries that share a key. In that case, the two entries will be merged
2299
    into a single entry with the position of the first such entry and value of the second.
2300
    """
2301
    merged = {}
1✔
2302
    for pair in x:
1✔
2303
        merged[pair['Key']] = pair['Value']
1✔
2304
    for pair in y:
1✔
2305
        merged[pair['Key']] = pair['Value']
1✔
2306
    return [key_value_dict(k, v) for k, v in merged.items()]
1✔
2307

2308

2309
def merge_objects(target: Union[dict, List[Any]], source: Union[dict, List[Any]],
1✔
2310
                  full: bool = False,  # deprecated
2311
                  expand_lists: Optional[bool] = None,
2312
                  primitive_lists: bool = False,
2313
                  copy: bool = False, _recursing: bool = False) -> Union[dict, List[Any]]:
2314
    """
2315
    Merges the given source dictionary or list into the target dictionary or list and returns the
2316
    result. This MAY well change the given target (dictionary or list) IN PLACE ... UNLESS the copy
2317
    argument is True, then the given target will not change as a local copy is made (and returned).
2318

2319
    If the expand_lists argument is True then any target lists longer than the
2320
    source be will be filled out with the last element(s) of the source; the full
2321
    argument (is deprecated and) is a synomym for this. The default is False.
2322

2323
    If the primitive_lists argument is True then lists of primitives (i.e. lists in which
2324
    NONE of its elements are dictionaries, lists, or tuples) will themselves be treated
2325
    like primitives, meaning the whole of a source list will replace the corresponding
2326
    target; otherwise they  will be merged normally, meaning each element of a source list
2327
    will be merged, recursively, into the corresponding target list. The default is False.
2328
    """
2329
    def is_primitive_list(value: Any) -> bool:  # noqa
1✔
2330
        if not isinstance(value, list):
1✔
2331
            return False
×
2332
        for item in value:
1✔
2333
            if isinstance(item, (dict, list, tuple)):
1✔
2334
                return False
×
2335
        return True
1✔
2336

2337
    if target is None:
1✔
2338
        return source
1✔
2339
    if expand_lists not in (True, False):
1✔
2340
        expand_lists = full is True
1✔
2341
    if (copy is True) and (_recursing is not True):
1✔
2342
        target = deepcopy(target)
1✔
2343
    if isinstance(target, dict) and isinstance(source, dict) and source:
1✔
2344
        for key, value in source.items():
1✔
2345
            if ((primitive_lists is True) and
1✔
2346
                (key in target) and is_primitive_list(target[key]) and is_primitive_list(value)):  # noqa
2347
                target[key] = value
1✔
2348
            else:
2349
                target[key] = merge_objects(target[key], value,
1✔
2350
                                            expand_lists=expand_lists, _recursing=True) if key in target else value
2351
    elif isinstance(target, list) and isinstance(source, list) and source:
1✔
2352
        for i in range(max(len(source), len(target))):
1✔
2353
            if i < len(target):
1✔
2354
                if i < len(source):
1✔
2355
                    target[i] = merge_objects(target[i], source[i], expand_lists=expand_lists, _recursing=True)
1✔
2356
                elif expand_lists is True:
1✔
2357
                    target[i] = merge_objects(target[i], source[len(source) - 1], expand_lists=expand_lists)
1✔
2358
            else:
2359
                target.append(source[i])
1✔
2360
    elif source not in (None, {}, []):
1✔
2361
        target = source
1✔
2362
    return target
1✔
2363

2364

2365
def load_json_from_file_expanding_environment_variables(file: str) -> Union[dict, list]:
1✔
2366
    def expand_envronment_variables(data):  # noqa
1✔
2367
        if isinstance(data, dict):
1✔
2368
            return {key: expand_envronment_variables(value) for key, value in data.items()}
1✔
2369
        if isinstance(data, list):
1✔
2370
            return [expand_envronment_variables(element) for element in data]
×
2371
        if isinstance(data, str):
1✔
2372
            return re.sub(r"\$\{([^}]+)\}|\$([a-zA-Z_][a-zA-Z0-9_]*)",
1✔
2373
                          lambda match: os.environ.get(match.group(1) or match.group(2), match.group(0)), data)
2374
        return data
×
2375
    with open(file, "r") as file:
1✔
2376
        return expand_envronment_variables(json.load(file))
1✔
2377

2378

2379
# Stealing topological sort classes below from python's graphlib module introduced
2380
# in v3.9 with minor refactoring.
2381
# Source: https://github.com/python/cpython/blob/3.11/Lib/graphlib.py
2382
# Docs: https://docs.python.org/3.11/library/graphlib.html
2383
# TODO: Remove once python version >= 3.9
2384

2385

2386
class _NodeInfo:
1✔
2387
    __slots__ = "node", "npredecessors", "successors"
1✔
2388

2389
    def __init__(self, node):
1✔
2390
        # The node this class is augmenting.
2391
        self.node = node
1✔
2392

2393
        # Number of predecessors, generally >= 0. When this value falls to 0,
2394
        # and is returned by get_ready(), this is set to _NODE_OUT and when the
2395
        # node is marked done by a call to done(), set to _NODE_DONE.
2396
        self.npredecessors = 0
1✔
2397

2398
        # List of successor nodes. The list can contain duplicated elements as
2399
        # long as they're all reflected in the successor's npredecessors attribute.
2400
        self.successors = []
1✔
2401

2402

2403
class CycleError(ValueError):
1✔
2404
    """Subclass of ValueError raised by TopologicalSorter.prepare if cycles
2405
    exist in the working graph.
2406
    If multiple cycles exist, only one undefined choice among them will be reported
2407
    and included in the exception. The detected cycle can be accessed via the second
2408
    element in the *args* attribute of the exception instance and consists in a list
2409
    of nodes, such that each node is, in the graph, an immediate predecessor of the
2410
    next node in the list. In the reported list, the first and the last node will be
2411
    the same, to make it clear that it is cyclic.
2412
    """
2413
    pass
1✔
2414

2415

2416
class TopologicalSorter:
1✔
2417
    """Provides functionality to topologically sort a graph of hashable nodes"""
2418

2419
    _NODE_OUT = -1
1✔
2420
    _NODE_DONE = -2
1✔
2421

2422
    def __init__(self, graph=None):
1✔
2423
        self._node2info = {}
1✔
2424
        self._ready_nodes = None
1✔
2425
        self._npassedout = 0
1✔
2426
        self._nfinished = 0
1✔
2427

2428
        if graph is not None:
1✔
2429
            for node, predecessors in graph.items():
1✔
2430
                self.add(node, *predecessors)
1✔
2431

2432
    def _get_nodeinfo(self, node):
1✔
2433
        result = self._node2info.get(node)
1✔
2434
        if result is None:
1✔
2435
            self._node2info[node] = result = _NodeInfo(node)
1✔
2436
        return result
1✔
2437

2438
    def add(self, node, *predecessors):
1✔
2439
        """Add a new node and its predecessors to the graph.
2440
        Both the *node* and all elements in *predecessors* must be hashable.
2441
        If called multiple times with the same node argument, the set of dependencies
2442
        will be the union of all dependencies passed in.
2443
        It is possible to add a node with no dependencies (*predecessors* is not provided)
2444
        as well as provide a dependency twice. If a node that has not been provided before
2445
        is included among *predecessors* it will be automatically added to the graph with
2446
        no predecessors of its own.
2447
        Raises ValueError if called after "prepare".
2448
        """
2449
        if self._ready_nodes is not None:
1✔
2450
            raise ValueError("Nodes cannot be added after a call to prepare()")
×
2451

2452
        # Create the node -> predecessor edges
2453
        nodeinfo = self._get_nodeinfo(node)
1✔
2454
        nodeinfo.npredecessors += len(predecessors)
1✔
2455

2456
        # Create the predecessor -> node edges
2457
        for pred in predecessors:
1✔
2458
            pred_info = self._get_nodeinfo(pred)
1✔
2459
            pred_info.successors.append(node)
1✔
2460

2461
    def prepare(self):
1✔
2462
        """Mark the graph as finished and check for cycles in the graph.
2463
        If any cycle is detected, "CycleError" will be raised, but "get_ready" can
2464
        still be used to obtain as many nodes as possible until cycles block more
2465
        progress. After a call to this function, the graph cannot be modified and
2466
        therefore no more nodes can be added using "add".
2467
        """
2468
        if self._ready_nodes is not None:
1✔
2469
            raise ValueError("cannot prepare() more than once")
×
2470

2471
        self._ready_nodes = [
1✔
2472
            i.node for i in self._node2info.values() if i.npredecessors == 0
2473
        ]
2474
        # ready_nodes is set before we look for cycles on purpose:
2475
        # if the user wants to catch the CycleError, that's fine,
2476
        # they can continue using the instance to grab as many
2477
        # nodes as possible before cycles block more progress
2478
        cycle = self._find_cycle()
1✔
2479
        if cycle:
1✔
2480
            raise CycleError(f"nodes are in a cycle", cycle)
1✔
2481

2482
    def get_ready(self):
1✔
2483
        """Return a tuple of all the nodes that are ready.
2484
        Initially it returns all nodes with no predecessors; once those are marked
2485
        as processed by calling "done", further calls will return all new nodes that
2486
        have all their predecessors already processed. Once no more progress can be made,
2487
        empty tuples are returned.
2488
        Raises ValueError if called without calling "prepare" previously.
2489
        """
2490
        if self._ready_nodes is None:
1✔
2491
            raise ValueError("prepare() must be called first")
×
2492

2493
        # Get the nodes that are ready and mark them
2494
        result = tuple(self._ready_nodes)
1✔
2495
        n2i = self._node2info
1✔
2496
        for node in result:
1✔
2497
            n2i[node].npredecessors = self._NODE_OUT
1✔
2498

2499
        # Clean the list of nodes that are ready and update
2500
        # the counter of nodes that we have returned.
2501
        self._ready_nodes.clear()
1✔
2502
        self._npassedout += len(result)
1✔
2503

2504
        return result
1✔
2505

2506
    def is_active(self):
1✔
2507
        """Return ``True`` if more progress can be made and ``False`` otherwise.
2508
        Progress can be made if cycles do not block the resolution and either there
2509
        are still nodes ready that haven't yet been returned by "get_ready" or the
2510
        number of nodes marked "done" is less than the number that have been returned
2511
        by "get_ready".
2512
        Raises ValueError if called without calling "prepare" previously.
2513
        """
2514
        if self._ready_nodes is None:
1✔
2515
            raise ValueError("prepare() must be called first")
×
2516
        return self._nfinished < self._npassedout or bool(self._ready_nodes)
1✔
2517

2518
    def __bool__(self):
1✔
2519
        return self.is_active()
×
2520

2521
    def done(self, *nodes):
1✔
2522
        """Marks a set of nodes returned by "get_ready" as processed.
2523
        This method unblocks any successor of each node in *nodes* for being returned
2524
        in the future by a call to "get_ready".
2525
        Raises :exec:`ValueError` if any node in *nodes* has already been marked as
2526
        processed by a previous call to this method, if a node was not added to the
2527
        graph by using "add" or if called without calling "prepare" previously or if
2528
        node has not yet been returned by "get_ready".
2529
        """
2530

2531
        if self._ready_nodes is None:
1✔
2532
            raise ValueError("prepare() must be called first")
×
2533

2534
        n2i = self._node2info
1✔
2535

2536
        for node in nodes:
1✔
2537

2538
            # Check if we know about this node (it was added previously using add()
2539
            nodeinfo = n2i.get(node)
1✔
2540
            if nodeinfo is None:
1✔
2541
                raise ValueError(f"node {node!r} was not added using add()")
×
2542

2543
            # If the node has not being returned (marked as ready) previously, inform the user.
2544
            stat = nodeinfo.npredecessors
1✔
2545
            if stat != self._NODE_OUT:
1✔
2546
                if stat >= 0:
×
2547
                    raise ValueError(
×
2548
                        f"node {node!r} was not passed out (still not ready)"
2549
                    )
2550
                elif stat == self._NODE_DONE:
×
2551
                    raise ValueError(f"node {node!r} was already marked done")
×
2552
                else:
2553
                    assert False, f"node {node!r}: unknown status {stat}"
×
2554

2555
            # Mark the node as processed
2556
            nodeinfo.npredecessors = self._NODE_DONE
1✔
2557

2558
            # Go to all the successors and reduce the number of predecessors, collecting all the ones
2559
            # that are ready to be returned in the next get_ready() call.
2560
            for successor in nodeinfo.successors:
1✔
2561
                successor_info = n2i[successor]
1✔
2562
                successor_info.npredecessors -= 1
1✔
2563
                if successor_info.npredecessors == 0:
1✔
2564
                    self._ready_nodes.append(successor)
1✔
2565
            self._nfinished += 1
1✔
2566

2567
    def _find_cycle(self):
1✔
2568
        n2i = self._node2info
1✔
2569
        stack = []
1✔
2570
        itstack = []
1✔
2571
        seen = set()
1✔
2572
        node2stacki = {}
1✔
2573

2574
        for node in n2i:
1✔
2575
            if node in seen:
1✔
2576
                continue
×
2577

2578
            while True:
2579
                if node in seen:
1✔
2580
                    # If we have seen already the node and is in the
2581
                    # current stack we have found a cycle.
2582
                    if node in node2stacki:
1✔
2583
                        return stack[node2stacki[node]:] + [node]
1✔
2584
                    # else go on to get next successor
2585
                else:
2586
                    seen.add(node)
1✔
2587
                    itstack.append(iter(n2i[node].successors).__next__)
1✔
2588
                    node2stacki[node] = len(stack)
1✔
2589
                    stack.append(node)
1✔
2590

2591
                # Backtrack to the topmost stack entry with
2592
                # at least another successor.
2593
                while stack:
1✔
2594
                    try:
1✔
2595
                        node = itstack[-1]()
1✔
2596
                        break
1✔
2597
                    except StopIteration:
1✔
2598
                        del node2stacki[stack.pop()]
1✔
2599
                        itstack.pop()
1✔
2600
                else:
2601
                    break
×
2602
        return None
1✔
2603

2604
    def static_order(self):
1✔
2605
        """Returns an iterable of nodes in a topological order.
2606
        The particular order that is returned may depend on the specific
2607
        order in which the items were inserted in the graph.
2608
        Using this method does not require to call "prepare" or "done". If any
2609
        cycle is detected, :exc:`CycleError` will be raised.
2610
        """
2611
        self.prepare()
1✔
2612
        while self.is_active():
1✔
2613
            node_group = self.get_ready()
1✔
2614
            yield from node_group
1✔
2615
            self.done(*node_group)
1✔
2616

2617

2618
def deduplicate_list(lst):
1✔
2619
    """ De-duplicates the given list by converting it to a set then back to a list.
2620
    NOTES:
2621
    * The list must contain 'hashable' type elements that can be used in sets.
2622
    * The result list might not be ordered the same as the input list.
2623
    * This will also take tuples as input, though the result will be a list.
2624
    :param lst: list to de-duplicate
2625
    :return: de-duplicated list
2626
    """
2627
    return list(set(lst))
1✔
2628

2629

2630
def chunked(seq, *, chunk_size=1):
1✔
2631
    if not isinstance(chunk_size, int) or chunk_size < 1:
1✔
2632
        raise ValueError(f"The chunk_size, {chunk_size}, must be a positive integer.")
×
2633
    chunk = []
1✔
2634
    i = 0
1✔
2635
    for item in seq:
1✔
2636
        chunk.append(item)
1✔
2637
        i = (i + 1) % chunk_size
1✔
2638
        if i == 0:
1✔
2639
            yield chunk
1✔
2640
            chunk = []
1✔
2641
    if chunk:
1✔
2642
        yield chunk
1✔
2643

2644

2645
def map_chunked(fn, seq, *, chunk_size=1, reduce=None):
1✔
2646
    result = (fn(chunk) for chunk in chunked(seq, chunk_size=chunk_size))
1✔
2647
    return reduce(result) if reduce is not None else result
1✔
2648

2649

2650
_36_DIGITS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
1✔
2651

2652

2653
def format_in_radix(n: int, *, radix: int):
1✔
2654

2655
    if not isinstance(n, int) or n < 0:
1✔
2656
        raise ValueError(f"Expected n to be a non-negative integer {n}")
1✔
2657
    if radix < 2 or radix > 36:
1✔
2658
        raise ValueError(f"Expected radix to be an integer between 2 and 36, inclusive: {radix}")
1✔
2659
    buffer = []
1✔
2660
    while n > 0:
1✔
2661
        quo = n // radix
1✔
2662
        rem = n % radix
1✔
2663
        buffer += _36_DIGITS[rem]
1✔
2664
        n = quo
1✔
2665
    buffer.reverse()
1✔
2666
    return "".join(buffer) or "0"
1✔
2667

2668

2669
def parse_in_radix(text: str, *, radix: int):
1✔
2670
    if not (text and isinstance(text, str)):
1✔
2671
        raise ValueError(f"Expected a string to parse: {text}")
1✔
2672
    if radix < 2 or radix > 36:
1✔
2673
        raise ValueError(f"Expected radix to be an integer between 2 and 36, inclusive: {radix}")
1✔
2674
    res = 0
1✔
2675
    try:
1✔
2676
        for c in text:
1✔
2677
            res = res * radix + _36_DIGITS.index(c.upper())
1✔
2678
        return res
1✔
2679
    except Exception:
1✔
2680
        pass
1✔
2681
    raise ValueError(f"Unable to parse: {text!r}")
1✔
2682

2683

2684
def pad_to(target_size: int, data: list, *, padding=None):
1✔
2685
    """
2686
    This will pad to a given target size, a list of a potentially different actual size, using given padding.
2687
    e.g., pad_to(3, [1, 2]) will return [1, 2, None]
2688
    """
2689
    actual_size = len(data)
1✔
2690
    if actual_size < target_size:
1✔
2691
        data = data + [padding] * (target_size - actual_size)
1✔
2692
    return data
1✔
2693

2694

2695
def normalize_spaces(value: str) -> str:
1✔
2696
    """
2697
    Returns the given string with multiple consecutive occurrences of whitespace
2698
    converted to a single space, and left and right trimmed of spaces.
2699
    """
2700
    return re.sub(r"\s+", " ", value).strip()
1✔
2701

2702

2703
def normalize_string(value: Optional[str]) -> Optional[str]:
1✔
2704
    """
2705
    Strips leading/trailing spaces, and converts multiple consecutive spaces to a single space
2706
    in the given string value and returns the result. If the given value is None returns an
2707
    empty string. If the given value is not actually even a string then return None.
2708
    """
2709
    if value is None:
×
2710
        return ""
×
2711
    elif isinstance(value, str):
×
2712
        return re.sub(r"\s+", " ", value).strip()
×
2713
    return None
×
2714

2715

2716
def find_nth_from_end(string: str, substring: str, nth: int) -> int:
1✔
2717
    """
2718
    Returns the index of the nth occurrence of the given substring within
2719
    the given string from the END of the given string; or -1 if not found.
2720
    """
2721
    index = -1
1✔
2722
    string = string[::-1]
1✔
2723
    for i in range(0, nth):
1✔
2724
        index = string.find(substring, index + 1)
1✔
2725
    return len(string) - index - 1 if index >= 0 else -1
1✔
2726

2727

2728
def set_nth(string: str, nth: int, replacement: str) -> str:
1✔
2729
    """
2730
    Sets the nth character of the given string to the given replacement string.
2731
    """
2732
    if not isinstance(string, str) or not isinstance(nth, int) or not isinstance(replacement, str):
1✔
2733
        return string
×
2734
    if nth < 0:
1✔
2735
        nth += len(string)
×
2736
    return string[:nth] + replacement + string[nth + 1:] if 0 <= nth < len(string) else string
1✔
2737

2738

2739
def format_size(nbytes: Union[int, float], precision: int = 2, nospace: bool = False, terse: bool = False) -> str:
1✔
2740
    if isinstance(nbytes, str) and nbytes.isdigit():
×
2741
        nbytes = int(nbytes)
×
2742
    elif not isinstance(nbytes, (int, float)):
×
2743
        return ""
×
2744
    UNITS = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
×
2745
    UNITS_TERSE = ['b', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y']
×
2746
    MAX_UNITS_INDEX = len(UNITS) - 1
×
2747
    ONE_K = 1024
×
2748
    index = 0
×
2749
    if (precision := max(precision, 0)) and (nbytes <= ONE_K):
×
2750
        precision -= 1
×
2751
    while abs(nbytes) >= ONE_K and index < MAX_UNITS_INDEX:
×
2752
        nbytes /= ONE_K
×
2753
        index += 1
×
2754
    if index == 0:
×
2755
        nbytes = int(nbytes)
×
2756
        return f"{nbytes} byte{'s' if nbytes != 1 else ''}"
×
2757
    unit = (UNITS_TERSE if terse else UNITS)[index]
×
2758
    size = f"{nbytes:.{precision}f}"
×
2759
    if size.endswith(f".{'0' * precision}"):
×
2760
        # Tidy up extraneous zeros.
2761
        size = size[:-(precision - 1)]
×
2762
    return f"{size}{'' if nospace else ' '}{unit}"
×
2763

2764

2765
def format_duration(seconds: Union[int, float]) -> str:
1✔
2766
    seconds_actual = seconds
×
2767
    seconds = round(max(seconds, 0))
×
2768
    durations = [("year", 31536000), ("day", 86400), ("hour", 3600), ("minute", 60), ("second", 1)]
×
2769
    parts = []
×
2770
    for name, duration in durations:
×
2771
        if seconds >= duration:
×
2772
            count = seconds // duration
×
2773
            seconds %= duration
×
2774
            if count != 1:
×
2775
                name += "s"
×
2776
            parts.append(f"{count} {name}")
×
2777
    if len(parts) == 0:
×
2778
        return f"{seconds_actual:.1f} seconds"
×
2779
    elif len(parts) == 1:
×
2780
        return f"{seconds_actual:.1f} seconds"
×
2781
    else:
2782
        return " ".join(parts[:-1]) + " " + parts[-1]
×
2783

2784

2785
class JsonLinesReader:
1✔
2786

2787
    def __init__(self, fp, padded=False, padding=None):
1✔
2788
        """
2789
        Given an fp (the conventional name for a "file pointer", the thing a call to io.open returns,
2790
        this creates an object that can be used to iterate across the lines in the JSON lines file
2791
        that the fp is reading from.
2792

2793
        There are two possible formats that this will return.
2794

2795
        For files that contain a series of dictionaries, such as:
2796
            {"something": 1, "else": "a"}
2797
            {"something": 2, "else": "b"}
2798
            ...etc
2799
        this will just return thos those dictionaries one-by-one when iterated over.
2800

2801
        The same set of dictionaries will also be yielded by a file containing:
2802
            ["something", "else"]
2803
            [1, "a"]
2804
            [2, "b"]
2805
            ...etc
2806
        this will just return thos those dictionaries one-by-one when iterated over.
2807

2808
        NOTES:
2809

2810
        * In the second case, shorter lists on subsequent lines return only partial dictionaries.
2811
        * In the second case, longer lists on subsequent lines will quietly drop any extra elements.
2812
        """
2813

2814
        self.fp = fp
1✔
2815
        self.padded: bool = padded
1✔
2816
        self.padding = padding
1✔
2817
        self.headers = None  # Might change after we see first line
1✔
2818

2819
    def __iter__(self):
1✔
2820
        first_line = True
1✔
2821
        n_headers = 0
1✔
2822
        for raw_line in self.fp:
1✔
2823
            line = json.loads(raw_line)
1✔
2824
            if first_line:
1✔
2825
                first_line = False
1✔
2826
                if isinstance(line, list):
1✔
2827
                    self.headers = line
1✔
2828
                    n_headers = len(line)
1✔
2829
                    continue
1✔
2830
            # If length of line is more than we expect, ignore it. Let user put comments beyond our table
2831
            # But if length of line is less than we expect, extend the line with None
2832
            if self.headers:
1✔
2833
                if not isinstance(line, list):
1✔
2834
                    raise Exception("If the first line is a list, all lines must be.")
×
2835
                if self.padded and len(line) < n_headers:
1✔
2836
                    line = pad_to(n_headers, line, padding=self.padding)
×
2837
                yield dict(zip(self.headers, line))
1✔
2838
            elif isinstance(line, dict):
1✔
2839
                yield line
1✔
2840
            else:
2841
                raise Exception(f"If the first line is not a list, all lines must be dictionaries: {line!r}")
×
2842

2843

2844
def get_app_specific_directory() -> str:
1✔
2845
    """
2846
    Returns the standard system application specific directory:
2847
    - On MacOS this directory: is: ~/Library/Application Support
2848
    - On Linux this directory is: ~/.local/share
2849
    - On Windows this directory is: %USERPROFILE%\\AppData\\Local  # noqa
2850
    N.B. This is has been tested on MacOS and Linux but not on Windows.
2851
    """
2852
    return appdirs.user_data_dir()
×
2853

2854

2855
def get_os_name() -> str:
1✔
2856
    if os_name := platform.system():
×
2857
        if os_name == "Darwin": return "osx"  # noqa
×
2858
        elif os_name == "Linux": return "linux"  # noqa
×
2859
        elif os_name == "Windows": return "windows"  # noqa
×
2860
    return ""
×
2861

2862

2863
def get_cpu_architecture_name() -> str:
1✔
2864
    if os_architecture_name := platform.machine():
×
2865
        if os_architecture_name == "x86_64": return "amd64"  # noqa
×
2866
        return os_architecture_name
×
2867
    return ""
×
2868

2869

2870
def create_uuid(nodash: bool = False, upper: bool = False) -> str:
1✔
2871
    value = str(uuid.uuid4())
×
2872
    if nodash is True:
×
2873
        value = value.replace("-", "")
×
2874
    if upper is True:
×
2875
        value = value.upper()
×
2876
    return value
×
2877

2878

2879
def create_short_uuid(length: Optional[int] = None, upper: bool = False):
1✔
2880
    # Not really techincally a uuid of course.
2881
    if (length is None) or (not isinstance(length, int)) or (length < 1):
×
2882
        length = 16
×
2883
    value = shortuuid.ShortUUID().random(length=length)
×
2884
    if upper is True:
×
2885
        value = value.upper()
×
2886
    return value
×
2887

2888

2889
def run_concurrently(functions: Iterable[Callable], nthreads: int = 4) -> None:
1✔
NEW
2890
    with concurrent.futures.ThreadPoolExecutor(max_workers=nthreads) as executor:
×
NEW
2891
        concurrent.futures.as_completed([executor.submit(f) for f in functions])
×
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

© 2025 Coveralls, Inc