Skip to main content

An exceptional library

Project description

https://gitlab.com/uploads/-/system/project/avatar/50698236/izulu_logo_512.png?width=128

“An exceptional library”


Installation

pip install izulu

Presenting izulu

Bring OOP into exception/error management

You can read docs from top to bottom or jump straight into “Quickstart” section. For details note “Specifications” sections below.

Neat #1: Stop messing with raw strings and manual message formatting

if not data:
    raise ValueError("Data is invalid: no data")

amount = data["amount"]
if amount < 0:
    raise ValueError(f"Data is invalid: amount can't be negative ({amount})")
elif amount > 1000:
    raise ValueError(f"Data is invalid: amount is too large ({amount})")

if data["status"] not in {"READY", "IN_PROGRESS}:
    raise ValueError("Data is invalid: unprocessable status")

With izulu you can forget about manual error message management all over the codebase!

class ValidationError(Error):
    __template__ = "Data is invalid: {reason}"

class AmountValidationError(ValidationError):
    __template__ = f"{ValidationError.__template__} ({{amount}})"


if not data:
    raise ValidationError(reason="no data")

amount = data["amount"]
if amount < 0:
    raise AmountValidationError(reason="amount can't be negative", amount=amount)
elif amount > 1000:
    raise AmountValidationError(reason="amount is too large", amount=amount)

if data["status"] not in {"READY", "IN_PROGRESS}:
    raise ValidationError(reason="unprocessable status")

Provide only variable data for error instantiations. Keep static data within error class.

Under the hood kwargs are used to format __template__ into final error message.

Neat #2: Attribute errors with useful fields

from falcon import HTTPBadRequest

class AmountValidationError(ValidationError):
    __template__ = "Data is invalid: {reason} ({amount})"
    amount: int


try:
    validate(data)
except AmountValidationError as e:
    if e.amount < 0:
        raise HTTPBadRequest(f"Bad amount: {e.amount}")
    raise

Annotated instance attributes automatically populated from kwargs.

Neat #3: Static and dynamic defaults

class AmountValidationError(ValidationError):
    __template__ = "Data is invalid: {reason} ({amount}; MAX={_MAX}) at {ts}"
    _MAX: ClassVar[int] = 1000
    amount: int
    reason: str = "amount is too large"
    ts: datetime = factory(datetime.now)


print(AmountValidationError(amount=15000))
# Data is invalid: amount is too large (15000; MAX=1000) at 2024-01-13 22:59:25.132699

print(AmountValidationError(amount=-1, reason="amount can't be negative"))
# Data is invalid: amount can't be negative (-1; MAX=1000) at 2024-01-13 22:59:54.482577

Quickstart

Let’s start with defining our initial error class (exception)

  1. subclass Error

  2. provide special message template for each of your exceptions

  3. use only kwargs to instantiate exception (no more message copying across the codebase)

class MyError(Error):
    __template__ = "Having count={count} for owner={owner}"


print(MyError(count=10, owner="me"))
# MyError: Having count=10 for owner=me

MyError(10, owner="me")
# TypeError: __init__() takes 1 positional argument but 2 were given

Move on and improve our class with attributes

  1. define annotations for fields you want to publish as exception instance attributes

  2. you have to define desired template fields in annotations too (see AttributeError for owner)

  3. you can provide annotation for attributes not included in template (see timestamp)

  4. type hinting from annotations are not enforced or checked (see timestamp)

class MyError(Error):
    __template__ = "Having count={count} for owner={owner}"
    count: int
    timestamp: datetime

e = MyError(count=10, owner="me", timestamp=datetime.now())

print(e.count)
# 10
print(e.timestamp)
# 2023-09-27 18:18:22.957925

e.owner
# AttributeError: 'MyError' object has no attribute 'owner'

We can provide defaults for our attributes

  1. define default static values after field annotation just as usual

  2. for dynamic defaults use provided factory tool with your callable - it would be evaluated without arguments during exception instantiation

  3. now fields would receive values from kwargs if present - otherwise from defaults

class MyError(Error):
    __template__ = "Having count={count} for owner={owner}"
    count: int
    owner: str = "nobody"
    timestamp: datetime = factory(datetime.now)

e = MyError(count=10)

print(e.count)
# 10
print(e.owner)
# nobody
print(e.timestamp)
# 2023-09-27 18:19:37.252577

Dynamic defaults also supported

class MyError(Error):
    __template__ = "Having count={count} for owner={owner}"

    count: int
    begin: datetime
    owner: str = "nobody"
    timestamp: datetime = factory(datetime.now)
    duration: timedelta = factory(lambda self: self.timestamp - self.begin, self=True)


begin = datetime.fromordinal(date.today().toordinal())
e = MyError(count=10, begin=begin)

print(e.begin)
# 2023-09-27 00:00:00
print(e.duration)
# 18:45:44.502490
print(e.timestamp)
# 2023-09-27 18:45:44.502490
  • very similar to dynamic defaults, but callable must accept single argument - your exception fresh instance

  • don’t forget to provide second True argument for factory tool (keyword or positional - doesn’t matter)

Specifications

izulu bases on class definitions to provide handy instance creation.

The 6 pillars of izulu

  • all behavior is defined on the class-level

  • __template__ class attribute defines the template for target error message

    • template may contain “fields” for substitution from kwargs and “defaults” to produce final error message

  • __features__ class attribute defines constraints and behaviour (see “Features” section below)

    • by default all constraints are enabled

  • “class hints” annotated with ClassVar are noted by izulu

    • annotated class attributes normally should have values (treated as “class defaults”)

    • “class defaults” can only be static

    • “class defaults” may be referred within __template__

  • “instance hints” regularly annotated (not with ClassVar) are noted by izulu

    • all annotated attributes are treated as “instance attributes”

    • each “instance attribute” will automatically obtain value from the kwarg of the same name

    • “instance attributes” with default are also treated as “instance defaults”

    • “instance defaults” may be static and dynamic

    • “instance defaults” may be referred within __template__

  • kwargs — the new and main way to form exceptions/error instance

    • forget about creating exception instances from message strings

    • kwargs are the datasource for template “fields” and “instance attributes” (shared input for templating attribution)

Mechanics

  • inheritance from izulu.root.Error is required

class AmountError(Error):
    pass
  • optionally behaviour can be adjusted with __features__ (not recommended)

class AmountError(Error):
    __features__ = Features.DEFAULT ^ Features.FORBID_UNDECLARED_FIELDS
  • you should provide a template for the target error message with __template__

    class AmountError(Error):
        __template__ = "Data is invalid: {reason} (amount={amount})"
    
    print(AmountError(reason="negative amount", amount=-10.52))
    # [2024-01-23 19:16] Data is invalid: negative amount (amount=-10.52)
  • error instantiation requires data to format __template__

    • all data for __template__ fields must be provided

      class AmountError(Error):
          __template__ = "Data is invalid: {reason} (amount={amount})"
      
      print(AmountError(reason="amount can't be negative", amount=-10))
      # Data is invalid: amount can't be negative (amount=-10)
      
      AmountError()
      # TypeError: Missing arguments: 'reason', 'amount'
      AmountError(amount=-10)
      # TypeError: Missing arguments: 'reason'
    • only named arguments allowed: __init__() accepts only kwargs

      class AmountError(Error):
          __template__ = "Data is invalid: {reason} (amount={amount})"
      
      print(AmountError(reason="amount can't be negative", amount=-10))
      # Data is invalid: amount can't be negative (amount=-10)
      
      AmountError("amount can't be negative", -10)
      # TypeError: __init__() takes 1 positional argument but 3 were given
      AmountError("amount can't be negative", amount=-10)
      # TypeError: __init__() takes 1 positional argument but 2 were given
  • “class defaults” can be defined and used

    • “class defaults” must be type hinted with ClassVar annotation and provide static values

    • template “fields” may refer “class defaults”

class AmountError(Error):
    LIMIT: ClassVar[int] = 10_000
    __template__ = "Amount is too large: amount={amount} limit={LIMIT}"
    amount: int

print(AmountError(amount=10_500))
# Amount is too large: amount=10500 limit=10000
  • “instance attributes” are populated from relevant kwargs

class AmountError(Error):
    amount: int

print(AmountError(amount=-10).amount)
# -10
  • instance and class attribute types from annotations are not validated or enforced (izulu uses type hints just for attribute discovery and only ClassVar marker is processed for instance/class segregation)

class AmountError(Error):
    amount: int

print(AmountError(amount="lots of money").amount)
# lots of money
  • static “instance defaults” can be provided regularly with instance type hints and static values

class AmountError(Error):
    amount: int = 500

print(AmountError().amount)
# 500
  • dynamic “instance defaults” are also supported

    • they must be type hinted and have special value

    • value must be a callable object wrapped with factory helper

    • factory provides 2 modes depending on value of the self flag:

      • self=False (default): callable accepting no arguments

        class AmountError(Error):
            ts: datetime = factory(datetime.now)
        
        print(AmountError().ts)
        # 2024-01-23 23:18:22.019963
      • self=True: provide callable accepting single argument (error instance)

        class AmountError(Error):
            LIMIT = 10_000
            amount: int
            overflow: int = factory(lambda self: self.amount - self.LIMIT, self=True)
        
        print(AmountError(amount=10_500).overflow)
        # 500
  • “instance defaults” and “instance attributes” may be referred in __template__

class AmountError(Error):
    __template__ = "[{ts:%Y-%m-%d %H:%M}] Amount is too large: {amount}"
    amount: int
    ts: datetime = factory(datetime.now)

print(AmountError(amount=10_500))
# [2024-01-23 23:21] Amount is too large: 10500
  • Pause and sum up: defaults, attributes and template

class AmountError(Error):
    LIMIT: ClassVar[int] = 10_000
    __template__ = "[{ts:%Y-%m-%d %H:%M}] Amount is too large: amount={amount} limit={LIMIT} overflow={overflow}"
    amount: int
    overflow: int = factory(lambda self: self.amount - self.LIMIT, self=True)
    ts: datetime = factory(datetime.now)

err = AmountError(amount=15_000)

print(err.amount)
# 15000
print(err.LIMIT)
# 10000
print(err.overflow)
# 5000
print(err.ts)
# 2024-01-23 23:21:26

print(err)
# [2024-01-23 23:21] Amount is too large: amount=15000 limit=10000 overflow=5000
  • kwargs overlap “instance defaults”

class AmountError(Error):
    LIMIT: ClassVar[int] = 10_000
    __template__ = "[{ts:%Y-%m-%d %H:%M}] Amount is too large: amount={amount} limit={LIMIT} overflow={overflow}"
    amount: int = 15_000
    overflow: int = factory(lambda self: self.amount - self.LIMIT, self=True)
    ts: datetime = factory(datetime.now)

print(AmountError())
# [2024-01-23 23:21] Amount is too large: amount=15000 limit=10000 overflow=5000

print(AmountError(amount=10_333, overflow=42, ts=datetime(1900, 1, 1)))
# [2024-01-23 23:21] Amount is too large: amount=10333 limit=10000 overflow=42
  • izulu provides flexibility for templates, fields, attributes and defaults

    • “defaults” are not required to be __template__ “fields”

      class AmountError(Error):
          LIMIT: ClassVar[int] = 10_000
          __template__ = "Amount is too large"
      
      print(AmountError().LIMIT)
      # 10000
      print(AmountError())
      # Amount is too large
    • there can be hints for attributes not present in error message template

      class AmountError(Error):
          __template__ = "Amount is too large"
          amount: int
      
      print(AmountError(amount=500).amount)
      # 500
      print(AmountError(amount=500))
      # Amount is too large
    • “fields” don’t have to be hinted as instance attributes

      class AmountError(Error):
          __template__ = "Amount is too large: {amount}"
      
      print(AmountError(amount=500))
      # Amount is too large: 500
      print(AmountError(amount=500).amount)
      # AttributeError: 'AmountError' object has no attribute 'amount'

Features

The izulu error class behaviour is controlled by __features__ class attribute.

(For details about “runtime” and “class definition” stages see Validation and behavior in case of problems below)

Supported features

  • FORBID_MISSING_FIELDS: checks provided kwargs contain data for all template “fields” and “instance attributes” that have no “defaults”

    • always should be enabled (provides consistent and detailed TypeError exceptions for appropriate arguments)

    • if disabled raw exceptions from izulu machinery internals could appear

    Stage

    Raises

    runtime

    TypeError

class AmountError(Error):
    __template__ = "Some {amount} of money for {client_id} client"
    client_id: int

# I. enabled
AmountError()
# TypeError: Missing arguments: client_id, amount

# II. disabled
AmountError.__features__ ^= Features.FORBID_MISSING_FIELDS

AmountError()
# ValueError: Failed to format template with provided kwargs:
  • FORBID_UNDECLARED_FIELDS: forbids undefined arguments in provided kwargs (names not present in template “fields” and “instance/class hints”)

    • if disabled allows and completely ignores unknown data in kwargs

    Stage

    Raises

    runtime

    TypeError

class MyError(Error):
    __template__ = "My error occurred"

# I. enabled
MyError(unknown_data="data")
# Undeclared arguments: unknown_data

# II. disabled
MyError.__features__ ^= Features.FORBID_UNDECLARED_FIELDS
err = MyError(unknown_data="data")

print(err)
# Unspecified error
print(repr(err))
# __main__.MyError(unknown_data='data')
err.unknown_data
# AttributeError: 'MyError' object has no attribute 'unknown_data'
  • FORBID_KWARG_CONSTS: checks provided kwargs not to contain attributes defined as ClassVar

    • if disabled allows data in kwargs to overlap class attributes during template formatting

    • overlapping data won’t modify class attribute values

    Stage

    Raises

    runtime

    TypeError

class MyError(Error):
    __template__ = "My error occurred {_TYPE}"
    _TYPE: ClassVar[str]

# I. enabled
MyError(_TYPE="SOME_ERROR_TYPE")
# TypeError: Constants in arguments: _TYPE

# II. disabled
MyError.__features__ ^= Features.FORBID_KWARG_CONSTS
err = MyError(_TYPE="SOME_ERROR_TYPE")

print(err)
# My error occurred SOME_ERROR_TYPE
print(repr(err))
# __main__.MyError(_TYPE='SOME_ERROR_TYPE')
err._TYPE
# AttributeError: 'MyError' object has no attribute '_TYPE'
  • FORBID_NON_NAMED_FIELDS: forbids empty and digit field names in __template__

    • if disabled validation (runtime issues)

    • izulu relies on kwargs and named fields

    • by default it’s forbidden to provide empty ({}) and digit ({0}) fields in __template__

    Stage

    Raises

    class definition

    ValueError

class MyError(Error):
    __template__ = "My error occurred {_TYPE}"
    _TYPE: ClassVar[str]

# I. enabled
MyError(_TYPE="SOME_ERROR_TYPE")
# TypeError: Constants in arguments: _TYPE

# II. disabled
MyError.__features__ ^= Features.FORBID_KWARG_CONSTS
err = MyError(_TYPE="SOME_ERROR_TYPE")

print(err)
# My error occurred SOME_ERROR_TYPE
print(repr(err))
# __main__.MyError(_TYPE='SOME_ERROR_TYPE')
err._TYPE
# AttributeError: 'MyError' object has no attribute '_TYPE'

Tuning __features__

Features are represented as “Flag Enum”, so you can use regular operations to configure desired behaviour. Examples:

  • Use single option

class AmountError(Error):
    __features__ = Features.FORBID_MISSING_FIELDS
  • Use presets

class AmountError(Error):
    __features__ = Features.NONE
  • Combining wanted features:

class AmountError(Error):
    __features__ = Features.FORBID_MISSING_FIELDS | Features.FORBID_KWARG_CONSTS
  • Discarding unwanted feature from default feature set:

class AmountError(Error):
    __features__ = Features.DEFAULT ^ Features.FORBID_UNDECLARED_FIELDS

Validation and behavior in case of problems

izulu may trigger native Python exceptions on invalid data during validation process. By default you should expect following ones

  • TypeError: argument constraints issues

  • ValueError: template and formatting issues

Some exceptions are raised from original exception (e.g. template formatting issues), so you can check e.__cause__ and traceback output for details.

The validation behavior depends on the set of enabled features. Changing feature set may cause different and raw exceptions being raised. Read and understand “Features” section to predict and experiment with different situations and behaviours.

izulu has 2 validation stages:

  • class definition stage

    • validation is made during error class definition

      # when you import error module
      from izulu import root
      
      # when you import error from module
      from izulu.root import Error
      
      # when you interactively define new error classes
      class MyError(Error):
          pass
    • class attributes __template__ and __features__ are validated

      class MyError(Error):
          __template__ = "Hello {}"
      
      # ValueError: Field names can't be empty
  • runtime stage

    • validation is made during error instantiation

      root.Error()
    • kwargs are validated according to enabled features

      class MyError(Error):
          __template__ = "Hello {name}"
      
      MyError()
      # TypeError: Missing arguments: 'name'

Additional APIs

Representations

class AmountValidationError(Error):
    __template__ = "Data is invalid: {reason} ({amount}; MAX={_MAX}) at {ts}"
    _MAX: ClassVar[int] = 1000
    amount: int
    reason: str = "amount is too large"
    ts: datetime = factory(datetime.now)


err = AmountValidationError(amount=15000)

print(str(err))
# Data is invalid: amount is too large (15000; MAX=1000) at 2024-01-13 23:33:13.847586

print(repr(err))
# __main__.AmountValidationError(amount=15000, ts=datetime.datetime(2024, 1, 13, 23, 33, 13, 847586), reason='amount is too large')
  • str and repr output differs

  • str is for humans and Python (Python dictates the result to be exactly and only the message)

  • repr allows to reconstruct the same error instance from its output (if data provided into kwargs supports repr the same way)

    note: class name is fully qualified name of class (dot-separated module full path with class name)

    reconstructed = eval(repr(err).replace("__main__.", "", 1))
    
    print(str(reconstructed))
    # Data is invalid: amount is too large (15000; MAX=1000) at 2024-01-13 23:33:13.847586
    
    print(repr(reconstructed))
    # AmountValidationError(amount=15000, ts=datetime.datetime(2024, 1, 13, 23, 33, 13, 847586), reason='amount is too large')
  • in addition to str there is another human-readable representations provided by .as_str() method; it prepends message with class name:

    print(err.as_str())
    # AmountValidationError: Data is invalid: amount is too large (15000; MAX=1000) at 2024-01-13 23:33:13.847586

Pickling, dumping and loading

Pickling

izulu-based errors support pickling by default.

Dumping
  • .as_kwargs() dumps shallow copy of original kwargs

err.as_kwargs()
# {'amount': 15000}
  • .as_dict() by default, combines original kwargs and all “instance attribute” values into “full state”

    err.as_dict()
    # {'amount': 15000, 'ts': datetime(2024, 1, 13, 23, 33, 13, 847586), 'reason': 'amount is too large'}

    Additionally, there is the wide flag for enriching the result with “class defaults” (note additional _MAX data)

    err.as_dict(True)
    # {'amount': 15000, 'ts': datetime(2024, 1, 13, 23, 33, 13, 847586), 'reason': 'amount is too large', '_MAX': 1000}

    Data combination process follows prioritization — if there are multiple values for same name then high priority data will overlap data with lower priority. Here is the prioritized list of data sources:

    1. kwargs (max priority)

    2. “instance attributes”

    3. “class defaults”

Loading
  • .as_kwargs() result can be used to create inaccurate copy of original error, but pay attention to dynamic factories — datetime.now(), uuid() and many others would produce new values for data missing in kwargs (note ts field in the example below)

inaccurate_copy = AmountValidationError(**err.as_kwargs())

print(inaccurate_copy)
# Data is invalid: amount is too large (15000; MAX=1000) at 2024-02-01 21:11:21.681080
print(repr(inaccurate_copy))
# __main__.AmountValidationError(amount=15000, reason='amount is too large', ts=datetime.datetime(2024, 2, 1, 21, 11, 21, 681080))
  • .as_dict() result can be used to create accurate copy of original error; flag wide should be False by default according to FORBID_KWARG_CONSTS restriction (if you disable FORBID_KWARG_CONSTS then you may need to use wide=True depending on your situation)

accurate_copy = AmountValidationError(**err.as_dict())

print(accurate_copy)
# Data is invalid: amount is too large (15000; MAX=1000) at 2024-02-01 21:11:21.681080
print(repr(accurate_copy))
# __main__.AmountValidationError(amount=15000, reason='amount is too large', ts=datetime.datetime(2024, 2, 1, 21, 11, 21, 681080))

(advanced) Wedge

There is a special method you can override and additionally manage the machinery.

But it should not be need in 99,9% cases. Avoid it, please.

def _hook(self,
          store: _utils.Store,
          kwargs: dict[str, t.Any],
          msg: str) -> str:
    """Adapter method to wedge user logic into izulu machinery

    This is the place to override message/formatting if regular mechanics
    don't work for you. It has to return original or your flavored message.
    The method is invoked between izulu preparations and original
    `Exception` constructor receiving the result of this hook.

    You can also do any other logic here. You will be provided with
    complete set of prepared data from izulu. But it's recommended
    to use classic OOP inheritance for ordinary behaviour extension.

    Params:
      * store: dataclass containing inner error class specifications
      * kwargs: original kwargs from user
      * msg: formatted message from the error template
    """

    return msg

Tips

1. inheritance / root exception

# intermediate class to centrally control the default behaviour
class BaseError(Error):  # <-- inherit from this in your code (not directly from ``izulu``)
    __features__ = Features.None


class MyRealError(BaseError):
    __template__ = "Having count={count} for owner={owner}"

2. factories

TODO: self=True / self.as_kwargs() (as_dict forbidden? - recursion)

  • stdlib factories

from uuid import uuid4

class MyError(Error):
    id: datetime = factory(uuid4)
    timestamp: datetime = factory(datetime.now)
  • lambdas

class MyError(Error):
    timestamp: datetime = factory(lambda: datetime.now().isoformat())
  • function

from random import randint

def flip_coin():
    return "TAILS" if randint(0, 100) % 2 else "HEADS

class MyError(Error):
    coin: str = factory(flip_coin)
  • method

class MyError(Error):
    __template__ = "Having count={count} for owner={owner}"

    def __make_duration(self) -> timedelta:
        kwargs = self.as_kwargs()
        return self.timestamp - kwargs["begin"]

    timestamp: datetime = factory(datetime.now)
    duration: timedelta = factory(__make_duration, self=True)


begin = datetime.fromordinal(date.today().toordinal())
e = MyError(count=10, begin=begin)

print(e.begin)
# 2023-09-27 00:00:00
print(e.duration)
# 18:45:44.502490
print(e.timestamp)
# 2023-09-27 18:45:44.502490

3. handling errors in presentation layers / APIs

err = Error()
view = RespModel(error=err.as_dict(wide=True)


class MyRealError(BaseError):
    __template__ = "Having count={count} for owner={owner}"

Additional examples

TBD

For developers

  • Running tests:

    tox
  • Building package:

    tox -e build
  • Contributing: contact me through Issues

Versioning

SemVer used for versioning. For available versions see the repository tags and releases.

Authors

  • Dima Burmistrov - Initial work - pyctrl

Special thanks to Eugene Frolov for inspiration.

License

This project is licensed under the X11 License (extended MIT) - see the LICENSE file for details

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

izulu-0.5.2.tar.gz (32.1 kB view details)

Uploaded Source

Built Distribution

izulu-0.5.2-py3-none-any.whl (14.5 kB view details)

Uploaded Python 3

File details

Details for the file izulu-0.5.2.tar.gz.

File metadata

  • Download URL: izulu-0.5.2.tar.gz
  • Upload date:
  • Size: 32.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/5.0.0 CPython/3.9.1

File hashes

Hashes for izulu-0.5.2.tar.gz
Algorithm Hash digest
SHA256 0c1276b4a2bbba0b47412302c5e1c5b667e651568c6644ae019131b48de84a89
MD5 bd39004eb052243e5883a388be78a036
BLAKE2b-256 99429d311e6edf847f146ac9886e40138104a0334f925c8862eeba683c912751

See more details on using hashes here.

File details

Details for the file izulu-0.5.2-py3-none-any.whl.

File metadata

  • Download URL: izulu-0.5.2-py3-none-any.whl
  • Upload date:
  • Size: 14.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/5.0.0 CPython/3.9.1

File hashes

Hashes for izulu-0.5.2-py3-none-any.whl
Algorithm Hash digest
SHA256 d8d315e7b63c99ec424b0f7cfc365f84db2d939f116509bbec9e5005844a982a
MD5 f400c5b685e4a67e17c8149efe668934
BLAKE2b-256 077fda0c31c80e63312a2748745cb7599926a8dba212676eb87ccbebfdcebec7

See more details on using hashes here.

Supported by

AWS AWS Cloud computing and Security Sponsor Datadog Datadog Monitoring Fastly Fastly CDN Google Google Download Analytics Microsoft Microsoft PSF Sponsor Pingdom Pingdom Monitoring Sentry Sentry Error logging StatusPage StatusPage Status page