Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,11 @@ There is no PyPI package yet; the file is the distribution.
{"application":"roam","name":"command","value":1.0,"tags":{"name":"home","version":"1.4.0"}}
```

`value` is omitted when not given, and when it is NaN or infinite (JSON has no
such numbers); `tags` always holds at least `version`, and
`install` when the client has an [installation ID](#every-event-carries-a-random-installation-id). A tag whose
`value` is omitted when not given, when it is NaN or infinite (JSON has no
such numbers), and when it is not a real number at all — a string such as
`"3"`, or a `bool` — though the event itself is still sent. Any other real
number (a `Decimal`, a `Fraction`) is sent as a float. `tags` always holds at
least `version`, and `install` when the client has an [installation ID](#every-event-carries-a-random-installation-id). A tag whose
key or value is `None` is left out, and every other key and value is sent as a
string. An event with a blank `name` is not sent at all. The server assigns the
timestamp. A `201` is success; anything else is logged at `DEBUG` and dropped.
Expand Down
28 changes: 28 additions & 0 deletions tests/test_trace_client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""Drives the client against a real HTTP server on a loopback port -- the
standard library's own, so the tests have no more dependencies than the
client does."""
import decimal
import fractions
import json
import logging
import os
Expand Down Expand Up @@ -608,6 +610,32 @@ def test_json_drops_infinite_values_but_keeps_zero(self):
"JSON has no infinity; sending one would be rejected by the server")
self.assertEqual(0, json.loads(_json("App", "n", 0, None))["value"], "zero is a value, not a missing one")

def test_json_leaves_out_a_value_that_is_not_a_real_number_but_keeps_the_event(self):
from trace_client.trace_client import _json
for odd in ("3", "", True, False, b"3", [1], object(), 1j):
body = json.loads(_json("App", "n", odd, None))
self.assertEqual({"application": "App", "name": "n"}, body,
"%r is not a number the server can take; the event goes without it" % (odd,))

def test_json_sends_other_real_numbers_as_floats(self):
from trace_client.trace_client import _json
self.assertEqual(1.5, json.loads(_json("App", "n", decimal.Decimal("1.5"), None))["value"])
self.assertEqual(0.25, json.loads(_json("App", "n", fractions.Fraction(1, 4), None))["value"])
self.assertEqual(7, json.loads(_json("App", "n", 7, None))["value"])
for unsendable in (decimal.Decimal("NaN"), decimal.Decimal("sNaN"), decimal.Decimal("1e400"),
fractions.Fraction(10 ** 400)):
self.assertNotIn("value", json.loads(_json("App", "n", unsendable, None)),
"%r has no finite float; it is left out rather than raising" % (unsendable,))

def test_report_with_a_decimal_value_still_delivers_the_event(self):
client = TraceClient(self.base_url, "MyGame", "1.2.3", key="k")
try:
client.report("score", decimal.Decimal("2.5"))
self.assertTrue(self.capture.arrived.wait(5), "a Decimal must not cost the event its place")
finally:
client.close()
self.assertEqual(2.5, json.loads(self.capture.requests[0]["body"])["value"])

def test_json_omits_tags_that_are_empty_after_cleaning_and_stringifies_the_rest(self):
from trace_client.trace_client import _json
self.assertNotIn("tags", json.loads(_json("App", "n", None, {"none": None})))
Expand Down
29 changes: 25 additions & 4 deletions trace_client/trace_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
"""
from __future__ import annotations

import decimal
import json
import logging
import numbers
import os
import queue
import re
Expand Down Expand Up @@ -272,8 +274,9 @@ def report(self, name: str, value: Optional[float] = None,
tags: Optional[Mapping[str, str]] = None) -> None:
"""Report that ``name`` happened, with an optional numeric value and
optional string tags. Returns immediately; see the class docstring.
A blank ``name`` sends nothing. A NaN or infinite ``value``, and any
tag whose key or value is ``None``, is left out of the event."""
A blank ``name`` sends nothing. A ``value`` that is NaN, infinite or
not a real number (a string, a bool), and any tag whose key or value
is ``None``, is left out of the event; the event is still sent."""
if self._queue is None:
return
try:
Expand Down Expand Up @@ -384,10 +387,28 @@ def _with_install(tags: Dict[str, str], install_id: Optional[str]) -> Dict[str,
return merged


def _number(value: object) -> Optional[float]:
"""``value`` as a number JSON can carry, or ``None`` for one it cannot:
anything that is not a real number (a string, a bool), NaN and infinity.
A ``Decimal`` or ``Fraction`` becomes a float rather than costing the
event its place: ``json.dumps`` would refuse it, and the event with it."""
if isinstance(value, bool) or not isinstance(value, (numbers.Real, decimal.Decimal)):
return None
if type(value) not in (int, float):
try:
value = float(value)
except (OverflowError, ValueError):
return None
if value != value or value in (float("inf"), float("-inf")):
return None
return value # type: ignore[return-value]


def _json(application: str, name: str, value: Optional[float], tags: Optional[Mapping[str, str]]) -> bytes:
payload: Dict[str, object] = {"application": application, "name": name}
if value is not None and value == value and value not in (float("inf"), float("-inf")):
payload["value"] = value
number = _number(value)
if number is not None:
payload["value"] = number
if tags:
clean = {str(k): str(v) for k, v in tags.items() if k is not None and v is not None}
if clean:
Expand Down
Loading