Skip to content

About

A failed measurement is not a value — a three-state UNKNOWN that refuses to be mistaken for zero, plus a linter that finds where your code turns failures into numbers

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

2 Commits

Folders and files

Repository files navigation

couldnottell

tests

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+.

The bug class

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.

The fix, in three pieces

1. A third state that refuses to behave like a value

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.

2. A guard, so "could not tell" never becomes permission

from couldnottell import refuses_unknown

@refuses_unknown
def sweep(free_gb, protected_names):
    ...          # never runs when either argument could not be measured

This 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.

3. A linter, because the rule is about code you have already written

$ 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 it

Put 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 test pattern

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 suite

The 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".

Install

pip install couldnottell     # once published
pip install .                # from a checkout

Limitations, honestly

  • UNKNOWN is 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, keep is_unknown() at the boundaries, and let the crashes show you the gates that were reading failures as facts.
  • Not a type checker. Nothing stops UNKNOWN reaching a library that will do something odd with it. It is not a numpy-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 0 returned in one module becomes a threshold in another. CNT004 is 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.

Licence

MIT.

About

A failed measurement is not a value — a three-state UNKNOWN that refuses to be mistaken for zero, plus a linter that finds where your code turns failures into numbers

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages