Skip to content

Commit

Permalink
refactor: udfs inside numaflow
Browse files Browse the repository at this point in the history
Signed-off-by: Avik Basu <ab93@users.noreply.github.com>
  • Loading branch information
ab93 committed Jul 13, 2023
1 parent 376bb6c commit 67a19e1
Show file tree
Hide file tree
Showing 9 changed files with 530 additions and 0 deletions.
Binary file added docs/assets/anomalydetection.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
45 changes: 45 additions & 0 deletions numalogic/config/_conn.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from dataclasses import dataclass, field

Check warning on line 1 in numalogic/config/_conn.py

View check run for this annotation

Codecov / codecov/patch

numalogic/config/_conn.py#L1

Added line #L1 was not covered by tests


@dataclass
class PrometheusConf:
server: str
pushgateway: str

Check warning on line 7 in numalogic/config/_conn.py

View check run for this annotation

Codecov / codecov/patch

numalogic/config/_conn.py#L5-L7

Added lines #L5 - L7 were not covered by tests


@dataclass
class RedisConf:
host: str
port: int
expiry: int = 300
master_name: str = "mymaster"

Check warning on line 15 in numalogic/config/_conn.py

View check run for this annotation

Codecov / codecov/patch

numalogic/config/_conn.py#L11-L15

Added lines #L11 - L15 were not covered by tests


@dataclass
class DruidConf:
url: str
endpoint: str

Check warning on line 21 in numalogic/config/_conn.py

View check run for this annotation

Codecov / codecov/patch

numalogic/config/_conn.py#L19-L21

Added lines #L19 - L21 were not covered by tests


@dataclass
class Pivot:
index: str = "timestamp"
columns: list[str] = field(default_factory=list)

Check warning on line 27 in numalogic/config/_conn.py

View check run for this annotation

Codecov / codecov/patch

numalogic/config/_conn.py#L25-L27

Added lines #L25 - L27 were not covered by tests
value: list[str] = field(default_factory=lambda: ["count"])


@dataclass
class DruidFetcherConf:
from pydruid.utils.aggregators import doublesum

Check warning on line 33 in numalogic/config/_conn.py

View check run for this annotation

Codecov / codecov/patch

numalogic/config/_conn.py#L32-L33

Added lines #L32 - L33 were not covered by tests

datasource: str
dimensions: list[str] = field(default_factory=list)
aggregations: dict = field(default_factory=dict)
group_by: list[str] = field(default_factory=list)

Check warning on line 38 in numalogic/config/_conn.py

View check run for this annotation

Codecov / codecov/patch

numalogic/config/_conn.py#L35-L38

Added lines #L35 - L38 were not covered by tests
pivot: Pivot = field(default_factory=lambda: Pivot())
granularity: str = "minute"
hours: float = 36

Check warning on line 41 in numalogic/config/_conn.py

View check run for this annotation

Codecov / codecov/patch

numalogic/config/_conn.py#L40-L41

Added lines #L40 - L41 were not covered by tests

def __post_init__(self):

Check warning on line 43 in numalogic/config/_conn.py

View check run for this annotation

Codecov / codecov/patch

numalogic/config/_conn.py#L43

Added line #L43 was not covered by tests
if not self.aggregations:
self.aggregations = {"count": self.doublesum("count")}

Check warning on line 45 in numalogic/config/_conn.py

View check run for this annotation

Codecov / codecov/patch

numalogic/config/_conn.py#L45

Added line #L45 was not covered by tests
65 changes: 65 additions & 0 deletions numalogic/config/_metric.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
from dataclasses import dataclass, field
from enum import Enum

Check warning on line 2 in numalogic/config/_metric.py

View check run for this annotation

Codecov / codecov/patch

numalogic/config/_metric.py#L1-L2

Added lines #L1 - L2 were not covered by tests

from omegaconf import MISSING

Check warning on line 4 in numalogic/config/_metric.py

View check run for this annotation

Codecov / codecov/patch

numalogic/config/_metric.py#L4

Added line #L4 was not covered by tests

from numalogic.config._conn import DruidFetcherConf, RedisConf, DruidConf, PrometheusConf
from numalogic.config._numa import NumalogicConf

Check warning on line 7 in numalogic/config/_metric.py

View check run for this annotation

Codecov / codecov/patch

numalogic/config/_metric.py#L6-L7

Added lines #L6 - L7 were not covered by tests


@dataclass
class UnifiedConf:
strategy: str = "max"
weights: list[float] = field(default_factory=list)

Check warning on line 13 in numalogic/config/_metric.py

View check run for this annotation

Codecov / codecov/patch

numalogic/config/_metric.py#L11-L13

Added lines #L11 - L13 were not covered by tests


@dataclass
class ReTrainConf:
train_hours: int = 36
min_train_size: int = 2000
retrain_freq_hr: int = 8
resume_training: bool = False

Check warning on line 21 in numalogic/config/_metric.py

View check run for this annotation

Codecov / codecov/patch

numalogic/config/_metric.py#L17-L21

Added lines #L17 - L21 were not covered by tests


@dataclass
class StaticThresholdConf:
upper_limit: int = 3
weight: float = 0.0

Check warning on line 27 in numalogic/config/_metric.py

View check run for this annotation

Codecov / codecov/patch

numalogic/config/_metric.py#L25-L27

Added lines #L25 - L27 were not covered by tests


@dataclass
class MetricConf:
metric: str

Check warning on line 32 in numalogic/config/_metric.py

View check run for this annotation

Codecov / codecov/patch

numalogic/config/_metric.py#L31-L32

Added lines #L31 - L32 were not covered by tests
retrain_conf: ReTrainConf = field(default_factory=lambda: ReTrainConf())
static_threshold: StaticThresholdConf = field(default_factory=lambda: StaticThresholdConf())
numalogic_conf: NumalogicConf = MISSING

Check warning on line 35 in numalogic/config/_metric.py

View check run for this annotation

Codecov / codecov/patch

numalogic/config/_metric.py#L35

Added line #L35 was not covered by tests


class DataSource(str, Enum):
PROMETHEUS = "prometheus"
DRUID = "druid"

Check warning on line 40 in numalogic/config/_metric.py

View check run for this annotation

Codecov / codecov/patch

numalogic/config/_metric.py#L38-L40

Added lines #L38 - L40 were not covered by tests


@dataclass
class DataStreamConf:
name: str = "default"
source: str = DataSource.PROMETHEUS.value
window_size: int = 12
composite_keys: list[str] = field(default_factory=list)
metrics: list[str] = field(default_factory=list)
metric_configs: list[MetricConf] = field(default_factory=list)

Check warning on line 50 in numalogic/config/_metric.py

View check run for this annotation

Codecov / codecov/patch

numalogic/config/_metric.py#L44-L50

Added lines #L44 - L50 were not covered by tests
unified_config: UnifiedConf = field(default_factory=lambda: UnifiedConf())
druid_fetcher: DruidFetcherConf = MISSING

Check warning on line 52 in numalogic/config/_metric.py

View check run for this annotation

Codecov / codecov/patch

numalogic/config/_metric.py#L52

Added line #L52 was not covered by tests


@dataclass
class Configs:
configs: list[DataStreamConf]

Check warning on line 57 in numalogic/config/_metric.py

View check run for this annotation

Codecov / codecov/patch

numalogic/config/_metric.py#L56-L57

Added lines #L56 - L57 were not covered by tests


@dataclass
class PipelineConf:
redis_conf: RedisConf

Check warning on line 62 in numalogic/config/_metric.py

View check run for this annotation

Codecov / codecov/patch

numalogic/config/_metric.py#L61-L62

Added lines #L61 - L62 were not covered by tests
# registry_conf: RegistryConf
prometheus_conf: PrometheusConf = MISSING
druid_conf: DruidConf = MISSING

Check warning on line 65 in numalogic/config/_metric.py

View check run for this annotation

Codecov / codecov/patch

numalogic/config/_metric.py#L64-L65

Added lines #L64 - L65 were not covered by tests
79 changes: 79 additions & 0 deletions numalogic/config/_numa.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Copyright 2022 The Numaproj Authors.
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.


from dataclasses import dataclass, field
from typing import Optional, Any

Check warning on line 14 in numalogic/config/_numa.py

View check run for this annotation

Codecov / codecov/patch

numalogic/config/_numa.py#L13-L14

Added lines #L13 - L14 were not covered by tests

from omegaconf import MISSING

Check warning on line 16 in numalogic/config/_numa.py

View check run for this annotation

Codecov / codecov/patch

numalogic/config/_numa.py#L16

Added line #L16 was not covered by tests


@dataclass
class ModelInfo:

Check warning on line 20 in numalogic/config/_numa.py

View check run for this annotation

Codecov / codecov/patch

numalogic/config/_numa.py#L20

Added line #L20 was not covered by tests
"""Schema for defining the model/estimator.
Args:
----
name: name of the model; this should map to a supported list of models
mentioned in the factory file
conf: kwargs for instantiating the model class
stateful: flag indicating if the model is stateful or not
"""

name: str = MISSING
conf: dict[str, Any] = field(default_factory=dict)
stateful: bool = True

Check warning on line 33 in numalogic/config/_numa.py

View check run for this annotation

Codecov / codecov/patch

numalogic/config/_numa.py#L31-L33

Added lines #L31 - L33 were not covered by tests


@dataclass
class RegistryInfo:

Check warning on line 37 in numalogic/config/_numa.py

View check run for this annotation

Codecov / codecov/patch

numalogic/config/_numa.py#L37

Added line #L37 was not covered by tests
"""Registry config base class.
Args:
----
name: name of the registry
conf: kwargs for instantiating the model class
"""

name: str = MISSING
conf: dict[str, Any] = field(default_factory=dict)

Check warning on line 47 in numalogic/config/_numa.py

View check run for this annotation

Codecov / codecov/patch

numalogic/config/_numa.py#L46-L47

Added lines #L46 - L47 were not covered by tests


@dataclass
class LightningTrainerConf:

Check warning on line 51 in numalogic/config/_numa.py

View check run for this annotation

Codecov / codecov/patch

numalogic/config/_numa.py#L51

Added line #L51 was not covered by tests
"""Schema for defining the Pytorch Lightning trainer behavior.
More details on the arguments are provided here:
https://pytorch-lightning.readthedocs.io/en/stable/common/trainer.html#trainer-class-api
"""

accelerator: str = "auto"
max_epochs: int = 100
logger: bool = False
check_val_every_n_epoch: int = 5
log_every_n_steps: int = 20
enable_checkpointing: bool = False
enable_progress_bar: bool = True
enable_model_summary: bool = True
limit_val_batches: bool = 0
callbacks: Optional[Any] = None

Check warning on line 67 in numalogic/config/_numa.py

View check run for this annotation

Codecov / codecov/patch

numalogic/config/_numa.py#L58-L67

Added lines #L58 - L67 were not covered by tests


@dataclass
class NumalogicConf:

Check warning on line 71 in numalogic/config/_numa.py

View check run for this annotation

Codecov / codecov/patch

numalogic/config/_numa.py#L71

Added line #L71 was not covered by tests
"""Top level config schema for numalogic."""

model: ModelInfo = field(default_factory=ModelInfo)
trainer: LightningTrainerConf = field(default_factory=LightningTrainerConf)
registry: RegistryInfo = field(default_factory=RegistryInfo)
preprocess: list[ModelInfo] = field(default_factory=list)
threshold: ModelInfo = field(default_factory=ModelInfo)
postprocess: ModelInfo = field(default_factory=ModelInfo)

Check warning on line 79 in numalogic/config/_numa.py

View check run for this annotation

Codecov / codecov/patch

numalogic/config/_numa.py#L74-L79

Added lines #L74 - L79 were not covered by tests
4 changes: 4 additions & 0 deletions numalogic/connectors/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from numalogic.connectors.druid import DruidFetcher
from numalogic.connectors.prometheus import PrometheusDataFetcher

Check warning on line 2 in numalogic/connectors/__init__.py

View check run for this annotation

Codecov / codecov/patch

numalogic/connectors/__init__.py#L1-L2

Added lines #L1 - L2 were not covered by tests

__all__ = ["DruidFetcher", "PrometheusDataFetcher"]

Check warning on line 4 in numalogic/connectors/__init__.py

View check run for this annotation

Codecov / codecov/patch

numalogic/connectors/__init__.py#L4

Added line #L4 was not covered by tests
3 changes: 3 additions & 0 deletions numalogic/numaflow/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Anomaly Detection

![anomalydetection](../../docs/assets/anomalydetection.png)
149 changes: 149 additions & 0 deletions numalogic/numaflow/entities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
from copy import copy
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Union, TypeVar

Check warning on line 4 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L1-L4

Added lines #L1 - L4 were not covered by tests

import numpy as np
import numpy.typing as npt
import orjson
import pandas as pd

Check warning on line 9 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L6-L9

Added lines #L6 - L9 were not covered by tests

Vector = list[float]
Matrix = Union[Vector, list[Vector], npt.NDArray[float]]

Check warning on line 12 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L11-L12

Added lines #L11 - L12 were not covered by tests


class Status(str, Enum):

Check warning on line 15 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L15

Added line #L15 was not covered by tests
"""Status is the enum that is used to identify the status of the payload."""

RAW = "raw"
EXTRACTED = "extracted"
PRE_PROCESSED = "pre_processed"
INFERRED = "inferred"
THRESHOLD = "threshold_complete"
POST_PROCESSED = "post_processed"
ARTIFACT_NOT_FOUND = "artifact_not_found"
ARTIFACT_STALE = "artifact_is_stale"
RUNTIME_ERROR = "runtime_error"

Check warning on line 26 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L18-L26

Added lines #L18 - L26 were not covered by tests


class Header(str, Enum):

Check warning on line 29 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L29

Added line #L29 was not covered by tests
"""Header is the enum that is used to identify the type of payload."""

STATIC_INFERENCE = "static_threshold"
MODEL_INFERENCE = "model_inference"
TRAIN_REQUEST = "request_training"
MODEL_STALE = "model_stale"

Check warning on line 35 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L32-L35

Added lines #L32 - L35 were not covered by tests


@dataclass
class _BasePayload:

Check warning on line 39 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L39

Added line #L39 was not covered by tests
"""_BasePayload is the base data structure that is passed."""

uuid: str
composite_keys: list[str]

Check warning on line 43 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L42-L43

Added lines #L42 - L43 were not covered by tests


PayloadType = TypeVar("PayloadType", bound=_BasePayload)

Check warning on line 46 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L46

Added line #L46 was not covered by tests


@dataclass
class TrainerPayload(_BasePayload):

Check warning on line 50 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L50

Added line #L50 was not covered by tests
"""
TrainerPayload is the data structure that is passed
around in the system when a training request is made.
"""

metric: str
header: Header = Header.TRAIN_REQUEST

Check warning on line 57 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L56-L57

Added lines #L56 - L57 were not covered by tests

def to_json(self):
return orjson.dumps(self)

Check warning on line 60 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L59-L60

Added lines #L59 - L60 were not covered by tests


@dataclass(repr=False)
class StreamPayload(_BasePayload):

Check warning on line 64 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L64

Added line #L64 was not covered by tests
"""StreamPayload is the main data structure that is passed around in the system."""

data: Matrix
raw_data: Matrix
metrics: list[str]
timestamps: list[int]
status: dict[str, Status] = field(default_factory=dict)
header: dict[str, Header] = field(default_factory=dict)
metadata: dict[str, dict[str, Any]] = field(default_factory=dict)

Check warning on line 73 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L67-L73

Added lines #L67 - L73 were not covered by tests

def get_df(self, original=False) -> pd.DataFrame:
return pd.DataFrame(self.get_data(original), columns=self.metrics)

Check warning on line 76 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L75-L76

Added lines #L75 - L76 were not covered by tests

def set_data(self, arr: Matrix) -> None:
self.data = arr

Check warning on line 79 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L78-L79

Added lines #L78 - L79 were not covered by tests

def set_metric_data(self, metric: str, arr: Matrix) -> None:
_df = self.get_df().copy()
_df[metric] = arr
self.set_data(np.asarray(_df.values.tolist()))

Check warning on line 84 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L81-L84

Added lines #L81 - L84 were not covered by tests

def get_metric_arr(self, metric: str) -> npt.NDArray[float]:
return self.get_df()[metric].values

Check warning on line 87 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L86-L87

Added lines #L86 - L87 were not covered by tests

def get_data(self, original=False) -> npt.NDArray[float]:

Check warning on line 89 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L89

Added line #L89 was not covered by tests
if original:
return np.asarray(self.raw_data)
return np.asarray(self.data)

Check warning on line 92 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L91-L92

Added lines #L91 - L92 were not covered by tests

def set_status(self, metric: str, status: Status) -> None:
self.status[metric] = status

Check warning on line 95 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L94-L95

Added lines #L94 - L95 were not covered by tests

def set_header(self, metric: str, header: Header) -> None:
self.header[metric] = header

Check warning on line 98 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L97-L98

Added lines #L97 - L98 were not covered by tests

def set_metric_metadata(self, metric: str, key: str, value) -> None:

Check warning on line 100 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L100

Added line #L100 was not covered by tests
if metric in self.metadata.keys():
self.metadata[metric][key] = value

Check warning on line 102 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L102

Added line #L102 was not covered by tests
else:
self.metadata[metric] = {key: value}

Check warning on line 104 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L104

Added line #L104 was not covered by tests

def set_metadata(self, key: str, value) -> None:
self.metadata[key] = value

Check warning on line 107 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L106-L107

Added lines #L106 - L107 were not covered by tests

def get_metadata(self, key: str) -> dict[str, Any]:
return copy(self.metadata[key])

Check warning on line 110 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L109-L110

Added lines #L109 - L110 were not covered by tests

def __repr__(self) -> str:

Check warning on line 112 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L112

Added line #L112 was not covered by tests
"""Return a string representation of the object."""
return "header: {}, status: {}, composite_keys: {}, data: {}, metadata: {}}}".format(

Check warning on line 114 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L114

Added line #L114 was not covered by tests
self.header,
self.status,
self.composite_keys,
list(self.data),
self.metadata,
)

def to_json(self):
return orjson.dumps(self, option=orjson.OPT_SERIALIZE_NUMPY)

Check warning on line 123 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L122-L123

Added lines #L122 - L123 were not covered by tests


@dataclass
class InputPayload:

Check warning on line 127 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L127

Added line #L127 was not covered by tests
"""Input payload."""

start_time: int
end_time: int
data: list[dict[str, Any]]
metadata: dict[str, Any]

Check warning on line 133 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L130-L133

Added lines #L130 - L133 were not covered by tests

def to_json(self):
return orjson.dumps(self, option=orjson.OPT_SERIALIZE_NUMPY)

Check warning on line 136 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L135-L136

Added lines #L135 - L136 were not covered by tests


@dataclass
class OutputPayload:

Check warning on line 140 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L140

Added line #L140 was not covered by tests
"""Output payload."""

timestamp: int
unified_anomaly: float
data: dict[str, Any]
metadata: dict[str, Any]

Check warning on line 146 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L143-L146

Added lines #L143 - L146 were not covered by tests

def to_json(self):
return orjson.dumps(self, option=orjson.OPT_SERIALIZE_NUMPY)

Check warning on line 149 in numalogic/numaflow/entities.py

View check run for this annotation

Codecov / codecov/patch

numalogic/numaflow/entities.py#L148-L149

Added lines #L148 - L149 were not covered by tests
Empty file.
Loading

0 comments on commit 67a19e1

Please sign in to comment.