Skip to content

Commit

Permalink
Overhaul and improve InvalidRequest exceptions
Browse files Browse the repository at this point in the history
Closes #53.
  • Loading branch information
parafoxia committed Feb 19, 2023
1 parent cdfeaac commit 5666df2
Show file tree
Hide file tree
Showing 11 changed files with 320 additions and 184 deletions.
81 changes: 69 additions & 12 deletions analytix/errors.py
Expand Up @@ -106,20 +106,77 @@ class InvalidRequest(AnalytixError):
"""Exception thrown when a request to be made to the YouTube
Analytics API is not valid."""

@staticmethod
def list_of(values: set[str]) -> str:
items = tuple(f"{v!r}" for v in sorted(values))

class InvalidFeatures(InvalidRequest):
"""A helper exception class for `InvalidRequest`. When catching
exceptions, use `InvalidRequest` instead."""
if len(items) > 2:
return f"{', '.join(items[:-1])}, and {items[-1]}"

def __init__(self, ctx: str, errors: set[str]) -> None:
err_list = ", ".join(errors)
super().__init__(f"{ctx}: {err_list}")
return f" and ".join(items)

@classmethod
def invalid(cls, key: str, values: set[str]) -> InvalidRequest:
plural = "s" if len(values) > 1 else ""
return cls(f"invalid {key}{plural} provided: " + cls.list_of(values))

class InvalidFeatureSet(InvalidRequest):
"""A helper exception class for `InvalidRequest`. When catching
exceptions, use `InvalidRequest` instead."""
@classmethod
def incompatible_dimensions(cls, values: set[str]) -> InvalidRequest:
return cls(f"dimensions {cls.list_of(values)} cannot be used together")

@classmethod
def incompatible_filters(cls, values: set[str]) -> InvalidRequest:
return cls(f"filters {cls.list_of(values)} cannot be used together")

@classmethod
def invalid_filter_value(cls, key: str, value: str) -> InvalidRequest:
return cls(f"invalid value {value!r} for filter {key!r}")

@classmethod
def incompatible_filter_value(cls, key: str, value: str) -> InvalidRequest:
return cls(
f"value {value!r} for filter {key!r} cannot be used with the given "
"dimensions"
)

@classmethod
def incompatible_metrics(cls, values: set[str]) -> InvalidRequest:
plural = "s" if len(values) > 1 else ""
return cls(
f"metric{plural} {cls.list_of(values)} cannot be used with the given "
"dimensions and filters"
)

@classmethod
def incompatible_sort_options(cls, values: set[str]) -> InvalidRequest:
plural = "s" if len(values) > 1 else ""
return cls(
f"sort option{plural} {cls.list_of(values)} cannot be used with the given "
"dimensions and filters"
)

def __init__(self, type: str, expd: str, recv: int, values: set[str]) -> None:
val_list = ", ".join(values)
super().__init__(f"expected {expd} {type}(s) from [ {val_list} ], got {recv}")
@classmethod
def non_matching_sort_options(cls, values: set[str]) -> InvalidRequest:
plural = "s" if len(values) > 1 else ""
isare = "are" if plural else "is"
return cls(
f"sort option{plural} {cls.list_of(values)} {isare} not part of the given "
"metrics"
)

@classmethod
def invalid_set(
cls, key: str, values: set[str], expd: str, recv: int
) -> InvalidRequest:
plural = "" if expd in ("1", "at least 1") else "s"
return cls(
f"expected {expd} {key}{plural} from {cls.list_of(values)}, got {recv}"
)


class InvalidFeatures(InvalidRequest):
...


class InvalidFeatureSet(InvalidRequest):
...
8 changes: 4 additions & 4 deletions analytix/queries.py
Expand Up @@ -148,7 +148,9 @@ def validate(self) -> None:
_log.info(f"Getting data between {self.start_date} and {self.end_date}")

if self.currency not in data.CURRENCIES:
raise InvalidRequest("expected a valid ISO 4217 currency code")
raise InvalidRequest(
f"expected a valid ISO 4217 currency code, got {self.currency!r}"
)

if self.start_index < 1:
raise InvalidRequest("the start index should be positive")
Expand All @@ -164,9 +166,7 @@ def validate(self) -> None:

diff = set(o.strip("-") for o in self.sort_options) - set(self.metrics)
if diff:
raise InvalidRequest(
"some sort options do not match metrics: " + ", ".join(diff)
)
raise InvalidRequest.non_matching_sort_options(diff)

self.rtype.validate(
self.dimensions,
Expand Down
83 changes: 41 additions & 42 deletions analytix/reports/features.py
Expand Up @@ -44,7 +44,7 @@
import typing as t

from analytix import abc
from analytix.errors import InvalidFeatures, InvalidFeatureSet, InvalidRequest
from analytix.errors import InvalidRequest
from analytix.reports import data


Expand Down Expand Up @@ -96,13 +96,11 @@ def validate(self, inputs: t.Collection[str]) -> None:

diff = inputs - data.ALL_METRICS
if diff:
raise InvalidFeatures("invalid metric(s) provided", diff)
raise InvalidRequest.invalid("metric", diff)

diff = inputs - self.values
if diff:
raise InvalidFeatures(
"dimensions and filters are incompatible with metric(s)", diff
)
raise InvalidRequest.incompatible_metrics(diff)


class SortOptions(abc.FeatureType, _CompareMixin):
Expand All @@ -117,13 +115,11 @@ def validate(self, inputs: t.Collection[str]) -> None:

diff = raw_inputs - data.ALL_METRICS
if diff:
raise InvalidFeatures("invalid sort option(s) provided", diff)
raise InvalidRequest.invalid("sort option", diff)

diff = raw_inputs - self.values
if diff:
raise InvalidFeatures(
"dimensions and filters are incompatible with sort option(s)", diff
)
raise InvalidRequest.incompatible_sort_options(diff)

if self.descending_only:
diff = {i for i in inputs if not i.startswith("-")}
Expand All @@ -141,11 +137,10 @@ def validate(self, inputs: t.Collection[str]) -> None:

diff = inputs - data.ALL_DIMENSIONS
if diff:
raise InvalidFeatures("invalid dimension(s) provided", diff)
raise InvalidRequest.invalid("dimension", diff)

diff = inputs - self.every
if diff:
raise InvalidFeatures("incompatible combination of dimensions", inputs)
if inputs - self.every:
raise InvalidRequest.incompatible_dimensions(inputs)

for set_type in self.values:
set_type.validate_dimensions(inputs)
Expand Down Expand Up @@ -173,24 +168,20 @@ def validate(self, inputs: dict[str, str]) -> None:

diff = keys - data.ALL_FILTERS
if diff:
raise InvalidFeatures("invalid filter(s) provided", diff)
raise InvalidRequest.invalid("filter", diff)

for k, v in inputs.items():
valid = data.VALID_FILTER_OPTIONS[k]

if valid and (v not in valid):
raise InvalidRequest(f"invalid value for filter {k!r}: {v!r}")
raise InvalidRequest.invalid_filter_value(k, v)

if k in locked.keys():
if v != locked[k]:
raise InvalidRequest(
"dimensions and filters are incompatible with value "
f"{v!r} for filter {k!r}"
)
raise InvalidRequest.incompatible_filter_value(k, v)

diff = keys - self.every_key
if diff:
raise InvalidFeatures("incompatible combination of filters", keys)
if keys - self.every_key:
raise InvalidRequest.incompatible_filters(keys)

for set_type in self.values:
set_type.validate_filters(keys)
Expand All @@ -201,55 +192,61 @@ def validate_dimensions(self, inputs: set[str]) -> None:
if self.values & inputs == self.values:
return

common = len(inputs & self.values)
raise InvalidFeatureSet("dimension", "all", common, self.values)
raise InvalidRequest.invalid_set(
"dimension", self.values, "all", len(inputs & self.values)
)

def validate_filters(self, keys: set[str]) -> None:
if self.expd_keys & keys == self.expd_keys:
return

common = len(keys & self.expd_keys)
raise InvalidFeatureSet("filter", "all", common, self.values)
raise InvalidRequest.invalid_set(
"filter", self.values, "all", len(keys & self.values)
)


class ExactlyOne(abc.SetType, _CompareMixin):
def validate_dimensions(self, inputs: set[str]) -> None:
if len(self.values & inputs) == 1:
return

common = len(inputs & self.values)
raise InvalidFeatureSet("dimension", "1", common, self.values)
raise InvalidRequest.invalid_set(
"dimension", self.values, "1", len(inputs & self.values)
)

def validate_filters(self, keys: set[str]) -> None:
if len(self.expd_keys & keys) == 1:
return

common = len(keys & self.expd_keys)
raise InvalidFeatureSet("filter", "1", common, self.values)
raise InvalidRequest.invalid_set(
"filter", self.values, "1", len(keys & self.values)
)


class OneOrMore(abc.SetType, _CompareMixin):
def validate_dimensions(self, inputs: set[str]) -> None:
if len(self.values & inputs) > 0:
return

common = len(inputs & self.values)
raise InvalidFeatureSet("dimension", "at least 1", common, self.values)
raise InvalidRequest.invalid_set(
"dimension", self.values, "at least 1", len(inputs & self.values)
)

def validate_filters(self, keys: set[str]) -> None:
if len(self.expd_keys & keys) > 0:
return

common = len(keys & self.expd_keys)
raise InvalidFeatureSet("filter", "at least 1", common, self.values)
raise InvalidRequest.invalid_set(
"filter", self.values, "at least 1", len(keys & self.values)
)


class Optional(abc.SetType, _CompareMixin):
def validate_dimensions(self, inputs: set[str]) -> None:
def validate_dimensions(self, _: set[str]) -> None:
# No verifiction required.
...

def validate_filters(self, keys: set[str]) -> None:
def validate_filters(self, _: set[str]) -> None:
# No verifiction required.
...

Expand All @@ -259,22 +256,24 @@ def validate_dimensions(self, inputs: set[str]) -> None:
if len(self.values & inputs) < 2:
return

common = len(inputs & self.values)
raise InvalidFeatureSet("dimension", "0 or 1", common, self.values)
raise InvalidRequest.invalid_set(
"dimension", self.values, "0 or 1", len(inputs & self.values)
)

def validate_filters(self, keys: set[str]) -> None:
if len(self.expd_keys & keys) < 2:
return

common = len(keys & self.expd_keys)
raise InvalidFeatureSet("filter", "0 or 1", common, self.values)
raise InvalidRequest.invalid_set(
"filter", self.values, "0 or 1", len(keys & self.values)
)


class ZeroOrMore(abc.SetType, _CompareMixin):
def validate_dimensions(self, inputs: set[str]) -> None:
def validate_dimensions(self, _: set[str]) -> None:
# No verifiction required.
...

def validate_filters(self, keys: set[str]) -> None:
def validate_filters(self, _: set[str]) -> None:
# No verifiction required.
...
10 changes: 4 additions & 6 deletions analytix/reports/types.py
Expand Up @@ -455,9 +455,8 @@ def validate(

itst = filters["insightTrafficSourceType"]
if itst not in data.VALID_FILTER_OPTIONS["insightTrafficSourceDetail"]:
raise InvalidRequest(
"dimensions and filters are incompatible with value "
f"{itst!r} for filter 'insightTrafficSourceType'"
raise InvalidRequest.incompatible_filter_value(
"insightTrafficSourceType", itst
)


Expand Down Expand Up @@ -820,9 +819,8 @@ def validate(

itst = filters["insightTrafficSourceType"]
if itst not in data.VALID_FILTER_OPTIONS["insightTrafficSourceDetail"]:
raise InvalidRequest(
"dimensions and filters are incompatible with value "
f"{itst!r} for filter 'insightTrafficSourceType'"
raise InvalidRequest.incompatible_filter_value(
"insightTrafficSourceType", itst
)


Expand Down
23 changes: 20 additions & 3 deletions tests/queries/test_report_queries.py
Expand Up @@ -27,6 +27,7 @@
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

import datetime as dt
import re
import warnings

import pytest
Expand Down Expand Up @@ -152,7 +153,9 @@ def test_validate_end_date_gt_start_date():

def test_validate_currency():
query = ReportQuery(currency="LOL")
with pytest.raises(InvalidRequest, match="expected a valid ISO 4217 currency code"):
with pytest.raises(
InvalidRequest, match="expected a valid ISO 4217 currency code, got 'LOL'"
):
query.validate()


Expand Down Expand Up @@ -182,14 +185,28 @@ def test_validate_months_are_corrected():
assert query._end_date == dt.date(2022, 3, 1)


def test_validate_all_sort_options_are_metrics():
def test_validate_all_sort_options_are_metrics_singular():
query = ReportQuery(
metrics=("likes",),
sort_options=("-views",),
)
with pytest.raises(
InvalidRequest,
match=re.escape("sort option 'views' is not part of the given metrics"),
):
query.validate()


def test_validate_all_sort_options_are_metrics_plural():
query = ReportQuery(
metrics=("likes",),
sort_options=("-views", "comments"),
)
with pytest.raises(
InvalidRequest,
match="some sort options do not match metrics: views, comments|some sort options do not match metrics: comments, views",
match=re.escape(
"sort options 'comments' and 'views' are not part of the given metrics"
),
):
query.validate()

Expand Down

0 comments on commit 5666df2

Please sign in to comment.