You point the script at a folder of three log files. It reads them, prints nothing, and dies with a TypeError on the line that writes the report. The last two lines of the error are these:
$ python3 daily.py logs
TypeError: Object of type datetime is not JSON serializable
when serializing dict item 'generated_at'
The second line names the dict key that held the value json refused. The numbers here come from Python 3.14.9 on Linux.
Key takeaways
json.dumpswrites a fixed set of basic types, and adatetimeis not one of them, so the encoder raises instead of choosing a format for you.datetime.now()returns the machine's local time with no timezone, andisoformat(timespec="seconds")is the documented way to turn it into the string json can write.argparseanswers-hwithout being asked,pathlibturns a folder into paths and text, andjsonturns text into lists and dicts.- A checker that only asserts the stamp parses cannot tell
timespec="hours"fromtimespec="seconds", so it proves the shape, not the precision.
Four modules, one small program
daily.py adds up minutes per day from a folder of session logs. Each of four standard-library modules has one job.
# daily.py
import argparse
import json
from datetime import datetime
from pathlib import Path
parser = argparse.ArgumentParser(description="Minutes per day from a folder of session logs")
parser.add_argument("folder")
args = parser.parse_args()
total = {}
for path in sorted(Path(args.folder).glob("*.json")):
if path.name == "summary.json":
continue
for session in json.loads(path.read_text()):
total[session["date"]] = total.get(session["date"], 0) + session["minutes"]
report = {
"generated_at": datetime.now(),
"days": [{"date": day, "minutes": minutes} for day, minutes in sorted(total.items())],
}
out = Path(args.folder) / "summary.json"
out.write_text(json.dumps(report, indent=2))
print(f"{len(report['days'])} days from {len(list(Path(args.folder).glob('*.json'))) - 1} files")
argparse reads one argument, the folder. You never asked for -h, but it exists anyway:
$ python3 daily.py --help
usage: daily.py [-h] folder
Minutes per day from a folder of session logs
positional arguments:
folder
options:
-h, --help show this help message and exit
pathlib builds the paths. glob("*.json") lists the log files, and read_text() hands you each one as a string. json turns that text into lists and dicts, then turns the report back into text. datetime supplies the stamp.
The glob also matches summary.json, the file the script is about to write. The if skips it. Without that, a second run would read its own output as a session log. The - 1 in the last line removes it from the file count, since it exists by then.
Why json will not take a datetime
The json module writes a short list of types: string, number, boolean, None, list and dict. A datetime is not on it.
When json.dumps meets a type outside that list, it raises a TypeError. It does not guess a format. A date can be written many ways, so someone has to choose one. ISO 8601 is the choice here: year, month, day, a T, then the time. The documented way to get that string is isoformat().
The checker
check_daily.py is a plain script with no test framework. Run it from the folder that holds daily.py.
# check_daily.py
import json
import subprocess
import sys
import tempfile
from datetime import datetime
from pathlib import Path
FIXTURE = {
"mon.json": [{"date": "2026-11-06", "minutes": 25}, {"date": "2026-11-06", "minutes": 35}],
"tue.json": [{"date": "2026-11-07", "minutes": 40}],
"wed.json": [{"date": "2026-11-02", "minutes": 15}, {"date": "2026-11-08", "minutes": 50}],
}
EXPECTED = {"2026-11-02": 15, "2026-11-06": 60, "2026-11-07": 40, "2026-11-08": 50}
def report(results):
for label, ok, detail in results:
print(f"{'PASS' if ok else 'FAIL'} {label}" + (f" <- {detail}" if not ok and detail else ""))
passed = sum(1 for _, ok, _ in results if ok)
print(f"\n{passed} of {len(results)} checks passed")
return passed == len(results)
def main():
folder = Path(tempfile.mkdtemp())
for name, sessions in FIXTURE.items():
(folder / name).write_text(json.dumps(sessions))
run = subprocess.run([sys.executable, "daily.py", str(folder)], capture_output=True, text=True)
crash = ""
for line in reversed(run.stderr.strip().splitlines()):
if line.split(":")[0].endswith("Error"):
crash = line
break
if run.returncode != 0:
report([("the script runs without crashing", False, crash)])
return 1
results = [("the script runs without crashing", True, "")]
out = folder / "summary.json"
results.append(("summary.json is written", out.exists(), "no file"))
if not out.exists():
report(results)
return 1
text = out.read_text()
try:
summary = json.loads(text)
except json.JSONDecodeError as exc:
report(results + [("summary.json is valid JSON", False, str(exc))])
return 1
results.append(("summary.json is valid JSON", True, ""))
days = summary.get("days", [])
results.append(("one row per calendar day", len(days) == 4, f"got {len(days)}"))
minutes = {row["date"]: row["minutes"] for row in days if isinstance(row.get("date"), str)}
results.append(("the two sessions on 2026-11-06 are added up", minutes.get("2026-11-06") == 60, f"got {minutes.get('2026-11-06')}"))
results.append(("every day carries its own total", minutes == EXPECTED, str(minutes)))
results.append(("the rows come out in date order", [row["date"] for row in days] == sorted(EXPECTED), str([row["date"] for row in days])))
stamp = summary.get("generated_at")
iso = False
if isinstance(stamp, str):
try:
iso = datetime.fromisoformat(stamp) is not None
except ValueError:
iso = False
results.append(("generated_at is an ISO 8601 string", iso, repr(stamp)))
results.append(("the script prints its own summary line", run.stdout.strip() == "4 days from 3 files", repr(run.stdout.strip())))
return 0 if report(results) else 1
if __name__ == "__main__":
sys.exit(main())
It writes a fixed fixture folder: three files, with two sessions on 2026-11-06 in one of them. It runs daily.py on that folder, then asserts on what the script printed and on the JSON it wrote. Against the starter script, the result is red:
$ python3 check_daily.py
FAIL the script runs without crashing <- TypeError: Object of type datetime is not JSON serializable
0 of 1 checks passed
The exit code is 1. The text after the arrow is the error line the checker pulled from stderr. It stops at the first failure on purpose. A crashed script writes no summary.json, so the other checks would have nothing to look at.
The one line that fixes it
datetime.now() returns the machine's local time with no timezone attached. Add .isoformat(timespec="seconds") and you get a string, which json already knows how to write. Only the generated_at line changes.
# daily.py
import argparse
import json
from datetime import datetime
from pathlib import Path
parser = argparse.ArgumentParser(description="Minutes per day from a folder of session logs")
parser.add_argument("folder")
args = parser.parse_args()
total = {}
for path in sorted(Path(args.folder).glob("*.json")):
if path.name == "summary.json":
continue
for session in json.loads(path.read_text()):
total[session["date"]] = total.get(session["date"], 0) + session["minutes"]
report = {
"generated_at": datetime.now().isoformat(timespec="seconds"),
"days": [{"date": day, "minutes": minutes} for day, minutes in sorted(total.items())],
}
out = Path(args.folder) / "summary.json"
out.write_text(json.dumps(report, indent=2))
print(f"{len(report['days'])} days from {len(list(Path(args.folder).glob('*.json'))) - 1} files")
A real run on a folder with three log files printed exactly 4 days from 3 files. The checker agrees, with exit code 0:
$ python3 check_daily.py
PASS the script runs without crashing
PASS summary.json is written
PASS summary.json is valid JSON
PASS one row per calendar day
PASS the two sessions on 2026-11-06 are added up
PASS every day carries its own total
PASS the rows come out in date order
PASS generated_at is an ISO 8601 string
PASS the script prints its own summary line
9 of 9 checks passed
The summary.json from that real run began with the stamp, then four day rows:
{
"generated_at": "2026-11-08T00:01:43",
"days": [
{
"date": "2026-11-02",
"minutes": 15
},
{
"date": "2026-11-06",
"minutes": 60
},
{
"date": "2026-11-07",
"minutes": 40
},
{
"date": "2026-11-08",
"minutes": 50
}
]
}
The 60 is a sum. That file held two sessions, 25 and 35 minutes.
What the checker cannot see
Change timespec="seconds" to timespec="hours" and run the checker again. It still prints 9 of 9 checks passed and exits 0. datetime.fromisoformat accepts that shorter string. The date check proves the stamp parses. It does not prove the stamp has the precision you wanted.
Before the next article
Delete sorted() around total.items() and run the checker once more. You get 8 of 9 checks and exit code 1:
$ python3 check_daily.py
PASS the script runs without crashing
PASS summary.json is written
PASS summary.json is valid JSON
PASS one row per calendar day
PASS the two sessions on 2026-11-06 are added up
PASS every day carries its own total
FAIL the rows come out in date order <- ['2026-11-06', '2026-11-07', '2026-11-02', '2026-11-08']
PASS generated_at is an ISO 8601 string
PASS the script prints its own summary line
8 of 9 checks passed
Dicts keep insertion order, so the rows come out in the order the files were read. A row order you did not sort is an order you did not choose.