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
26 changes: 22 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,28 @@ trace.close()
| **Bounded** | At most 256 reports wait to be sent; past that, new ones are dropped. A trace server that is unreachable for a week costs a few kilobytes, not your memory. |
| **`close()` drains** | Reports already queued get up to the client timeout (5 s total) to be sent before the thread stops, so a CLI that reports and exits at once does not lose its event. Still bounded: an unreachable server delays exit by at most the timeout. |

Reporting is **opt-out**: `enabled=False`, or no key at all, yields a client
that does nothing and costs nothing. A program that runs on other people's
machines should expose that switch in its settings — and say so once, the
first time it runs, so the player knows it is on and where to turn it off.
## Turning it off

Reporting is **opt-out**. Any one of these yields a client that does nothing
and costs nothing; the first that applies is the reason:

- **Environment, for every trace-reporting program at once:**
`TRACE_USAGE_REPORTING=off` (also `false`, `0`, `no`; case-insensitive) or
`DO_NOT_TRACK=1` (also `true`, `yes`; the
[consoledonottrack.com](https://consoledonottrack.com) convention). The
constructor checks these before anything else, so they win over the
program's own setting. Any other value, or an unset variable, leaves that
setting in charge.
- **The program's own setting:** `enabled=False`.
- **No key** (or a blank one).

`client.disabled_reason` says which one applied — `"environment"`, `"config"`
or `"no key"` — and is `None` when the client reports, so a program can log
it. A program that runs on other people's machines should expose the
`enabled` switch in its settings — and say so once, the first time it runs,
so the player knows reporting is on, that `TRACE_USAGE_REPORTING=off` turns
it off, and where the details are:
<https://github.com/Stephenson-Software/trace#usage-reporting>.

## Getting it

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "trace-client"
version = "0.1.1"
version = "0.2.0"
description = "One call to report that a program was used, to a trace server. Standard library only, Python 3.8+."
readme = "README.md"
license = {text = "MIT"}
Expand Down
1 change: 1 addition & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Marks tests/ as a package so ``python -m unittest`` discovers it from the repo root."""
95 changes: 94 additions & 1 deletion tests/test_trace_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@
client does."""
import json
import logging
import os
import threading
import time
import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from unittest import mock

from trace_client import TraceClient
from trace_client import TraceClient, environment_opts_out

_ENV_VARS = ("TRACE_USAGE_REPORTING", "DO_NOT_TRACK")


class _Capture:
Expand All @@ -30,6 +34,7 @@ def do_POST(self):
"path": self.path,
"authorization": self.headers.get("Authorization"),
"content_type": self.headers.get("Content-Type"),
"user_agent": self.headers.get("User-Agent"),
"body": body.decode("utf-8"),
})
self.send_response(capture.reply_status)
Expand All @@ -47,6 +52,12 @@ def log_message(self, *args): # keep test output quiet

class TraceClientTest(unittest.TestCase):
def setUp(self):
# The machine running the tests may itself have opted out; every
# test starts from a clean environment and sets what it needs.
scrubbed = {k: v for k, v in os.environ.items() if k not in _ENV_VARS}
patcher = mock.patch.dict(os.environ, scrubbed, clear=True)
patcher.start()
self.addCleanup(patcher.stop)
self.capture = _Capture()
self.server = _server(self.capture)
self.base_url = "http://127.0.0.1:%d" % self.server.server_address[1]
Expand Down Expand Up @@ -124,6 +135,88 @@ def test_disabled_client_sends_nothing(self):
self.assertFalse(self.capture.arrived.wait(0.3), "nothing should have been sent")
self.assertEqual([], self.capture.requests)

def test_disabled_reason_is_none_when_the_client_reports(self):
client = TraceClient(self.base_url, "MyGame", key="k")
self.assertTrue(client.enabled)
self.assertIsNone(client.disabled_reason)
client.close()
self.assertFalse(client.enabled, "close() stops reporting")
self.assertIsNone(client.disabled_reason, "but the reason describes how it was built")

def test_disabled_reason_names_the_config_flag_or_the_missing_key(self):
self.assertEqual("config", TraceClient(self.base_url, "MyGame", key="k", enabled=False).disabled_reason)
self.assertEqual("config", TraceClient.disabled().disabled_reason)
self.assertEqual("no key", TraceClient(self.base_url, "MyGame").disabled_reason)
self.assertEqual("no key", TraceClient(self.base_url, "MyGame", key=" ").disabled_reason)
self.assertEqual("config", TraceClient(self.base_url, "MyGame", enabled=False).disabled_reason,
"the config flag is checked before the key")

def _assert_environment_disables(self, variable, value):
with mock.patch.dict(os.environ, {variable: value}):
self.assertTrue(environment_opts_out(), "%s=%r should opt out" % (variable, value))
client = TraceClient(self.base_url, "MyGame", key="k")
self.assertFalse(client.enabled, "%s=%r should disable the client" % (variable, value))
self.assertEqual("environment", client.disabled_reason)
client.report("startup")
client.close()
self.assertFalse(self.capture.arrived.wait(0.2), "%s=%r: nothing should have been sent" % (variable, value))
self.assertEqual([], self.capture.requests)

def test_TRACE_USAGE_REPORTING_off_disables_reporting_for_every_accepted_value(self):
for value in ("off", "false", "0", "no", "OFF", "False", "No", " off "):
self._assert_environment_disables("TRACE_USAGE_REPORTING", value)

def test_DO_NOT_TRACK_disables_reporting_for_every_accepted_value(self):
for value in ("1", "true", "yes", "TRUE", "Yes", " 1 "):
self._assert_environment_disables("DO_NOT_TRACK", value)

def test_other_environment_values_leave_the_program_setting_in_charge(self):
for variable, value in (("TRACE_USAGE_REPORTING", "on"), ("TRACE_USAGE_REPORTING", ""),
("TRACE_USAGE_REPORTING", "disabled"), ("TRACE_USAGE_REPORTING", "1"),
("DO_NOT_TRACK", "0"), ("DO_NOT_TRACK", ""), ("DO_NOT_TRACK", "false"),
("DO_NOT_TRACK", "off")):
with mock.patch.dict(os.environ, {variable: value}):
self.assertFalse(environment_opts_out(), "%s=%r is not an opt-out" % (variable, value))
client = TraceClient(self.base_url, "MyGame", key="k")
self.assertTrue(client.enabled, "%s=%r must not disable the client" % (variable, value))
self.assertIsNone(client.disabled_reason)
client.close()
self.assertFalse(environment_opts_out(), "an unset variable is not an opt-out")

def test_environment_wins_over_the_config_flag_and_over_the_key(self):
# enabled=True with a key, and the environment still says no.
with mock.patch.dict(os.environ, {"TRACE_USAGE_REPORTING": "off"}):
client = TraceClient(self.base_url, "MyGame", key="k", enabled=True)
self.assertEqual("environment", client.disabled_reason)
client.close()
# enabled=False AND the environment: the environment is the reason.
with mock.patch.dict(os.environ, {"DO_NOT_TRACK": "1"}):
self.assertEqual("environment",
TraceClient(self.base_url, "MyGame", key="k", enabled=False).disabled_reason)
self.assertEqual("environment", TraceClient(self.base_url, "MyGame").disabled_reason,
"the environment is checked before the key too")
# Once the variable is gone, the program's own setting is back in charge.
client = TraceClient(self.base_url, "MyGame", key="k")
self.assertTrue(client.enabled)
client.report("startup")
self.assertTrue(self.capture.arrived.wait(5))
client.close()

def test_environment_opts_out_accepts_an_explicit_mapping(self):
self.assertTrue(environment_opts_out({"TRACE_USAGE_REPORTING": "off"}))
self.assertTrue(environment_opts_out({"DO_NOT_TRACK": "yes"}))
self.assertFalse(environment_opts_out({}))
self.assertFalse(environment_opts_out({"DO_NOT_TRACK": "0", "TRACE_USAGE_REPORTING": "on"}))

def test_user_agent_names_the_client_version(self):
from trace_client import __version__
self.assertEqual("0.2.0", __version__)
client = TraceClient(self.base_url, "MyGame", key="k")
client.report("startup")
self.assertTrue(self.capture.arrived.wait(5))
client.close()
self.assertEqual("trace-client-python/0.2.0 (MyGame)", self.capture.requests[0]["user_agent"])

def test_report_ignores_a_blank_name(self):
client = TraceClient(self.base_url, "MyGame", key="k")
client.report("")
Expand Down
9 changes: 6 additions & 3 deletions trace_client/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
"""trace-client 0.1.1 -- https://github.com/Stephenson-Software/trace-client-python
"""trace-client 0.2.0 -- https://github.com/Stephenson-Software/trace-client-python

One call to report that a program was used. Copy ``trace_client.py`` (this
package's single module) into a project as is, or vendor the package; either
way there is nothing else to add.

MIT licensed. Keep this header when vendoring so the file can be found again.
"""
from .trace_client import TraceClient, __version__
from .trace_client import (ENV_DO_NOT_TRACK, ENV_TRACE_USAGE_REPORTING, REASON_CONFIG,
REASON_ENVIRONMENT, REASON_NO_KEY, TraceClient, __version__,
environment_opts_out)

__all__ = ["TraceClient", "__version__"]
__all__ = ["TraceClient", "__version__", "environment_opts_out", "ENV_TRACE_USAGE_REPORTING",
"ENV_DO_NOT_TRACK", "REASON_ENVIRONMENT", "REASON_CONFIG", "REASON_NO_KEY"]
61 changes: 55 additions & 6 deletions trace_client/trace_client.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""trace-client 0.1.1 -- https://github.com/Stephenson-Software/trace-client-python
"""trace-client 0.2.0 -- https://github.com/Stephenson-Software/trace-client-python

One call to report that a program was used. Copy this file into a project as
is, or vendor the package; either way there is nothing else to add. Standard
Expand All @@ -10,16 +10,45 @@

import json
import logging
import os
import queue
import threading
import urllib.error
import urllib.request
from typing import Dict, Mapping, Optional

__version__ = "0.1.1"
__version__ = "0.2.0"

_LOG = logging.getLogger("trace")

#: Environment variables that turn reporting off for every program using a
#: trace client, checked before the program's own setting. Set
#: ``TRACE_USAGE_REPORTING=off`` (also ``false``, ``0``, ``no``; case does not
#: matter) or ``DO_NOT_TRACK=1`` (also ``true``, ``yes``; see
#: https://consoledonottrack.com).
ENV_TRACE_USAGE_REPORTING = "TRACE_USAGE_REPORTING"
ENV_DO_NOT_TRACK = "DO_NOT_TRACK"
_OFF_VALUES = frozenset(("off", "false", "0", "no"))
_DO_NOT_TRACK_VALUES = frozenset(("1", "true", "yes"))

#: The values :attr:`TraceClient.disabled_reason` can take. First match wins.
REASON_ENVIRONMENT = "environment"
REASON_CONFIG = "config"
REASON_NO_KEY = "no key"


def environment_opts_out(environ: Optional[Mapping[str, str]] = None) -> bool:
"""Whether the environment asks for usage reporting to be off, via
``TRACE_USAGE_REPORTING=off`` or ``DO_NOT_TRACK=1``. Only the listed
values count; anything else (including an empty value) leaves the
program's own setting in charge."""
env = os.environ if environ is None else environ
if env.get(ENV_TRACE_USAGE_REPORTING, "").strip().lower() in _OFF_VALUES:
return True
if env.get(ENV_DO_NOT_TRACK, "").strip().lower() in _DO_NOT_TRACK_VALUES:
return True
return False


class TraceClient:
"""Reports usage events to a trace server, and never gets in the way of
Expand All @@ -39,8 +68,15 @@ class TraceClient:
not the host's memory.

Reporting is opt-out: ``enabled=False``, or no key, yields a client that
does nothing and costs nothing. Programs that run on other people's
machines should expose that switch in their settings and say so once.
does nothing and costs nothing. So does the environment: the constructor
checks ``TRACE_USAGE_REPORTING=off`` and ``DO_NOT_TRACK=1`` before it
looks at ``enabled``, so a user can switch off every trace-reporting
program at once. :attr:`disabled_reason` says which of those applied
(``"environment"``, ``"config"`` or ``"no key"``; ``None`` when on) so
the program can say so in its notice. Programs that run on other
people's machines should expose that switch in their settings and say
so once, pointing at
https://github.com/Stephenson-Software/trace#usage-reporting.

::

Expand All @@ -65,7 +101,18 @@ def __init__(self, base_url: str, application: str, *, key: Optional[str] = None
self._key = (key or "").strip()
self._queue: Optional["queue.Queue[Optional[bytes]]"] = None
self._thread: Optional[threading.Thread] = None
if enabled and self._key:
#: Why this client reports nothing: ``"environment"`` (the
#: ``TRACE_USAGE_REPORTING`` / ``DO_NOT_TRACK`` variables),
#: ``"config"`` (``enabled=False``) or ``"no key"``; ``None`` when it
#: reports. Decided once, here, in that order of precedence.
self.disabled_reason: Optional[str] = None
if environment_opts_out():
self.disabled_reason = REASON_ENVIRONMENT
elif not enabled:
self.disabled_reason = REASON_CONFIG
elif not self._key:
self.disabled_reason = REASON_NO_KEY
if self.disabled_reason is None:
self._queue = queue.Queue(maxsize=self.QUEUE_CAPACITY)
self._thread = threading.Thread(target=self._drain, name="trace-client/" + self._application,
daemon=True)
Expand All @@ -78,7 +125,9 @@ def disabled(cls) -> "TraceClient":

@property
def enabled(self) -> bool:
"""Whether :meth:`report` will actually send anything."""
"""Whether :meth:`report` will actually send anything. ``False`` after
:meth:`close` too; :attr:`disabled_reason` keeps the reason it was
built off, if it was."""
return self._queue is not None

def report(self, name: str, value: Optional[float] = None,
Expand Down
Loading