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: 6 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,19 @@ jobs:
with:
python-version: ${{ matrix.python-version }}

- name: Compile both modules
run: python -m py_compile src/collide.py src/ideaCollisionGenerator.py
- name: Compile every module
run: python -m py_compile src/collide.py src/ideaCollisionGenerator.py src/usage_reporting.py src/trace_client.py

- name: Run the test suite
run: python -m unittest discover -s tests -v

# the suite patches every prompt, so the real stdin path documented in
# README.md is exercised here instead
- name: Run the program end to end
# a CI run is not a person using Collide: without this the run would
# report to the real trace service on every push
env:
TRACE_USAGE_REPORTING: "off"
run: |
printf 'k%s\n' 1 2 3 4 5 6 7 8 9 10 > fixture.txt
printf 'idea %s\n' 1 2 3 4 5 >> fixture.txt
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@ __pycache__/
# generated idea files, but keep the committed sample
ideas/*.txt
!ideas/example.txt

# written on first launch by src/usage_reporting.py; holds the usage-reporting opt-out
settings.json
36 changes: 35 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ shown in turn, and the idea entered for it is written to a timestamped file.
## Requirements

- Python 3.8 or later
- No third-party dependencies — only `datetime`, `random`, and `os` from the standard library
- No third-party dependencies — only the standard library (the vendored usage-reporting client in `src/trace_client.py` included)

## Usage

Expand Down Expand Up @@ -79,6 +79,40 @@ commands on every push to `main` and on every pull request, against Python 3.8
and 3.13: `py_compile` over both modules, the test suite, and one full run of
the program with a 15-line fixture on stdin.

## Usage reporting

Usage reporting is on by default: Collide sends its name (`Collide`), its version and the events
`startup` (when the program starts) and `ideas-written` (when a session's ideas have been saved)
to [trace](https://github.com/Stephenson-Software/trace) at `https://trace.danielstephenson.dev`.
Nothing about you, your machine, your IP address, the keywords or the ideas is sent. The report
is made from a background thread, never blocks the program, and is dropped silently if the
service is unreachable.

The first launch writes a `settings.json` at the repository root and prints a one-line notice.
To turn reporting off, any one of these is enough:

- `"usage_reporting": {"enabled": false}` in `settings.json`:

```json
{
"usage_reporting": {
"enabled": false
}
}
```

- the environment variable `TRACE_USAGE_REPORTING=off` (also `false`, `0`, `no`), which turns off
every program that reports to trace
- the environment variable `DO_NOT_TRACK=1` (see [consoledonottrack.com](https://consoledonottrack.com))

The environment variables win over `settings.json`. The `endpoint` and `key` entries in the same
block select where reports go and the key they are sent with. The client is `src/trace_client.py`,
vendored from [trace-client-python](https://github.com/Stephenson-Software/trace-client-python);
the settings handling is in `src/usage_reporting.py`. Only a command-line run reports; the test
suite's direct `main()` calls do not, and the CI run sets `TRACE_USAGE_REPORTING=off`.

Details: https://github.com/Stephenson-Software/trace#usage-reporting

## License

See [LICENSE](LICENSE).
10 changes: 8 additions & 2 deletions src/collide.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
from ideaCollisionGenerator import IdeaCollisionGenerator
from usage_reporting import startUsageReporting

def main():
def main(reportUsage=False):
# usage reporting is started only from the command line (below), so a
# direct main() call - the test suite's, for one - reports nothing
usage = startUsageReporting() if reportUsage else None
generator = IdeaCollisionGenerator()
generator.getKeywords()
generator.createPairs()
generator.promptForIdeas()
generator.writeToFile()
if usage is not None:
usage.report("ideas-written")

if __name__ == "__main__":
main()
main(reportUsage=True)
218 changes: 218 additions & 0 deletions src/trace_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
"""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
library only; Python 3.8+.

MIT licensed. Keep this header when vendoring so the file can be found again.

Vendored into Collide unmodified apart from this note.
"""
from __future__ import annotations

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

__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
the program doing the reporting.

Three properties hold for every call to :meth:`report`:

* **It returns immediately.** The HTTP call happens on a single daemon
thread owned by this client. A game loop can report from its main
thread without a frame ever waiting on the network.
* **It never raises.** A server that is down, slow, or rejecting the key
is a dropped report, not an exception in the host program. Failures
are logged at DEBUG on the ``trace`` logger and otherwise not at all.
* **It is bounded.** At most :attr:`QUEUE_CAPACITY` reports wait to be
sent; beyond that, new reports are dropped rather than accumulated.
A trace server that is unreachable for a week costs a few kilobytes,
not the host's memory.

Reporting is opt-out: ``enabled=False``, or no key, yields a client that
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.

::

trace = TraceClient("https://trace.example.org", "roam",
key=settings.usage_key, enabled=settings.usage_reporting)
trace.report("startup", tags={"version": __version__})
...
trace.close() # on shutdown
"""

QUEUE_CAPACITY = 256
TIMEOUT_SECONDS = 5.0

def __init__(self, base_url: str, application: str, *, key: Optional[str] = None,
enabled: bool = True) -> None:
if not base_url or not base_url.strip():
raise ValueError("base_url is required")
if not application or not application.strip():
raise ValueError("application is required")
self._endpoint = base_url.strip().rstrip("/") + "/api/metrics"
self._application = application.strip()
self._key = (key or "").strip()
self._queue: Optional["queue.Queue[Optional[bytes]]"] = None
self._thread: Optional[threading.Thread] = None
#: 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)
self._thread.start()

@classmethod
def disabled(cls) -> "TraceClient":
"""A client that reports nothing. Useful as a default before settings are read."""
return cls("http://disabled.invalid", "disabled", enabled=False)

@property
def enabled(self) -> bool:
"""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,
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."""
if self._queue is None or not name or not name.strip():
return
try:
body = _json(self._application, name, value, tags)
self._queue.put_nowait(body)
except queue.Full:
_LOG.debug("[trace] queue full, dropped %s", name)
except Exception as failure: # noqa: BLE001 - a report must never be the reason a program stops
_LOG.debug("[trace] could not queue %s: %s", name, failure)

def close(self, timeout: float = TIMEOUT_SECONDS) -> None:
"""Stop the sending thread, giving reports already queued up to
``timeout`` seconds in total to be sent first. A program that reports
and then exits within milliseconds -- a CLI, a short script -- would
otherwise lose its one event to the race between queueing it and the
sender thread picking it up. The bound still holds: an unreachable
server costs at most ``timeout``, never a hang. Safe to call more than
once, and on a disabled client."""
if self._queue is None or self._thread is None:
return
q, thread = self._queue, self._thread
self._queue = None # report() is a no-op from here on
try:
q.put_nowait(None) # sentinel behind whatever is queued
except queue.Full:
# A full queue means 256 reports are waiting; the sentinel would be
# the 257th. Drop the oldest to make room -- one lost report beats a
# thread that never stops.
try:
q.get_nowait()
q.put_nowait(None)
except (queue.Empty, queue.Full):
pass
thread.join(timeout)

# -- internals --------------------------------------------------------

def _drain(self) -> None:
q = self._queue
assert q is not None
while True:
body = q.get()
if body is None:
return
self._send(body)

def _send(self, body: bytes) -> None:
request = urllib.request.Request(
self._endpoint, data=body, method="POST",
headers={
"Content-Type": "application/json; charset=utf-8",
"Authorization": "Bearer " + self._key,
"User-Agent": "trace-client-python/%s (%s)" % (__version__, self._application),
})
try:
with urllib.request.urlopen(request, timeout=self.TIMEOUT_SECONDS) as response:
status = response.status
response.read()
except urllib.error.HTTPError as answered:
status = answered.code
try:
answered.read()
except Exception: # noqa: BLE001
pass
except Exception as failure: # noqa: BLE001 - see report()
_LOG.debug("[trace] could not deliver %s: %s", body, failure)
return
if status != 201:
_LOG.debug("[trace] trace server answered %s for %s", status, body)


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
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:
payload["tags"] = clean
return json.dumps(payload, separators=(",", ":")).encode("utf-8")
Loading
Loading