mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:07:17 +01:00
earned_time 0.17.0: a LeetCode day paid from banked credits earns the bonus
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e74aa7b0ea
commit
20b2cdbc04
@ -10,7 +10,7 @@ gaming time and shutdown time, read by both consumers.
|
||||
|
||||
A gate (leetcode-guard, book-guard, anki-guard, screen-locker's workout
|
||||
credit, ...) never touches either. It publishes a fact — HMAC-signed `credit`
|
||||
rows in its ledger — and is registered here once.
|
||||
rows in its ledger — and is registered here once. LeetCode also counts its signed `source == "leetcode"` `charge` for the day (0.17.0, `ROW_KINDS`): a day paid ahead from banked credits earns like a solve; escape and outage charges never do.
|
||||
|
||||
## The model
|
||||
|
||||
@ -243,7 +243,7 @@ on the day itself is logged and does not count.
|
||||
## Install
|
||||
|
||||
```console
|
||||
pip install --user --break-system-packages --no-deps "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.16.0#subdirectory=earned_time"
|
||||
pip install --user --break-system-packages --no-deps "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.17.0#subdirectory=earned_time"
|
||||
```
|
||||
|
||||
The root `steam-backlog-enforcer.service` sets `HOME=/home/kuhy`, so it imports
|
||||
|
||||
@ -35,9 +35,11 @@ if TYPE_CHECKING:
|
||||
|
||||
from earned_time._policy import Earner, Window
|
||||
|
||||
from earned_time._match import ROW_KINDS
|
||||
|
||||
_logger: Final = logging.getLogger(__name__)
|
||||
|
||||
_CREDIT: Final = "credit"
|
||||
_CREDIT_ONLY: Final = frozenset({"credit"})
|
||||
|
||||
|
||||
def entry_signature(entry: dict[str, object], key: bytes) -> str:
|
||||
@ -119,7 +121,9 @@ def read_rows(ledger: Path, earner: Earner) -> list[object] | None:
|
||||
def counting_rows(
|
||||
earner: Earner, ledger: Path, key_file: Path, window: Window
|
||||
) -> Iterator[dict[str, object]] | None:
|
||||
"""The verified ``credit`` rows that ``earner.match`` counts in ``window``.
|
||||
"""The verified rows that ``earner.match`` counts in ``window``.
|
||||
|
||||
``credit`` rows, plus whatever else ``ROW_KINDS`` shows that matcher.
|
||||
|
||||
``None`` when the key or the ledger cannot be read.
|
||||
|
||||
@ -136,11 +140,12 @@ def counting_rows(
|
||||
if rows is None:
|
||||
return None
|
||||
match = earner.match
|
||||
kinds = ROW_KINDS.get(match, _CREDIT_ONLY)
|
||||
return (
|
||||
row
|
||||
for row in rows
|
||||
if isinstance(row, dict)
|
||||
and row.get("kind") == _CREDIT
|
||||
and row.get("kind") in kinds
|
||||
and verified(row, key)
|
||||
and match(row, window)
|
||||
)
|
||||
|
||||
@ -37,8 +37,21 @@ def leetcode_match(row: dict[str, object], window: Window) -> bool:
|
||||
``day`` is the *harvesting* run's date and runs late, so it is only the
|
||||
fallback for a row without a usable stamp: it can miss a late solve but
|
||||
never invent one.
|
||||
|
||||
A day paid ahead from banked credits counts too (0.17.0, kuhy 2026-10-23:
|
||||
working ahead must not forfeit the bonus). leetcode-guard writes a
|
||||
``charge`` with ``source == "leetcode"`` only when verified credits cover
|
||||
the day; its escape hatch and outage path write other sources and pay
|
||||
nothing. The charge counts for the day it names.
|
||||
"""
|
||||
detail = row.get("detail")
|
||||
if row.get("kind") == "charge":
|
||||
local = datetime.fromtimestamp(window[0], tz=UTC).astimezone().date()
|
||||
return (
|
||||
isinstance(detail, dict)
|
||||
and detail.get("source") == "leetcode"
|
||||
and row.get("day") == local.isoformat()
|
||||
)
|
||||
raw = detail.get("submitted_at") if isinstance(detail, dict) else None
|
||||
if raw is not None:
|
||||
landed = _within(raw, window)
|
||||
@ -172,3 +185,9 @@ CREDIT_STAMPS: Final[Mapping[CreditMatch, str]] = MappingProxyType(
|
||||
ROW_UNITS: Final[Mapping[CreditMatch, Callable[[dict[str, object]], int | None]]] = (
|
||||
MappingProxyType({tutor_match: tutor_minutes})
|
||||
)
|
||||
# Which ledger row kinds each matcher is shown; a matcher without an entry
|
||||
# sees only ``credit`` rows. LeetCode also sees ``charge`` rows, so a day
|
||||
# settled from banked credits counts (``leetcode_match``).
|
||||
ROW_KINDS: Final[Mapping[CreditMatch, frozenset[str]]] = MappingProxyType(
|
||||
{leetcode_match: frozenset({"credit", "charge"})}
|
||||
)
|
||||
|
||||
97
earned_time/earned_time/tests/test_settled_by_credit.py
Normal file
97
earned_time/earned_time/tests/test_settled_by_credit.py
Normal file
@ -0,0 +1,97 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""A LeetCode day settled from banked credits earns the bonus like a solve.
|
||||
|
||||
kuhy, 2026-10-23: five solves on 10-22 paid 10-23's gate in advance, and the
|
||||
bonus (gaming, leisure, shutdown) still said "no solve today" -- the system
|
||||
punished working ahead. A signed ``charge`` with ``source == "leetcode"`` is
|
||||
written only when verified credits cover the day (leetcode-guard
|
||||
``_gate.decide``); the escape hatch and the outage path write their own
|
||||
sources and must never pay.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from earned_time import (
|
||||
LEETCODE,
|
||||
READING,
|
||||
done_today,
|
||||
entry_signature,
|
||||
first_credit_at,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
KEY = b"test-key"
|
||||
NOON = datetime(2026, 10, 23, 12, tzinfo=UTC)
|
||||
TODAY = NOON.astimezone().date()
|
||||
CHARGED_AT = "2026-10-23T09:00:00+00:00"
|
||||
|
||||
|
||||
def _charge(day: str, source: str = "leetcode") -> dict[str, object]:
|
||||
row: dict[str, object] = {
|
||||
"entry_id": f"charge:{day}",
|
||||
"kind": "charge",
|
||||
"day": day,
|
||||
"created_at": CHARGED_AT,
|
||||
"amount": 4,
|
||||
"detail": {"source": source},
|
||||
}
|
||||
return {**row, "hmac": entry_signature(row, KEY)}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def key_file(tmp_path: Path) -> Path:
|
||||
path = tmp_path / "hmac.key"
|
||||
path.write_bytes(KEY)
|
||||
return path
|
||||
|
||||
|
||||
def _ledger(tmp_path: Path, rows: list[object]) -> Path:
|
||||
path = tmp_path / "ledger.json"
|
||||
path.write_text(json.dumps({"entries": rows}), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def test_a_day_paid_from_banked_credits_counts(tmp_path: Path, key_file: Path) -> None:
|
||||
path = _ledger(tmp_path, [_charge(TODAY.isoformat())])
|
||||
assert done_today(LEETCODE, path, key_file, now=NOON) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["escape", "leetcode-outage", "seed"])
|
||||
def test_a_forgiven_day_never_counts(
|
||||
tmp_path: Path, key_file: Path, source: str
|
||||
) -> None:
|
||||
path = _ledger(tmp_path, [_charge(TODAY.isoformat(), source)])
|
||||
assert done_today(LEETCODE, path, key_file, now=NOON) is False
|
||||
|
||||
|
||||
def test_a_forged_charge_never_counts(tmp_path: Path, key_file: Path) -> None:
|
||||
forged = {**_charge(TODAY.isoformat()), "hmac": "forged"}
|
||||
path = _ledger(tmp_path, [forged])
|
||||
assert done_today(LEETCODE, path, key_file, now=NOON) is False
|
||||
|
||||
|
||||
def test_yesterdays_charge_pays_yesterday_only(tmp_path: Path, key_file: Path) -> None:
|
||||
path = _ledger(tmp_path, [_charge("2026-10-22")])
|
||||
assert done_today(LEETCODE, path, key_file, now=NOON) is False
|
||||
|
||||
|
||||
def test_only_leetcode_reads_charges(tmp_path: Path, key_file: Path) -> None:
|
||||
path = _ledger(tmp_path, [_charge(TODAY.isoformat())])
|
||||
assert done_today(READING, path, key_file, now=NOON) is False
|
||||
|
||||
|
||||
def test_the_settling_charge_is_timed_at_its_writing(
|
||||
tmp_path: Path, key_file: Path
|
||||
) -> None:
|
||||
path = _ledger(tmp_path, [_charge(TODAY.isoformat())])
|
||||
expected = datetime.fromisoformat(CHARGED_AT).timestamp()
|
||||
assert first_credit_at(LEETCODE, path, key_file, TODAY) == expected
|
||||
assert first_credit_at(LEETCODE, path, key_file, date(2026, 10, 22)) is None
|
||||
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "earned-time"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
description = "Penalty-then-reward registry: what every gate app earns in gaming time and shutdown time"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = []
|
||||
|
||||
Loading…
Reference in New Issue
Block a user