A failed measurement is not a value.
Every measurement has three answers: yes, no, and could not tell. This package gives the third one a type that refuses to be mistaken for the other two, a decorator that produces it instead of a default, a guard that keeps it away from anything destructive, and a linter that finds the places your codebase is still turning failures into numbers.
Zero dependencies. Pure standard library. Python 3.9+.
A measurement fails, its failure is converted into an ordinary value —
0,'',(),False,-1,999— and a gate downstream reads that value as a fact.
From there the failure is invisible. Nothing distinguishes "I looked and saw zero" from "I could not look". These are real incidents, each one line of code:
| the measurement | what it returned on failure | what happened |
|---|---|---|
| free disk space | 0.0 |
the cleanup job saw a full disk and deleted everything |
| a device screenshot | b'' |
"the UI is broken" — and a healthy device was force-restarted |
| a config file of protected names | [] |
"nothing is protected", so everything was fair game |
| media duration probe (timed out) | -1 |
a good file was declared corrupt and quarantined |
| frame brightness | -1 |
sorts below black, so the unreadable frame always won the is-this-black argument |
| "minutes since the last run" | -1, read as (x or 0) > limit |
a dead pipeline reported healthy for days |
None of these were exotic paths. They are the ordinary except Exception: return 0 that
everyone writes, meeting a gate that was written to compare numbers.
from couldnottell import measurement, is_unknown, unknown
@measurement
def free_gb(path):
return shutil.disk_usage(path).free / 1e9 # raises -> UNKNOWN, never 0>>> free = free_gb("/nope")
>>> free
UNKNOWN('FileNotFoundError: [Errno 2] No such file or directory')
>>> if free < 5: # the line that used to delete everything
Traceback (most recent call last):
couldnottell.UnknownError: cannot compare a measurement that could not be taken (FileNotFoundError: ...)
>>> free or 0 # the single most common way a failure becomes a number
Traceback (most recent call last):
couldnottell.UnknownError: a measurement that could not be taken has no truth value; handle 'could not tell' explicitly (...)Why a sentinel and not None? None is what most code already uses, and it fails quietly in
exactly the way this bug class needs it not to: None or 0 is 0, if None: is False, and
(x or 0) > limit silently answers a question nobody asked. UNKNOWN raises instead — so the
crash lands on the line that made the mistake, at the moment it makes it. That is the difference
between a bug you fix in a minute and one you find in a post-mortem.
== deliberately does not raise (UNKNOWN == 0 is False), because if it did, every
assertion, dict lookup and in test in your program would become a landmine. Attribute access
raises an error that is also an AttributeError, so hasattr() keeps working.
is_unknown(x) treats plain None as unknown too, so you can adopt this in a codebase that
already returns None without rewriting it all at once.
from couldnottell import refuses_unknown
@refuses_unknown
def sweep(free_gb, protected_names):
... # never runs when either argument could not be measuredThis is the half that actually saves data. Answering "could not tell" is only useful if nothing
downstream treats it as a yes. There is also require(value, "free space") for the imperative
style, gate(value, predicate) for a yes/no/could-not-tell question, and unwrap(value, default)
for the cases where a default genuinely is right — explicit, greppable, and visible in review.
$ couldnottell-lint src/
4 place(s) where a failed measurement becomes a value:
src/janitor.py:41: CNT001 returns 0.0 from a failed measurement return 0.0
src/probe.py:88: CNT002 returns False from a failed measurement return False
src/clip.py:19: CNT001 returns -1 from a failed measurement — a sentinel number sorts, and it will win a comparison it should not be in return -1
src/health.py:52: CNT003 `or 0` turns a failed measurement into a number, and the comparison then answers with confidence if (age_min or 0) > limit:
Return UNKNOWN (or None) instead, and keep it away from anything destructive. If a default really is right here, say so: `# couldnottell: <reason>`.| rule | what it finds | default |
|---|---|---|
CNT001 |
an except handler that returns an invented value — 0, -1, '', [], {}, int() |
on |
CNT002 |
an except handler that returns True or False |
on |
CNT003 |
(x or 0) > limit — a failed measurement coerced inside a comparison |
on |
CNT004 |
d.get("free", 0) — a default that cannot be told from a measured value |
off |
CNT000 |
a file that could not be read or parsed | always |
return None and raise are not findings — None is a third state, not an invented value.
or outside a comparison (cfg.get("name") or "anonymous") is left alone. Comments and
docstrings are not code. A file that will not parse is reported, not skipped: a checker that
quietly ignores what it cannot read is making the same mistake it exists to find.
Suppress a line with a reason — a bare suppression is not accepted:
return 0 # couldnottell: a display counter, no gate reads itPut it in your test suite, where a rule about your own code belongs:
from couldnottell.lint import lint, format_findings
def test_no_failed_measurement_becomes_a_value():
found = lint(["src"])
assert not found, format_findings(found)The rule needs two assertions, and almost everyone writes only the first:
from couldnottell.testing import Spy, assert_unknown, assert_known, broken
def test_the_sweep_deletes_nothing_when_the_disk_cannot_be_read(monkeypatch):
remove = Spy()
monkeypatch.setattr(janitor, "free_gb", broken(OSError("stat failed")))
monkeypatch.setattr(janitor.os, "remove", remove)
assert_unknown(janitor.sweep()) # 1. it says it could not tell
remove.assert_never_called("deleted files while the disk could not be read") # 2. and it did nothing
def test_a_measured_low_disk_still_purges(monkeypatch):
...
assert_known(janitor.sweep()) # or "return UNKNOWN from everything" passes your suiteThe second assertion is where the damage is: a function can honestly return UNKNOWN and still
have deleted the files on the way there. assert_known is the counterweight — a gate that can
never say yes is just as broken as one that cannot say "could not tell".
pip install couldnottell # once published
pip install . # from a checkoutUNKNOWNis contagious by design. It will crash code that was quietly limping. That is the point, and it does mean adopting it inside a running system is a migration, not a drop-in. Start at the measurements, keepis_unknown()at the boundaries, and let the crashes show you the gates that were reading failures as facts.- Not a type checker. Nothing stops
UNKNOWNreaching a library that will do something odd with it. It is not anumpy-style NA and does not propagate through arrays or dataframes. - The linter is a heuristic, not a proof. It reads one function at a time and cannot see that
a
0returned in one module becomes a threshold in another.CNT004is off by default because most.get(k, 0)calls are innocent. Expect to add a few justified suppressions. - Performance. The sentinel adds an object and a couple of branches per measurement. It is not for a tight inner loop; it is for the twenty places in your system that decide something.
- It will not fix a measurement that lies. A probe that returns a confident wrong number is a different problem, and no sentinel can help you with it.
MIT.