diff --git a/README.md b/README.md index 5316228..cf5b7e6 100644 --- a/README.md +++ b/README.md @@ -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: +. ## Getting it diff --git a/pyproject.toml b/pyproject.toml index 551a278..faf3379 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..da2e627 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Marks tests/ as a package so ``python -m unittest`` discovers it from the repo root.""" diff --git a/tests/test_trace_client.py b/tests/test_trace_client.py index c5acc37..483f4a6 100644 --- a/tests/test_trace_client.py +++ b/tests/test_trace_client.py @@ -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: @@ -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) @@ -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] @@ -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("") diff --git a/trace_client/__init__.py b/trace_client/__init__.py index 2e22e0a..de7c658 100644 --- a/trace_client/__init__.py +++ b/trace_client/__init__.py @@ -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 ``trace_client.py`` (this package's single module) into a project as is, or vendor the package; either @@ -6,6 +6,9 @@ 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"] diff --git a/trace_client/trace_client.py b/trace_client/trace_client.py index 209bdba..6b85173 100644 --- a/trace_client/trace_client.py +++ b/trace_client/trace_client.py @@ -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 @@ -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 @@ -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. :: @@ -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) @@ -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,