From 864a69fcb6c8d63fb3dbe418cd87d7a231d66e65 Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Tue, 15 Sep 2026 21:34:57 -0600 Subject: [PATCH] Honour TRACE_USAGE_REPORTING / DO_NOT_TRACK, expose disabled_reason, bump to 0.2.0 The constructor now checks the two environment opt-outs shared by every trace client -- TRACE_USAGE_REPORTING=off (off/false/0/no) and DO_NOT_TRACK=1 (1/true/yes), case-insensitive -- before the program's own enabled flag, so a user can switch off every trace-reporting program at once. TraceClient.disabled_reason says why a client reports nothing ("environment", "config", "no key"; None when on) so programs can log it. Adds tests/__init__.py: without it `python -m unittest -v` discovered no tests at all (CI has been failing with "NO TESTS RAN" on 3.12 and passing vacuously on 3.8 since the first commit). 21 tests now run on 3.8 and 3.11. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 26 ++++++++-- pyproject.toml | 2 +- tests/__init__.py | 1 + tests/test_trace_client.py | 95 +++++++++++++++++++++++++++++++++++- trace_client/__init__.py | 9 ++-- trace_client/trace_client.py | 61 ++++++++++++++++++++--- 6 files changed, 179 insertions(+), 15 deletions(-) create mode 100644 tests/__init__.py 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,