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

hasgeek / coaster / 9244418196

26 May 2024 03:39PM UTC coverage: 84.467% (-4.8%) from 89.263%
9244418196

push

github

web-flow
Add async support for Quart+Flask (#470)

This commit bumps the version number from 0.7 to 0.8 as it has extensive changes:

* Ruff replaces black, isort and flake8 for linting and formatting
* All decorators now support async functions and provide async wrapper implementations
* Some obsolete modules have been removed
* Pagination from Flask-SQLAlchemy is now included, removing that dependency (but still used in tests)
* New `compat` module provides wrappers to both Quart and Flake and is used by all other modules
* Some tests run using Quart. The vast majority of tests are not upgraded, nor are there tests for async decorators, so overall line coverage has dropped significantly. Comprehensive test coverage is still pending; for now we are using Funnel's tests as the extended test suite

648 of 1023 new or added lines in 29 files covered. (63.34%)

138 existing lines in 17 files now uncovered.

3948 of 4674 relevant lines covered (84.47%)

3.38 hits per line

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

98.6
/src/coaster/utils/misc.py
1
"""Miscellaneous utilities."""
4✔
2

3
# spell-checker:ignore newsecret newpin checkused nullint nullstr getbool dunder
4
from __future__ import annotations
4✔
5

6
import email.utils
4✔
7
import hashlib
4✔
8
import re
4✔
9
import time
4✔
10
import uuid
4✔
11
from base64 import urlsafe_b64decode, urlsafe_b64encode
4✔
12
from collections.abc import Collection, Mapping
4✔
13
from datetime import datetime
4✔
14
from functools import wraps
4✔
15
from secrets import randbelow, token_bytes
4✔
16
from typing import Any, Callable, Literal, Optional, TypeVar, Union, overload
4✔
17
from urllib.parse import urlparse
4✔
18

19
import base58
4✔
20
import tldextract
4✔
21
from unidecode import unidecode
4✔
22

23
__all__ = [
4✔
24
    'base_domain_matches',
25
    'buid',
26
    'buid2uuid',
27
    'domain_namespace_match',
28
    'format_currency',
29
    'get_email_domain',
30
    'getbool',
31
    'is_collection',
32
    'is_dunder',
33
    'make_name',
34
    'md5sum',
35
    'namespace_from_url',
36
    'nary_op',
37
    'newpin',
38
    'newsecret',
39
    'nullint',
40
    'nullstr',
41
    'require_one_of',
42
    'uuid1mc',
43
    'uuid1mc_from_datetime',
44
    'uuid2buid',
45
    'uuid_b58',
46
    'uuid_b64',
47
    'uuid_from_base58',
48
    'uuid_from_base64',
49
    'uuid_to_base58',
50
    'uuid_to_base64',
51
]
52

53
# --- Common delimiters and punctuation ------------------------------------------------
54

55
_strip_re = re.compile('[\'"`‘’“”′″‴]+')  # noqa: RUF001
4✔
56
_punctuation_re = re.compile(
4✔
57
    '[\x00-\x1f +!#$%&()*\\-/<=>?@\\[\\\\\\]^_{|}:;,.…‒–—―«»]+'  # noqa: RUF001
58
)
59
_ipv4_re = re.compile(
4✔
60
    r'^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}'
61
    r'(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$'
62
)
63

64

65
# --- Utilities ------------------------------------------------------------------------
66

67

68
def is_collection(item: Any) -> bool:
4✔
69
    """
70
    Return True if the item is a collection class but not a string or dict.
71

72
    List, tuple, set, frozenset or any other class that resembles one of these (using
73
    abstract base classes). Using ``collections.abc.Collection`` directly is not
74
    suitable as it also matches strings and dicts.
75

76
    >>> is_collection(0)
77
    False
78
    >>> is_collection(0.1)
79
    False
80
    >>> is_collection('')
81
    False
82
    >>> is_collection(b'')
83
    False
84
    >>> is_collection({})
85
    False
86
    >>> is_collection({}.keys())
87
    True
88
    >>> is_collection([])
89
    True
90
    >>> is_collection(())
91
    True
92
    >>> is_collection(set())
93
    True
94
    >>> is_collection(frozenset())
95
    True
96
    >>> from coaster.utils import InspectableSet
97
    >>> is_collection(InspectableSet({1, 2}))
98
    True
99
    """
100
    return not isinstance(item, (str, bytes, Mapping)) and isinstance(item, Collection)
4✔
101

102

103
def uuid_b64() -> str:
4✔
104
    """
105
    Return a UUID4 encoded in URL-safe Base64, for use as a random identifier.
106

107
    >>> len(buid())
108
    22
109
    >>> buid() == buid()
110
    False
111
    >>> isinstance(buid(), str)
112
    True
113
    """
114
    return urlsafe_b64encode(uuid.uuid4().bytes).decode().rstrip('=')
4✔
115

116

117
#: Legacy name
118
buid = uuid_b64
4✔
119

120

121
def uuid_b58() -> str:
4✔
122
    """
123
    Return a UUID4 encoded in Base58 using the Bitcoin alphabet.
124

125
    >>> len(uuid_b58()) in (21, 22)
126
    True
127
    >>> uuid_b58() == uuid_b58()
128
    False
129
    >>> isinstance(uuid_b58(), str)
130
    True
131
    """
132
    return base58.b58encode(uuid.uuid4().bytes).decode()
4✔
133

134

135
def uuid1mc() -> uuid.UUID:
4✔
136
    """
137
    Return a UUID1 with a random multicast MAC id.
138

139
    >>> isinstance(uuid1mc(), uuid.UUID)
140
    True
141
    """
142
    # pylint: disable=protected-access
143
    return uuid.uuid1(node=uuid._random_getnode())  # type: ignore[attr-defined]
4✔
144

145

146
def uuid1mc_from_datetime(dt: Union[datetime, float]) -> uuid.UUID:
4✔
147
    """
148
    Return a UUID1 with a specific timestamp and a random multicast MAC id.
149

150
    .. warning::
151
        This function does not consider the timezone, and is not guaranteed to
152
        return a unique UUID. Use under controlled conditions only.
153

154
    >>> dt = datetime.now()
155
    >>> u1 = uuid1mc()
156
    >>> u2 = uuid1mc_from_datetime(dt)
157
    >>> # Both timestamps should be very close to each other but not an exact match
158
    >>> u1.time > u2.time
159
    True
160
    >>> u1.time - u2.time < 5000
161
    True
162
    >>> d2 = datetime.fromtimestamp((u2.time - 0x01B21DD213814000) * 100 / 1e9)
163
    >>> d2 == dt
164
    True
165
    """
166
    fields = list(uuid1mc().fields)
4✔
167
    if isinstance(dt, datetime):
4✔
168
        timeval = time.mktime(dt.timetuple()) + dt.microsecond / 1e6
4✔
169
    else:
170
        # Assume we got an actual timestamp
UNCOV
171
        timeval = dt
×
172

173
    # The following code is borrowed from the UUID module source:
174
    nanoseconds = int(timeval * 1e9)
4✔
175
    # 0x01b21dd213814000 is the number of 100-ns intervals between the
176
    # UUID epoch 1582-10-15 00:00:00 and the Unix epoch 1970-01-01 00:00:00.
177
    timestamp = int(nanoseconds // 100) + 0x01B21DD213814000
4✔
178
    time_low = timestamp & 0xFFFFFFFF
4✔
179
    time_mid = (timestamp >> 32) & 0xFFFF
4✔
180
    time_hi_version = (timestamp >> 48) & 0x0FFF
4✔
181

182
    fields[0] = time_low
4✔
183
    fields[1] = time_mid
4✔
184
    fields[2] = time_hi_version
4✔
185

186
    return uuid.UUID(fields=tuple(fields))  # type: ignore[arg-type]
4✔
187

188

189
def uuid_to_base64(value: uuid.UUID) -> str:
4✔
190
    """
191
    Encode a UUID as a 22-char URL-safe Base64 string.
192

193
    >>> uuid_to_base64(uuid.UUID('33203dd2-f2ef-422f-aeb0-058d6f5f7089'))
194
    'MyA90vLvQi-usAWNb19wiQ'
195
    """
196
    return urlsafe_b64encode(value.bytes).decode().rstrip('=')
4✔
197

198

199
#: Legacy name
200
uuid2buid = uuid_to_base64
4✔
201

202

203
def uuid_from_base64(value: str) -> uuid.UUID:
4✔
204
    """
205
    Decode a UUID from a URL-safe Base64 string.
206

207
    >>> uuid_from_base64('MyA90vLvQi-usAWNb19wiQ')
208
    UUID('33203dd2-f2ef-422f-aeb0-058d6f5f7089')
209
    """
210
    return uuid.UUID(bytes=urlsafe_b64decode(str(value) + '=='))
4✔
211

212

213
#: Legacy name
214
buid2uuid = uuid_from_base64
4✔
215

216

217
def uuid_to_base58(value: uuid.UUID) -> str:
4✔
218
    """
219
    Encode a UUID as a Base58 string using the Bitcoin alphabet.
220

221
    >>> uuid_to_base58(uuid.UUID('33203dd2-f2ef-422f-aeb0-058d6f5f7089'))
222
    '7KAmj837MyuJWUYPwtqAfz'
223
    >>> # The following UUID to Base58 encoding is from NPM uuid-base58, for comparison
224
    >>> uuid_to_base58(uuid.UUID('d7ce8475-e77c-43b0-9dde-56b428981999'))
225
    'TedLUruK7MosG1Z88urTkk'
226
    """
227
    return base58.b58encode(value.bytes).decode()
4✔
228

229

230
def uuid_from_base58(value: str) -> uuid.UUID:
4✔
231
    """
232
    Decode a UUID from Base58 using the Bitcoin alphabet.
233

234
    >>> uuid_from_base58('7KAmj837MyuJWUYPwtqAfz')
235
    UUID('33203dd2-f2ef-422f-aeb0-058d6f5f7089')
236
    >>> # The following UUID to Base58 encoding is from NPM uuid-base58, for comparison
237
    >>> uuid_from_base58('TedLUruK7MosG1Z88urTkk')
238
    UUID('d7ce8475-e77c-43b0-9dde-56b428981999')
239
    """
240
    return uuid.UUID(bytes=base58.b58decode(str(value)))
4✔
241

242

243
def newsecret() -> str:
4✔
244
    """
245
    Make a secret key.
246

247
    Uses :func:`secrets.token_bytes` with 32 characters and renders into Base58 for a
248
    URL-friendly token, with a resulting length between 42 and 44 characters long.
249

250
    >>> len(newsecret()) in (42, 43, 44)
251
    True
252
    >>> isinstance(newsecret(), str)
253
    True
254
    >>> newsecret() == newsecret()
255
    False
256
    """
257
    return base58.b58encode(token_bytes(32)).decode()
4✔
258

259

260
def newpin(digits: int = 4) -> str:
4✔
261
    """
262
    Return a random numeric string with the specified number of digits, default 4.
263

264
    >>> len(newpin())
265
    4
266
    >>> len(newpin(5))
267
    5
268
    >>> newpin().isdigit()
269
    True
270
    >>> newpin() != newpin()
271
    True
272
    >>> newpin(6) != newpin(6)
273
    True
274
    """
275
    pin = '00' * digits
4✔
276
    while len(pin) > digits:
4✔
277
        randnum = randbelow(10**digits)
4✔
278
        pin = str(randnum).zfill(digits)
4✔
279
    return pin
4✔
280

281

282
def make_name(
4✔
283
    text: str,
284
    delim: str = '-',
285
    maxlength: int = 50,
286
    checkused: Optional[Callable[[str], bool]] = None,
287
    counter: int = 2,
288
) -> str:
289
    r"""
290
    Generate an ASCII name slug.
291

292
    If a checkused filter is provided, it will be called with the candidate. If it
293
    returns True, make_name will add counter numbers starting from 2 until a suitable
294
    candidate is found.
295

296
    :param string delim: Delimiter between words, default '-'
297
    :param int maxlength: Maximum length of name, default 50
298
    :param checkused: Function to check if a generated name is available for use
299
    :param int counter: Starting position for name counter
300

301
    >>> make_name('This is a title')
302
    'this-is-a-title'
303
    >>> make_name('Invalid URL/slug here')
304
    'invalid-url-slug-here'
305
    >>> make_name('this.that')
306
    'this-that'
307
    >>> make_name('this:that')
308
    'this-that'
309
    >>> make_name("How 'bout this?")
310
    'how-bout-this'
311
    >>> make_name("How’s that?")
312
    'hows-that'
313
    >>> make_name('K & D')
314
    'k-d'
315
    >>> make_name('billion+ pageviews')
316
    'billion-pageviews'
317
    >>> make_name('हिन्दी slug!')
318
    'hindii-slug'
319
    >>> make_name('Talk in español, Kiswahili, 廣州話 and অসমীয়া too.', maxlength=250)
320
    'talk-in-espanol-kiswahili-guang-zhou-hua-and-asmiiyaa-too'
321
    >>> make_name('__name__', delim='_')
322
    'name'
323
    >>> make_name('how_about_this', delim='_')
324
    'how_about_this'
325
    >>> make_name('and-that', delim='_')
326
    'and_that'
327
    >>> make_name('Umlauts in Mötörhead')
328
    'umlauts-in-motorhead'
329
    >>> make_name('Candidate', checkused=lambda c: c in ['candidate'])
330
    'candidate2'
331
    >>> make_name('Candidate', checkused=lambda c: c in ['candidate'], counter=1)
332
    'candidate1'
333
    >>> make_name(
334
    ...     'Candidate',
335
    ...     checkused=lambda c: c in ['candidate', 'candidate1', 'candidate2'],
336
    ...     counter=1,
337
    ... )
338
    'candidate3'
339
    >>> make_name('Long title, but snipped', maxlength=20)
340
    'long-title-but-snipp'
341
    >>> len(make_name('Long title, but snipped', maxlength=20))
342
    20
343
    >>> make_name(
344
    ...     'Long candidate',
345
    ...     maxlength=10,
346
    ...     checkused=lambda c: c in ['long-candi', 'long-cand1'],
347
    ... )
348
    'long-cand2'
349
    >>> make_name('Lǝnkǝran')
350
    'lankaran'
351
    >>> make_name('example@example.com')
352
    'example-example-com'
353
    >>> make_name('trailing-delimiter', maxlength=10)
354
    'trailing-d'
355
    >>> make_name('trailing-delimiter', maxlength=9)
356
    'trailing'
357
    >>> make_name('''test this
358
    ... newline''')
359
    'test-this-newline'
360
    >>> make_name("testing an emoji😁")
361
    'testing-an-emoji'
362
    >>> make_name('''testing\t\nmore\r\nslashes''')
363
    'testing-more-slashes'
364
    >>> make_name('What if a HTML <tag/>')
365
    'what-if-a-html-tag'
366
    >>> make_name('These are equivalent to \x01 through \x1a')
367
    'these-are-equivalent-to-through'
368
    >>> make_name("feedback;\x00")
369
    'feedback'
370
    """
371
    name = text.replace('@', delim)
4✔
372
    # We don't know why unidecode uses '@' for 'a'-like chars
373
    name = unidecode(name).replace('@', 'a')
4✔
374
    name = str(
4✔
375
        delim.join(
376
            [
377
                _strip_re.sub('', x)
378
                for x in _punctuation_re.split(name.lower())
379
                if x != ''
380
            ]
381
        )
382
    )
383
    candidate = name[:maxlength]
4✔
384
    if candidate.endswith(delim):
4✔
385
        candidate = candidate[:-1]
4✔
386
    if checkused is None:
4✔
387
        return candidate
4✔
388
    existing = checkused(candidate)
4✔
389
    while existing:
4✔
390
        candidate = name[: maxlength - len(str(counter))] + str(counter)
4✔
391
        counter += 1
4✔
392
        existing = checkused(candidate)
4✔
393
    return candidate
4✔
394

395

396
def format_currency(value: float, decimals: int = 2) -> str:
4✔
397
    """
398
    Return a number suitably formatted for display as currency.
399

400
    Separates thousands with commas and includes up to two decimal points.
401

402
    .. deprecated:: 0.7.0
403
        Use Babel for context-sensitive formatting.
404

405
    >>> format_currency(1000)
406
    '1,000'
407
    >>> format_currency(100)
408
    '100'
409
    >>> format_currency(999.95)
410
    '999.95'
411
    >>> format_currency(99.95)
412
    '99.95'
413
    >>> format_currency(100000)
414
    '100,000'
415
    >>> format_currency(1000.00)
416
    '1,000'
417
    >>> format_currency(1000.41)
418
    '1,000.41'
419
    >>> format_currency(23.21, decimals=3)
420
    '23.210'
421
    >>> format_currency(1000, decimals=3)
422
    '1,000'
423
    >>> format_currency(123456789.123456789)
424
    '123,456,789.12'
425
    """
426
    # pylint: disable=consider-using-f-string
427
    number, decimal = (('%%.%df' % decimals) % value).split('.')
4✔
428
    parts = []
4✔
429
    while len(number) > 3:
4✔
430
        part, number = number[-3:], number[:-3]
4✔
431
        parts.append(part)
4✔
432
    parts.append(number)
4✔
433
    parts.reverse()
4✔
434
    if int(decimal) == 0:
4✔
435
        return ','.join(parts)
4✔
436
    return ','.join(parts) + '.' + decimal
4✔
437

438

439
def md5sum(data: str) -> str:
4✔
440
    """
441
    Return md5sum of data as a 32-character string.
442

443
    >>> md5sum('random text')
444
    'd9b9bec3f4cc5482e7c5ef43143e563a'
445
    >>> md5sum('random text')
446
    'd9b9bec3f4cc5482e7c5ef43143e563a'
447
    >>> len(md5sum('random text'))
448
    32
449
    """
450
    return hashlib.md5(data.encode('utf-8'), usedforsecurity=False).hexdigest()
4✔
451

452

453
def getbool(value: Union[str, int, bool, None]) -> Optional[bool]:
4✔
454
    """
455
    Return a boolean from any of a range of boolean-like values.
456

457
    * Returns `True` for ``1``, ``t``, ``true``, ``y`` and ``yes``
458
    * Returns `False` for ``0``, ``f``, ``false``, ``n`` and ``no``
459
    * Returns `None` for unrecognized values. Numbers other than 0 and 1 are considered
460
      unrecognized
461

462
    >>> getbool(True)
463
    True
464
    >>> getbool(1)
465
    True
466
    >>> getbool('1')
467
    True
468
    >>> getbool('t')
469
    True
470
    >>> getbool(2)
471
    >>> getbool(0)
472
    False
473
    >>> getbool(False)
474
    False
475
    >>> getbool('n')
476
    False
477
    """
478
    value = str(value).lower()
4✔
479
    if value in ['1', 't', 'true', 'y', 'yes']:
4✔
480
        return True
4✔
481
    if value in ['0', 'f', 'false', 'n', 'no']:
4✔
482
        return False
4✔
483
    return None
4✔
484

485

486
def nullint(value: Optional[Any]) -> Optional[int]:
4✔
487
    """
488
    Return `int(value)` if `bool(value)` is not `False`. Return `None` otherwise.
489

490
    Useful for coercing optional values to an integer.
491

492
    >>> nullint('10')
493
    10
494
    >>> nullint('') is None
495
    True
496
    """
497
    return int(value) if value else None
4✔
498

499

500
def nullstr(value: Optional[Any]) -> Optional[str]:
4✔
501
    """
502
    Return `str(value)` if `bool(value)` is not `False`. Return `None` otherwise.
503

504
    Useful for coercing optional values to a string.
505

506
    >>> nullstr(10) == '10'
507
    True
508
    >>> nullstr('') is None
509
    True
510
    """
511
    return str(value) if value else None
4✔
512

513

514
@overload
515
def require_one_of(__return: Literal[False] = False, /, **kwargs: Any) -> None: ...
516

517

518
@overload
519
def require_one_of(__return: Literal[True], /, **kwargs: Any) -> tuple[str, Any]: ...
520

521

522
def require_one_of(
4✔
523
    __return: bool = False, /, **kwargs: Any
524
) -> Optional[tuple[str, Any]]:
525
    """
526
    Validate that only one of multiple parameters has a non-None value.
527

528
    Use this inside functions that take multiple parameters, but allow only one of them
529
    to be specified::
530

531
        def my_func(this=None, that=None, other=None):
532
            # Require one and only one of `this` or `that`
533
            require_one_of(this=this, that=that)
534

535
            # If we need to know which parameter was passed in:
536
            param, value = require_one_of(True, this=this, that=that)
537

538
            # Carry on with function logic
539
            pass
540

541
    :param __return: Return the matching parameter name and value
542
    :param kwargs: Parameters, of which one and only one is mandatory
543
    :return: If `__return`, matching parameter name and value
544
    :raises TypeError: If the count of parameters that aren't ``None`` is not 1
545

546
    .. deprecated:: 0.7.0
547
        Use static type checking with @overload declarations to avoid runtime overhead
548
    """
549
    # Two ways to count number of non-None parameters:
550
    #
551
    # 1. sum([1 if v is not None else 0 for v in kwargs.values()])
552
    #
553
    #    This uses a list comprehension instead of a generator comprehension as the
554
    #    parameter to `sum` is faster on both Python 2 and 3.
555
    #
556
    # 2. len(kwargs) - kwargs.values().count(None)
557
    #
558
    #    This is 2x faster than the first method under Python 2.7. Unfortunately,
559
    #    it does not work in Python 3 because `kwargs.values()` is a view that does not
560
    #    have a `count` method. It needs to be cast into a tuple/list first, but
561
    #    remains faster despite the cast's slowdown. Tuples are faster than lists.
562

563
    count = len(kwargs) - tuple(kwargs.values()).count(None)
4✔
564

565
    if count == 0:
4✔
566
        raise TypeError(
4✔
567
            "One of these parameters is required: " + ', '.join(kwargs.keys())
568
        )
569
    if count != 1:
4✔
570
        raise TypeError(
4✔
571
            "Only one of these parameters is allowed: " + ', '.join(kwargs.keys())
572
        )
573

574
    if __return:
4✔
575
        keys, values = zip(*((k, 1 if v is not None else 0) for k, v in kwargs.items()))
4✔
576
        k = keys[values.index(1)]
4✔
577
        return k, kwargs[k]
4✔
578
    return None
4✔
579

580

581
def get_email_domain(emailaddr: str) -> Optional[str]:
4✔
582
    """
583
    Return the domain component of an email address.
584

585
    Returns None if the provided string cannot be parsed as an email address.
586

587
    >>> get_email_domain('test@example.com')
588
    'example.com'
589
    >>> get_email_domain('test+trailing@example.com')
590
    'example.com'
591
    >>> get_email_domain('Example Address <test@example.com>')
592
    'example.com'
593
    >>> get_email_domain('foobar')
594
    >>> get_email_domain('foobar@')
595
    >>> get_email_domain('@foobar')
596
    """
597
    _realname, address = email.utils.parseaddr(emailaddr)
4✔
598
    try:
4✔
599
        username, domain = address.split('@')
4✔
600
        if not username:
4✔
601
            return None
4✔
602
        return domain or None
4✔
603
    except ValueError:
4✔
604
        return None
4✔
605

606

607
def namespace_from_url(url: str) -> Optional[str]:
4✔
608
    """Construct a dotted namespace string from a URL."""
609
    parsed = urlparse(url)
4✔
610
    if (
4✔
611
        parsed.hostname is None
612
        or parsed.hostname in ['localhost', 'localhost.localdomain']
613
        or (_ipv4_re.search(parsed.hostname))
614
    ):
615
        return None
4✔
616

617
    namespace = parsed.hostname.split('.')
4✔
618
    namespace.reverse()
4✔
619
    if namespace and not namespace[0]:
4✔
UNCOV
620
        namespace.pop(0)
×
621
    if namespace and namespace[-1] == 'www':
4✔
622
        namespace.pop(-1)
4✔
623
    return type(url)('.'.join(namespace))
4✔
624

625

626
def base_domain_matches(d1: str, d2: str) -> bool:
4✔
627
    """
628
    Check if two domains have the same base domain, using the Public Suffix List.
629

630
    >>> base_domain_matches('https://hasjob.co', 'hasjob.co')
631
    True
632
    >>> base_domain_matches('hasgeek.hasjob.co', 'hasjob.co')
633
    True
634
    >>> base_domain_matches('hasgeek.com', 'hasjob.co')
635
    False
636
    >>> base_domain_matches('static.hasgeek.co.in', 'hasgeek.com')
637
    False
638
    >>> base_domain_matches('static.hasgeek.co.in', 'hasgeek.co.in')
639
    True
640
    >>> base_domain_matches('example@example.com', 'example.com')
641
    True
642
    """
643
    r1 = tldextract.extract(d1)
4✔
644
    r2 = tldextract.extract(d2)
4✔
645
    # r1 and r2 contain subdomain, domain and suffix.
646
    # We want to confirm that domain and suffix match.
647
    return r1.domain == r2.domain and r1.suffix == r2.suffix
4✔
648

649

650
def domain_namespace_match(domain: str, namespace: str) -> bool:
4✔
651
    """
652
    Check if namespace is related to the domain because the base domain matches.
653

654
    >>> domain_namespace_match('hasgeek.com', 'com.hasgeek')
655
    True
656
    >>> domain_namespace_match('funnel.hasgeek.com', 'com.hasgeek.funnel')
657
    True
658
    >>> domain_namespace_match('app.hasgeek.com', 'com.hasgeek.peopleflow')
659
    True
660
    >>> domain_namespace_match('app.hasgeek.in', 'com.hasgeek.peopleflow')
661
    False
662
    >>> domain_namespace_match('peopleflow.local', 'local.peopleflow')
663
    True
664
    """
665
    return base_domain_matches(domain, '.'.join(namespace.split('.')[::-1]))
4✔
666

667

668
T = TypeVar('T')
4✔
669
T2 = TypeVar('T2')
4✔
670
R_co = TypeVar('R_co', covariant=True)
4✔
671

672

673
def nary_op(
674
    f: Callable[[T, T2], R_co], doc: Optional[str] = None
675
) -> Callable[..., R_co]:
676
    """
677
    Convert a binary operator function into a chained n-ary operator.
678

679
    Example::
680

681
        >>> @nary_op
682
        ... def subtract_all(lhs, rhs):
683
        ...     return lhs - rhs
684

685
    This converts ``subtract_all`` to accept multiple parameters::
686

687
        >>> subtract_all(10, 2, 3)
688
        5
689
    """
690

691
    @wraps(f)
692
    def inner(lhs: T, *others: T2) -> R_co:
693
        for other in others:
694
            lhs = f(lhs, other)  # type: ignore[assignment]
695
        return lhs  # type: ignore[return-value]
696

697
    if doc is not None:
698
        inner.__doc__ = doc
699
    return inner
700

701

702
def is_dunder(name: str) -> bool:
4✔
703
    """Check if a __dunder__ name (copied from the enum module)."""
704
    return (
4✔
705
        len(name) > 4
706
        and name[:2] == name[-2:] == '__'
707
        and name[2] != '_'
708
        and name[-3] != '_'
709
    )
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc