DEV Community

Taylor Wang
Taylor Wang

Posted on

I Printed python --version for 48 Hours. The Shebang Had Never Agreed.

Have you ever trusted a green bar from an agent, then rerun the file locally and watched it fail immediately? I did that recently, and I burned two calendar days before I printed the one path that actually mattered. The suite was not flaky, and the model was not inventing a pass that never happened on disk. Two interpreters were sharing a command name, and only one of them could see my fixtures.

Hour 0: a timestamp that moved when I did

The failing test asserted on a timestamp that a helper built with a naive datetime.now() call. On the remote session the assertion passed, and the agent kept telling me the patch was already complete. On my laptop the same assertion drifted by several hours, which made me blame pytest, TZ, and then a leftover Docker daemon. Why would a one-line datetime helper lie in one place and tell the truth in another?

I keep a small Python service with tests that freeze "now" poorly, because the original author treated local time as a universal constant. That is a bad idea, and I knew it, which is why I let an agent take the first swing. I wanted a second checkout that would not contaminate the venv on my laptop while I still had other branches open.

Hour 6: what I tried anyway

Here is the unflattering list, because field notes are useless if they only contain the clean ending. I am leaving the dead ends in, even the ones that wasted a whole evening. You can laugh at them after you check whether your own which python is a shim.

  1. I reran pytest tests/test_schedule.py -vv until the failure output started to blur.
  2. I exported TZ=UTC in my shell, then forgot that pytest had been launched by a Makefile.
  3. I grepped for datetime.now and replaced one call, then missed the helper two modules away.
  4. I asked the agent to make the test timezone-aware without ever asking which Python would run it.
  5. I compared python --version on both sides and got the same marketing string, which felt like proof.

Does any of that sound like debugging, or does it sound like I was soothing myself with motion? I still think the grep was reasonable given the symptoms I had written down. The version string was a trap, because it is designed to look complete while hiding the path.

Commands that lied politely

I ran the usual identity checks, and every one of them returned something that looked adult and specific. None of them printed sys.executable. None of them printed time.tzname. None of them opened the shebang inside the test runner I thought I was executing.

python --version
python3 --version
which python
which python3
type python
head -n 1 "$(command -v pytest)"
ls -l "$(command -v python)" "$(command -v python3)" "$(command -v pytest)"
Enter fullscreen mode Exit fullscreen mode

Would you have stopped after python --version matched on both machines? I did, and that is the whole joke. A version string is a slogan. A path is evidence.

Hour 18: what actually broke

The remote box had a python that was a real interpreter, plus a pytest console script whose shebang pointed at that interpreter. My laptop had a pyenv shim named python, a Homebrew python3, and a venv I had created three weeks earlier and then stopped activating. Guess which binary pytest selected when I typed pytest from a random subdirectory with no venv prompt?

The naive datetime.now() call was the fuse, not the bomb sitting under the suite. On the remote box the zone was UTC, so "now" lined up with the fixture's expected clock. On my laptop the zone was the room I sit in, so the same naive object carried a different civil time. Was the agent wrong about the remote run? No. It was wrong about the claim that the remote run was my run.

I also found a test module whose shebang still said #!/usr/bin/env python without a pinned version. That env walk hit a different binary than the venv I believed I had activated in another tab. Have you checked whether your test runner and your python -c one-liner are the same inode? I had not checked, and that is an embarrassing sentence to write in a public field note.

The artifact: a run receipt I now print first

I got tired of reconstructing the scene from memory, so I wrote a tiny receipt script that the test session can print. It is boring on purpose, because flashy logs are how I ignored the interpreter for two days. The script dumps paths, zone info, and a short hash of the executable so two machines cannot impersonate each other. If a line in that dump disagrees, I stop asking the agent for another patch.

# run_receipt.py
# Proposal: drop this next to pytest.ini and print it at session start.
from __future__ import annotations

import hashlib
import os
import platform
import sys
import time
from datetime import datetime, timezone
from pathlib import Path


def _sha256(path: Path) -> str:
    try:
        data = path.read_bytes()
    except OSError as exc:
        return f"unreadable:{exc.__class__.__name__}"
    return hashlib.sha256(data).hexdigest()[:12]


def build_receipt() -> dict[str, str]:
    argv0 = Path(sys.argv[0]).resolve()
    tzinfo = datetime.now().astimezone().tzinfo
    git_head = Path(".git/HEAD")
    return {
        "cwd": str(Path.cwd()),
        "argv0": sys.argv[0],
        "executable": sys.executable,
        "executable_sha": _sha256(Path(sys.executable)),
        "version": sys.version.replace("\n", " "),
        "platform": platform.platform(),
        "prefix": sys.prefix,
        "base_prefix": sys.base_prefix,
        "in_venv": str(sys.prefix != sys.base_prefix),
        "path0": sys.path[0] if sys.path else "",
        "tzname": time.tzname[0] if time.tzname else "",
        "tzinfo": str(tzinfo),
        "utc_now": datetime.now(timezone.utc).isoformat(),
        "naive_now": datetime.now().isoformat(),
        "lang": os.environ.get("LANG", ""),
        "tz_env": os.environ.get("TZ", ""),
        "pyenv_version": os.environ.get("PYENV_VERSION", ""),
        "virtual_env": os.environ.get("VIRTUAL_ENV", ""),
        "git_head": git_head.read_text().strip() if git_head.exists() else "no-git",
        "argv0_sha": _sha256(argv0),
    }


def format_receipt(data: dict[str, str]) -> str:
    width = max(len(k) for k in data)
    lines = ["--- run receipt ---"]
    for key, value in data.items():
        lines.append(f"{key.ljust(width)}  {value}")
    lines.append("--- end receipt ---")
    return "\n".join(lines)


if __name__ == "__main__":
    print(format_receipt(build_receipt()))
Enter fullscreen mode Exit fullscreen mode

I hook it from conftest.py so I cannot forget to run it when I am already angry. Session-scoped autouse is loud on purpose. If the receipt is hidden behind a flag, I will not pass the flag.

# conftest.py
import pytest

from run_receipt import build_receipt, format_receipt


@pytest.fixture(scope="session", autouse=True)
def _print_run_receipt() -> None:
    print("\n" + format_receipt(build_receipt()))
Enter fullscreen mode Exit fullscreen mode

If you want a file instead of stdout, write JSON under artifacts/receipt.json and compare it across machines. I have not automated a fail-on-drift gate in CI yet, so treat that extra step as a proposal. The table below is the part I actually use when two receipts refuse to line up.

What a disagreement is trying to tell you

Receipt field Local and remote match? What I do next
executable No Stop editing tests. Align the binary or stop comparing runs.
in_venv No Activate the venv you meant, or recreate it on the other side.
tzinfo / TZ No Stop using naive datetime.now(). Freeze time with an aware clock.
cwd No You are not running the suite you think you are running.
git_head No The agent patched a different commit than the one you are reading.
naive_now vs utc_now Drift Your helper is encoding local civil time as if it were universal.

Would I have needed that table if I had printed sys.executable at hour one? Obviously not. I needed it because I kept asking the agent to patch symptoms. The receipt does not fix the helper. It stops me from negotiating with the wrong machine.

Where a free remote loop made this louder

I parked the coding agent on a separate machine because I wanted a dirty worktree that would not collide with the branch in my editor. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode's free model access and the free server option for that loop. That pairing matters because the model and the tests shared a machine that was not my laptop.

That split is useful when you want the agent to install packages, run pytest, and leave your local venv alone. It is also how you accidentally debug a UTC box while reading stack traces with your own timezone still in your head. The receipt is the cheapest way I have found to keep those two stories from merging in a chat window.

I am not claiming the remote session is faster, smarter, or some kind of replacement for a real CI image. I am claiming that "it passed where the agent ran" is not the same sentence as "it passed where I live." If those two sentences collapse into one, you will debug ghosts for a weekend.

What I would repeat in the next 48 hours

If I am going to let an agent touch tests on a box I cannot see, I want a ritual that does not depend on my mood. The list is short because I will not follow a list that reads like a manifesto. Each item below is something I failed to do before hour eighteen of this mess.

  1. Print a run receipt before the first pytest invocation, not after the third failure.
  2. Compare sys.executable as a path, not python --version as a slogan.
  3. Replace naive datetime.now() with datetime.now(timezone.utc) or a fake clock.
  4. Refuse to discuss a patch until git_head on both sides matches.
  5. Keep the agent's worktree on the remote box, and pull a diff only after the receipt matches.

Would I still use naive datetime in a scheduling helper after this? Not a chance, not even in a demo fixture. Would I still trust a shebang that says python without a pinned interpreter on PATH? Also no, and I should have known that before hour six.

A minimal fix for the original helper looks like the example below. It stores instants in UTC and refuses to compare a naive string that lost its zone.

from datetime import datetime, timezone


def utc_now() -> datetime:
    return datetime.now(timezone.utc)


def parse_expected(raw: str) -> datetime:
    # Example only: store instants in UTC, compare instants in UTC.
    stamp = datetime.fromisoformat(raw)
    if stamp.tzinfo is None:
        raise ValueError(f"naive expected timestamp is not comparable: {raw!r}")
    return stamp.astimezone(timezone.utc)
Enter fullscreen mode Exit fullscreen mode

And the test stops doing civil-time arithmetic on a naive object. This is the shape I want, not a dump of a private suite.

from datetime import datetime, timezone


def test_schedule_window_is_utc(monkeypatch):
    frozen = datetime(2026, 9, 7, 15, 0, tzinfo=timezone.utc)

    class _FrozenDateTime(datetime):
        @classmethod
        def now(cls, tz=None):
            if tz is None:
                raise AssertionError("naive datetime.now() is banned in this helper")
            return frozen.astimezone(tz)

    monkeypatch.setattr("payments.schedule.datetime", _FrozenDateTime)
    # call the helper and assert against `frozen`
Enter fullscreen mode Exit fullscreen mode

Limitations, and who should skip this

This receipt habit will annoy you if you only ever have one Python and one machine. If your CI image is the only place tests run, a local receipt can become a second source of truth you start to worship. Do not paste receipts into public issues without redacting cwd, usernames, and virtualenv paths.

The script does not prove tests are correct. It only proves which binary, zone, and commit produced a given wall of green. It will not help if your code formats local time into a string and then parses that string as UTC somewhere else, unless you also delete that round trip.

People shipping air-gapped appliances, or people who cannot run anything except a managed CI runner, should keep the idea and skip the remote-agent half. I also would not use a free remote box as a stand-in for production timezone configuration. Staging should still look like staging. A receipt is a flashlight, not a replica of your customers' clocks.

If you already keep a remote agent in the loop, steal the receipt script and ignore the rest of my opinions. The datetime bug was mine, and the missing executable line was also mine from the start. I just needed two long days and a boring dump of sys.executable before I could admit it.

Top comments (0)