Skip to content

Commit

Permalink
Improved int/str base types; more validation
Browse files Browse the repository at this point in the history
  • Loading branch information
ml31415 committed Sep 27, 2023
1 parent ba51fbf commit 401350c
Show file tree
Hide file tree
Showing 5 changed files with 118 additions and 68 deletions.
2 changes: 1 addition & 1 deletion betfair_parser/spec/betting/type_definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@ class ClearedOrderSummary(BaseMessage, frozen=True):
price_matched: Optional[Price] = None # The average matched price across all settled bets or bet fragments
price_reduced: Optional[bool] = None # Indicates if the matched price was affected by a reduction factor
size_settled: Optional[Size] = None # The cumulative bet size that was settled as matched or voided under this item
profit: Optional[Size] = None # The profit or loss gained on this line
profit: Optional[float] = None # The profit or loss gained on this line
size_cancelled: Optional[Size] = None # The amount of the bet that was cancelled
customer_order_ref: Optional[CustomerOrderRef] = None # Defined by the customer for the bet order
customer_strategy_ref: Optional[CustomerStrategyRef] = None # Defined by the customer for the bet order
Expand Down
36 changes: 21 additions & 15 deletions betfair_parser/spec/common/type_definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,31 @@
from betfair_parser.spec.common.messages import BaseMessage


# Type aliases with minimalistic validation. More would be great.
IDType = Annotated[
int, msgspec.Meta(title="IDType", description="integer data, but defined and encoded as string", ge=0)
]
StrInt = Annotated[Union[str, int], msgspec.Meta(title="StrInt", description="str or int input, encoded as string")]

# Type aliases with minimalistic validation.

Date = Annotated[datetime.datetime, msgspec.Meta(title="Date", tz=True)]
SelectionId = Annotated[int, msgspec.Meta(title="SelectionId")]
MarketType = Annotated[str, msgspec.Meta(title="MarketType")]
Venue = Annotated[str, msgspec.Meta(title="Venue")]
MarketId = Annotated[str, msgspec.Meta(title="MarketId")]
Handicap = Annotated[float, msgspec.Meta(title="Handicap")]
EventId = Annotated[str, msgspec.Meta(title="EventId")]
EventTypeId = Annotated[int, msgspec.Meta(title="EventTypeId")]
MarketId = Annotated[str, msgspec.Meta(title="MarketId")] # The only ID, that actually needs to be a string
SelectionId = Annotated[int, msgspec.Meta(title="SelectionId", ge=0)] # The only ID, that is actually defined as int
Handicap = Annotated[float, msgspec.Meta(title="Handicap", gt=-100, lt=100)]
EventId = Annotated[IDType, msgspec.Meta(title="EventId")]
EventTypeId = Annotated[IDType, msgspec.Meta(title="EventTypeId")]
CountryCode = Annotated[str, msgspec.Meta(title="CountryCode", min_length=2, max_length=3)]
ExchangeId = Annotated[str, msgspec.Meta(title="ExchangeId")]
CompetitionId = Annotated[str, msgspec.Meta(title="CompetitionId")]
Price = Annotated[float, msgspec.Meta(title="Price")]
Size = Annotated[float, msgspec.Meta(title="Size")]
BetId = Annotated[Union[str, int], msgspec.Meta(title="BetId")]
MatchId = Annotated[Union[str, int], msgspec.Meta(title="MatchId")]
CustomerRef = Annotated[Union[str, int], msgspec.Meta(title="CustomerRef")]
CustomerOrderRef = Annotated[Union[str, int], msgspec.Meta(title="CustomerOrderRef")]
CustomerStrategyRef = Annotated[Union[str, int], msgspec.Meta(title="CustomerStrategyRef")]
ExchangeId = Annotated[IDType, msgspec.Meta(title="ExchangeId")]
CompetitionId = Annotated[IDType, msgspec.Meta(title="CompetitionId")]
Price = Annotated[float, msgspec.Meta(title="Price", ge=0, le=1001)]
Size = Annotated[float, msgspec.Meta(title="Size", ge=0)]
BetId = Annotated[IDType, msgspec.Meta(title="BetId")]
MatchId = Annotated[IDType, msgspec.Meta(title="MatchId")]
CustomerRef = Annotated[StrInt, msgspec.Meta(title="CustomerRef")]
CustomerOrderRef = Annotated[StrInt, msgspec.Meta(title="CustomerOrderRef")]
CustomerStrategyRef = Annotated[StrInt, msgspec.Meta(title="CustomerStrategyRef")]


class TimeRange(BaseMessage, frozen=True):
Expand Down
130 changes: 81 additions & 49 deletions tests/integration/test_completeness.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,35 @@ def id_check_item(item):
(spec, node)
for xml_file, spec in zip(XML_FILES, (accounts.enums, betting.enums, heartbeat))
for node in xml_nodes(xml_file, "simpleType")
if not list(node.iter("validValues")) # exclude enums
],
ids=id_check_item,
)
def test_basetype(spec, node):
xml_typename = node.get("name")
py_type = get_definition(spec, xml_typename)
xml_type = xml_type_compat(node.get("type"))
py_type_str = py_type_format(py_type_unpack(py_type))

if xml_typename.endswith("Id") and xml_typename not in ("SelectionId", "MarketId"):
# ID types are integer values encoded as strings
assert xml_type == "str"
assert py_type_str == "int"
elif xml_typename.endswith("Ref"):
# CustomerRef can be str or int as input
assert xml_type == "str"
assert py_type_str == "Union[str,int]"
else:
assert xml_type == py_type_str


@pytest.mark.parametrize(
["spec", "node"],
[
(spec, node)
for xml_file, spec in zip(XML_FILES, (accounts.enums, betting.enums, heartbeat))
for node in xml_nodes(xml_file, "simpleType")
if list(node.iter("validValues")) # enums only
],
ids=id_check_item,
)
Expand All @@ -43,29 +72,15 @@ def test_enum(spec, node):
if xml_typename in ("MarketGroupType", "LimitBreachActionType"):
# Defined only in XML, but not in documentation
pytest.skip("Not defined in documentation")
if xml_typename == "MarketType":
pytest.skip("Misplaced in XML, belongs to accounts")
if xml_typename == "EventTypeId":
pytest.skip("It's stated as str in the API, but actually an int")

datatype_cls = get_definition(spec, xml_typename)
validvalues = list(node.iter("validValues"))
if not validvalues:
# Type is a simple subclass
xml_type = xml_type_format(node.get("type"))
datatype_str = py_type_format(py_type_unpack(datatype_cls))
if datatype_str == "Union[str,int]":
# Some custom defined hybrid types for fields, that accept both, even if not stated in the XML
assert xml_type == "str"
else:
assert py_type_format(py_type_unpack(datatype_cls)) == xml_type
else:
# Type is an enum of strings
validvalues = validvalues[0]
assert node.get("type") == "string"
for value in validvalues.iter("value"): # type: ignore
valname = value.get("name")
assert hasattr(datatype_cls, valname), f"Enum field {xml_typename}.{valname} not set"
valid_values = list(node.iter("validValues"))[0]
min_values = 1 if xml_typename in ("Status", "ItemClass", "TokenType", "TimeInForce") else 2
assert len(valid_values) >= min_values
assert node.get("type") == "string"
for value in valid_values.iter("value"): # type: ignore
value_name = value.get("name")
assert hasattr(datatype_cls, value_name), f"Enum field {xml_typename}.{value_name} not set"


@pytest.mark.parametrize(
Expand Down Expand Up @@ -111,29 +126,30 @@ def test_typedef(spec, node):
"persistence_type": "Marked as optional in the XML, but mandatory according to the documentation",
},
"CurrentOrderSummary": {"matched_date": "Marked as mandatory, but is occasionally missing in real data"},
"ClearedOrderSummary": {"profit": "Profit is not a size, it's just a float"},
}


def check_typedef_param(param, py_cls, typedef_name):
xml_param_name = param_name(param)
if param_deprecated(param):
if is_param_deprecated(param):
return
if DOCUMENTATION_ERRORS.get(typedef_name, {}).get(xml_param_name):
# Documented exception for this parameter found, skip checks
return

assert py_cls is not None, f"{typedef_name} does not define parameters, but XML defines '{xml_param_name}'"
assert param_defined(param, py_cls), f"{typedef_name}.{xml_param_name} not defined"
assert is_param_defined(param, py_cls), f"{typedef_name}.{xml_param_name} not defined"
param_cls = py_cls.__annotations__[xml_param_name]
if param_mandatory(param):
assert not param_optional(param, py_cls), f"{typedef_name} fails to require {xml_param_name}"
if is_param_mandatory(param):
assert not is_param_optional(param, py_cls), f"{typedef_name} fails to require {xml_param_name}"
else:
assert param_optional(param, py_cls), f"{typedef_name} erroneously requires {xml_param_name}"
assert is_param_optional(param, py_cls), f"{typedef_name} erroneously requires {xml_param_name}"
param_cls = py_type_unpack(param_cls) # unpack Optional[...]

xml_type = xml_type_format(param.get("type"))
xml_type = xml_type_compat(param.get("type"), param_name=xml_param_name)
param_cls_name = py_type_format(param_cls)
assert param_cls_name == xml_type, f"{typedef_name}.{xml_param_name}:{xml_type}: Invalid type: {param_cls_name}"
assert xml_type == param_cls_name, f"{typedef_name}.{xml_param_name}:{xml_type}: Invalid type: {param_cls_name}"


@pytest.mark.parametrize(
Expand Down Expand Up @@ -174,9 +190,9 @@ def test_operations(spec, node):
assert isinstance(operation_cls.endpoint_type.value, str), "EndpointType was not defined correctly"
assert operation_cls.__doc__, "No documentation was provided"
xml_return_type = node.findall("parameters/simpleResponse")[0].get("type")
assert xml_type_format(xml_return_type) == py_type_format(py_type_unpack(operation_cls.return_type))
assert xml_type_compat(xml_return_type) == py_type_format(py_type_unpack(operation_cls.return_type))
xml_error_type = node.findall("parameters/exceptions/exception")[0].get("type")
assert xml_type_format(xml_error_type) == operation_cls.throws.__name__
assert xml_type_compat(xml_error_type) == operation_cls.throws.__name__
try:
params_cls = operation_cls.__annotations__["params"]
except KeyError:
Expand Down Expand Up @@ -210,30 +226,31 @@ def get_definition(spec, definition):
raise AssertionError(f"{definition} not defined")


def param_name(xml_param):
paramname = snake_case(xml_param.get("name"))
if paramname in keyword.kwlist:
return f"{paramname}_"
return paramname
def param_name(xml_param) -> str:
"""Translate the XML camel case names to snake case, escaping python keywords."""
p_name = snake_case(xml_param.get("name"))
if p_name in keyword.kwlist:
return f"{p_name}_"
return p_name


def param_deprecated(xml_param):
def is_param_deprecated(xml_param) -> bool:
desc = (xml_param.findall("description")[0].text or "").strip()
return desc.startswith("@Deprecated")


def param_defined(xml_param, api_cls):
paramname = param_name(xml_param)
return paramname in api_cls.__dict__
def is_param_defined(xml_param, api_cls) -> bool:
p_name = param_name(xml_param)
return p_name in api_cls.__dict__


def param_mandatory(xml_param):
def is_param_mandatory(xml_param) -> bool:
return xml_param.get("mandatory") == "true"


def param_optional(xml_param, api_cls):
paramname = param_name(xml_param)
type_spec_str = str(api_cls.__annotations__[paramname]).replace("typing.", "")
def is_param_optional(xml_param, api_cls) -> bool:
p_name = param_name(xml_param)
type_spec_str = str(api_cls.__annotations__[p_name]).replace("typing.", "")
return type_spec_str.startswith("Optional")


Expand All @@ -250,7 +267,7 @@ def py_type_unpack_annotated(type_def):
def py_type_format(type_def):
type_def_name = compat_type_name(type_def)
if hasattr(type_def, "__metadata__"):
return py_type_replace(type_def.__metadata__[0].title)
return py_type_replace(type_def.__metadata__[-1].title)
if not hasattr(type_def, "__args__"):
return py_type_replace(type_def_name)
args = type_def.__args__ if type_def_name != "Optional" else type_def.__args__[:-1]
Expand All @@ -262,8 +279,6 @@ def py_type_format(type_def):
"SubscriptionStatus": "str",
"MarketStatus": "str",
"MarketId": "str",
"BetId": "str",
"SelectionId": "int",
"RunnerStatus": "str",
"CustomerRef": "str",
"Wallet": "str",
Expand All @@ -282,6 +297,14 @@ def py_type_format(type_def):
"CustomerStrategyRef": "str",
"MarketBettingType": "str",
"RunnerMetaData": "dict[str,str]",
"BetId": "int",
"CompetitionId": "int",
"ExchangeId": "int",
"SelectionId": "int",
"EventId": "int",
"MatchId": "int",
"IDType": "int",
"EventTypeId": "int",
}

XML_TYPE_NAME_REPLACEMENTS = {
Expand All @@ -291,11 +314,9 @@ def py_type_format(type_def):
"double": "float",
"dateTime": "datetime",
"map": "dict",
"SelectionId": "int",
"MarketId": "str",
"Date": "datetime",
"Handicap": "float",
"BetId": "str",
"PersistenceType": "str", # inconsistently used
"CustomerOrderRef": "str", # inconsistently used
"CustomerStrategyRef": "str", # inconsistently used
Expand All @@ -308,6 +329,13 @@ def py_type_format(type_def):
"(": "[",
")": "]",
", ": ",", # inconsistently used
"SelectionId": "int",
"CompetitionId": "int",
"BetId": "int",
"ExchangeId": "int",
"MatchId": "int",
"EventId": "int",
"EventTypeId": "int",
}


Expand All @@ -316,7 +344,11 @@ def py_type_replace(name):
return PY_TYPE_NAME_REPLACEMENTS.get(name, name)


def xml_type_format(xml_type_def):
def xml_type_compat(xml_type_def, param_name=None):
"""API definition switches from using base type definitions to specialized types."""
if param_name == "bet_id" and xml_type_def == "string":
xml_type_def = "BetId"

for xml_type, py_type in XML_TYPE_NAME_REPLACEMENTS.items():
xml_type_def = xml_type_def.replace(xml_type, py_type)

Expand Down
14 changes: 13 additions & 1 deletion tests/integration/test_live.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,18 @@ def test_event_types(session: Session):
assert resp[0].event_type.name == "Horse Racing"


@skip_not_logged_in
def test_competitions(session: Session):
resp = client.request(session, bo.ListCompetitions.with_params(filter=btd.MarketFilter(text_query="Football")))
assert len(resp), "No football competitions found"
for cr in resp:
assert isinstance(cr, btd.CompetitionResult)
assert cr.market_count
assert isinstance(cr.competition, btd.Competition)
assert cr.competition.id
assert cr.competition.name


@skip_not_logged_in
def test_events(session: Session):
resp = client.request(session, bo.ListEvents.with_params(filter=btd.MarketFilter(text_query="Horse Racing")))
Expand Down Expand Up @@ -105,7 +117,7 @@ def test_market_catalogue(session: Session):
assert runner.metadata
assert runner.metadata
assert runner.metadata.cloth_number
assert runner.metadata.forecastprice_decimal
assert runner.metadata.colours_description


@skip_not_logged_in
Expand Down
4 changes: 2 additions & 2 deletions tests/unit/test_markets.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,14 @@ def test_market_catalogue():
],
"eventType": {"id": 1, "name": "Soccer"},
"event": {
"id": "30359506",
"id": 30359506,
"name": "Almere City v Den Bosch",
"timezone": "GMT",
"openDate": "2021-03-19T19:00:00.000Z",
"countryCode": "NL",
"venue": None,
},
"competition": {"id": "11", "name": "Dutch Eerste Divisie"},
"competition": {"id": 11, "name": "Dutch Eerste Divisie"},
}
result = decode(encode(catalog[5000]))
del result["description"]["rules"] # too lengthy
Expand Down

0 comments on commit 401350c

Please sign in to comment.