mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 22:47:14 +01:00
Compare commits
21 Commits
data_trans
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d6b17fd28 | |||
| ae56037e41 | |||
| eef7af4c86 | |||
| 9daa376254 | |||
| 17e300af0a | |||
| afcc1a47c7 | |||
| 0527bc680d | |||
| 21a0b5ce1c | |||
| 4c1ffd900b | |||
| 5a87868763 | |||
| fe4e34287a | |||
| 2f4f79bd6e | |||
| 51b747a364 | |||
| 89f464828a | |||
| eaf7282ac1 | |||
| 6641d85f05 | |||
| 46ab26fa15 | |||
| f71b0532fd | |||
| 3017bbf220 | |||
| fd81332f27 | |||
| ebedbf4b27 |
73
.github/workflows/code-runner-precommit.yml
vendored
73
.github/workflows/code-runner-precommit.yml
vendored
@ -1,73 +0,0 @@
|
|||||||
name: code-runner pre-commit
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
# The workflow's own file counts as code_runner: editing only this file would
|
|
||||||
# otherwise trigger nothing, so a fix to the job itself could never prove
|
|
||||||
# itself.
|
|
||||||
paths: ['code_runner/**', '.github/workflows/code-runner-precommit.yml']
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
paths: ['code_runner/**', '.github/workflows/code-runner-precommit.yml']
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
pre-commit:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.x"
|
|
||||||
# Only pre-commit itself. Every linting hook runs in its own isolated
|
|
||||||
# env -- ruff, mypy, pylint, bandit and codespell each come from their
|
|
||||||
# own repo+rev, and the local hooks need nothing beyond bash and
|
|
||||||
# python3 -- so installing the full requirements.txt spent 9s a run on
|
|
||||||
# packages no pre-commit-stage hook imports. Verified by running all
|
|
||||||
# three configs green in a venv holding pre-commit and nothing else.
|
|
||||||
# The pin is read out of requirements.txt rather than repeated here, so
|
|
||||||
# it cannot drift from what the dependency-freshness gate governs.
|
|
||||||
- name: Install pre-commit
|
|
||||||
run: |
|
|
||||||
pip install "$(grep '^pre-commit==' code_runner/requirements.txt)"
|
|
||||||
|
|
||||||
# Keyed on THIS package's config. pre-commit/action@v3.0.1 caches on
|
|
||||||
# hashFiles('.pre-commit-config.yaml') -- the repo ROOT's -- so all three
|
|
||||||
# package jobs shared one key, raced to overwrite it with hook sets built
|
|
||||||
# from four different configs, and editing a per-package config did not
|
|
||||||
# invalidate it. The cache was therefore silently wrong rather than
|
|
||||||
# missing, and the hooks reinstalled from scratch on every run (38-49s).
|
|
||||||
- uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.cache/pre-commit
|
|
||||||
key: >-
|
|
||||||
pre-commit-code-runner-${{ env.pythonLocation }}-${{
|
|
||||||
hashFiles('code_runner/.pre-commit-config.yaml') }}
|
|
||||||
# Prefix fallback, or this fix would evaporate on the very
|
|
||||||
# event that motivated it: bumping a hook rev changes the
|
|
||||||
# exact key, and a total miss means reinstalling every hook
|
|
||||||
# again. pre-commit's store is keyed internally by hook
|
|
||||||
# repo+rev, so a superseded entry is ignored disk rather
|
|
||||||
# than a wrong hook, and cache still re-saves under the new
|
|
||||||
# exact key after a prefix-only hit.
|
|
||||||
restore-keys: pre-commit-code-runner-${{ env.pythonLocation }}-
|
|
||||||
# mypy re-analyses the stdlib and every third-party stub from scratch
|
|
||||||
# without this, which measured 10s of the hook step in CI against 0.25s
|
|
||||||
# warm locally -- a 19MB cache dir does the whole difference. Keyed on
|
|
||||||
# the config, not the sources: mypy's own cache metadata records the
|
|
||||||
# interpreter, the flag set and a hash per source file, so it re-checks
|
|
||||||
# what changed and cannot pass stale code. Deliberately NOT keyed on
|
|
||||||
# github.sha -- that re-saves 19MB x3 every commit and would evict the
|
|
||||||
# 68MB hook caches under the repo's 10GB LRU budget. A fixed key keeps
|
|
||||||
# the expensive part, the dependency analysis, which is what costs 10s.
|
|
||||||
- uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: .mypy_cache
|
|
||||||
key: >-
|
|
||||||
mypy-code-runner-${{ env.pythonLocation }}-${{
|
|
||||||
hashFiles('code_runner/.pre-commit-config.yaml') }}
|
|
||||||
restore-keys: mypy-code-runner-${{ env.pythonLocation }}-
|
|
||||||
- name: Run the hooks
|
|
||||||
run: >-
|
|
||||||
pre-commit run --config code_runner/.pre-commit-config.yaml
|
|
||||||
--all-files --show-diff-on-failure
|
|
||||||
34
.github/workflows/code-runner-tests.yml
vendored
34
.github/workflows/code-runner-tests.yml
vendored
@ -1,34 +0,0 @@
|
|||||||
name: code-runner tests
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
# The workflow's own file counts as code_runner: editing only this file would
|
|
||||||
# otherwise trigger nothing, so a fix to the job itself could never prove
|
|
||||||
# itself.
|
|
||||||
paths: ['code_runner/**', '.github/workflows/code-runner-tests.yml']
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
paths: ['code_runner/**', '.github/workflows/code-runner-tests.yml']
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: code_runner
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.x"
|
|
||||||
cache: pip
|
|
||||||
cache-dependency-path: code_runner/requirements.txt
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pip install -r requirements.txt && pip install --no-deps -e .
|
|
||||||
|
|
||||||
- name: Run tests with coverage
|
|
||||||
run: python -m pytest -v
|
|
||||||
@ -13,7 +13,7 @@
|
|||||||
# linters, and carry this same hook so committing with one of those configs is
|
# linters, and carry this same hook so committing with one of those configs is
|
||||||
# gated too. This root config is what `pre-commit install` wires up, and it is
|
# gated too. This root config is what `pre-commit install` wires up, and it is
|
||||||
# the only one that covers the subprojects without a config of their own
|
# the only one that covers the subprojects without a config of their own
|
||||||
# (aur-publish/, guard-lib/, crdt_sync_dart/, file_length/, ...).
|
# (guard-lib/, crdt_sync_dart/, file_length/, ...).
|
||||||
#
|
#
|
||||||
# The path is repo-relative on purpose. pre-commit always runs hooks from the
|
# The path is repo-relative on purpose. pre-commit always runs hooks from the
|
||||||
# git root, so this resolves the same locally and on a CI runner -- an absolute
|
# git root, so this resolves the same locally and on a CI runner -- an absolute
|
||||||
|
|||||||
@ -1,45 +0,0 @@
|
|||||||
# aur-publish
|
|
||||||
|
|
||||||
Hands-off publishing of kuhy's Flutter apps to the AUR.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
~/src/utils/aur-publish/publish.sh # wait for registration, then publish
|
|
||||||
~/src/utils/aur-publish/publish.sh --dry-run # everything except the push
|
|
||||||
~/src/utils/aur-publish/publish.sh --skip-wait # account already works
|
|
||||||
~/src/utils/aur-publish/publish.sh --only habit-stack
|
|
||||||
```
|
|
||||||
|
|
||||||
## What it does
|
|
||||||
|
|
||||||
1. Polls `/register` until AUR signup reopens, opens it in a browser and
|
|
||||||
prints the SSH key to paste. **Signup is the only manual step** — it
|
|
||||||
requires email confirmation.
|
|
||||||
2. Waits for `ssh aur@aur.archlinux.org` to authenticate.
|
|
||||||
3. Then, unattended, per package: resolve the newest `vX.Y.Z` tag → set
|
|
||||||
`pkgver` → `updpkgsums` → `makepkg -C` → `namcap` → AUR rule check →
|
|
||||||
`pacman -U` → launch check → `.SRCINFO` → commit → push.
|
|
||||||
|
|
||||||
Every package is built, installed and **launched** before it is pushed, so a
|
|
||||||
broken package cannot reach the AUR. A package failing any gate is skipped and
|
|
||||||
reported; the others still publish. Safe to re-run.
|
|
||||||
|
|
||||||
## anubis_gate.py
|
|
||||||
|
|
||||||
The AUR sits behind [Anubis](https://anubis.techaro.lol/), which answers HTML
|
|
||||||
requests with a proof-of-work challenge page carrying **HTTP 200**. A plain
|
|
||||||
`curl` therefore reports 200 for a page that is actually unreachable — which is
|
|
||||||
exactly how "is registration back?" gets answered wrong. This module solves the
|
|
||||||
challenge and reports the status of the *real* page behind it.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 anubis_gate.py /register # prints the true status code
|
|
||||||
```
|
|
||||||
|
|
||||||
## AUR rules enforced before pushing
|
|
||||||
|
|
||||||
The AUR does not review uploads, but per the submission guidelines "packages
|
|
||||||
that violate the rules may be deleted without warning". `check_aur_rules()`
|
|
||||||
blocks a push on the mechanically checkable ones: `# Maintainer:` line,
|
|
||||||
`x86_64` in `arch`, no `replaces=` (reserved for renames — use `conflicts`),
|
|
||||||
not already in an official repo, `pkgdesc`/`url`/`license` present, and
|
|
||||||
`.SRCINFO` matching the PKGBUILD.
|
|
||||||
@ -1,138 +0,0 @@
|
|||||||
"""Fetch an AUR URL from behind the Anubis anti-bot gate.
|
|
||||||
|
|
||||||
The AUR sits behind Anubis (https://anubis.techaro.lol/), which answers every
|
|
||||||
HTML request with a proof-of-work challenge page carrying HTTP **200**. A bare
|
|
||||||
`curl` therefore reports 200 for a page that is actually unreachable, which is
|
|
||||||
exactly how "is /register back up?" gets answered wrong.
|
|
||||||
|
|
||||||
This module solves the challenge and reports the status of the *real* page
|
|
||||||
behind it, so the caller can distinguish:
|
|
||||||
|
|
||||||
* 200 -> genuinely available
|
|
||||||
* 503 -> reached the site; that endpoint is disabled server-side
|
|
||||||
* other-> whatever the app actually said
|
|
||||||
|
|
||||||
Exit codes mirror the HTTP status so shell callers can branch on them:
|
|
||||||
0 for 200, 1 for anything else, 2 for a local/transport failure.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import hashlib
|
|
||||||
import http.cookiejar
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import urllib.error
|
|
||||||
import urllib.parse
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
BASE = "https://aur.archlinux.org"
|
|
||||||
UA = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"
|
|
||||||
CHALLENGE_RE = re.compile(
|
|
||||||
r'<script id="anubis_challenge" type="application/json">(.*?)</script>',
|
|
||||||
re.S,
|
|
||||||
)
|
|
||||||
PASS_PATH = "/.within.website/x/cmd/anubis/api/pass-challenge"
|
|
||||||
# A solve at difficulty 4 takes ~0.01s; this ceiling only guards a server-side
|
|
||||||
# difficulty spike, so the poller degrades instead of spinning forever.
|
|
||||||
MAX_NONCE = 50_000_000
|
|
||||||
|
|
||||||
|
|
||||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
||||||
"""Keep redirects visible instead of silently following them."""
|
|
||||||
|
|
||||||
def redirect_request(self, *_args, **_kwargs): # noqa: D102 - see class doc
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _opener() -> urllib.request.OpenerDirector:
|
|
||||||
jar = http.cookiejar.CookieJar()
|
|
||||||
opener = urllib.request.build_opener(
|
|
||||||
urllib.request.HTTPCookieProcessor(jar), _NoRedirect
|
|
||||||
)
|
|
||||||
opener.addheaders = [
|
|
||||||
("User-Agent", UA),
|
|
||||||
("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"),
|
|
||||||
]
|
|
||||||
return opener
|
|
||||||
|
|
||||||
|
|
||||||
def _solve(random_data: str, difficulty: int) -> tuple[int, str, float]:
|
|
||||||
"""Find a nonce whose sha256 digest starts with `difficulty` zero hex chars."""
|
|
||||||
prefix = "0" * difficulty
|
|
||||||
started = time.time()
|
|
||||||
for nonce in range(MAX_NONCE):
|
|
||||||
digest = hashlib.sha256(f"{random_data}{nonce}".encode()).hexdigest()
|
|
||||||
if digest.startswith(prefix):
|
|
||||||
return nonce, digest, time.time() - started
|
|
||||||
raise RuntimeError(f"no nonce found below {MAX_NONCE} at difficulty {difficulty}")
|
|
||||||
|
|
||||||
|
|
||||||
def fetch(path: str, timeout: int = 30) -> tuple[int, str]:
|
|
||||||
"""Return (status, body) for `path`, clearing Anubis first if challenged."""
|
|
||||||
opener = _opener()
|
|
||||||
url = f"{BASE}{path}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
with opener.open(url, timeout=timeout) as resp:
|
|
||||||
body = resp.read().decode("utf-8", "replace")
|
|
||||||
status = resp.status
|
|
||||||
except urllib.error.HTTPError as exc:
|
|
||||||
return exc.code, exc.read().decode("utf-8", "replace")
|
|
||||||
|
|
||||||
match = CHALLENGE_RE.search(body)
|
|
||||||
if not match:
|
|
||||||
return status, body
|
|
||||||
|
|
||||||
challenge = json.loads(match.group(1))["challenge"]
|
|
||||||
nonce, digest, elapsed = _solve(
|
|
||||||
challenge["randomData"], int(challenge["difficulty"])
|
|
||||||
)
|
|
||||||
query = urllib.parse.urlencode(
|
|
||||||
{
|
|
||||||
"id": challenge["id"],
|
|
||||||
"response": digest,
|
|
||||||
"nonce": str(nonce),
|
|
||||||
"redir": path,
|
|
||||||
"elapsedTime": str(int(elapsed * 1000)),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
opener.open(f"{BASE}{PASS_PATH}?{query}", timeout=timeout)
|
|
||||||
except urllib.error.HTTPError:
|
|
||||||
# A redirect or non-2xx here is fine; what matters is the cookie the
|
|
||||||
# opener's jar just captured.
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
with opener.open(url, timeout=timeout) as resp:
|
|
||||||
return resp.status, resp.read().decode("utf-8", "replace")
|
|
||||||
except urllib.error.HTTPError as exc:
|
|
||||||
return exc.code, exc.read().decode("utf-8", "replace")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
|
||||||
parser.add_argument("path", help="AUR path, e.g. /register")
|
|
||||||
parser.add_argument(
|
|
||||||
"--print-body", action="store_true", help="write the page body to stdout"
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
try:
|
|
||||||
status, body = fetch(args.path)
|
|
||||||
except (urllib.error.URLError, RuntimeError, TimeoutError) as exc:
|
|
||||||
print(f"transport error: {exc}", file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
|
|
||||||
print(status)
|
|
||||||
if args.print_body:
|
|
||||||
sys.stdout.write(body)
|
|
||||||
return 0 if status == 200 else 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@ -1,107 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Building, verifying and pushing one package to the AUR.
|
|
||||||
#
|
|
||||||
# Sourced by publish.sh, never executed: these functions read the readonly
|
|
||||||
# config (AUR_HOST, AUR_ROOT, DRY_RUN, PUBLISHED, FAILED) that the entry script declares, and
|
|
||||||
# a `readonly` re-run inside a twice-sourced lib would abort under `set -e`.
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
publish_one() {
|
|
||||||
local pkg="$1" repo="$2" wrapper="$3" binary="$4" expected="$5"
|
|
||||||
local dir="$AUR_ROOT/$pkg"
|
|
||||||
local version
|
|
||||||
|
|
||||||
log "$pkg"
|
|
||||||
|
|
||||||
[[ -d "$repo" ]] || { warn "$pkg: no repo at $repo"; return 1; }
|
|
||||||
[[ -f "$dir/PKGBUILD" ]] || { warn "$pkg: no PKGBUILD at $dir"; return 1; }
|
|
||||||
|
|
||||||
version="$(newest_tag "$repo")"
|
|
||||||
[[ -n "$version" ]] || { warn "$pkg: no vX.Y.Z tag on the remote"; return 1; }
|
|
||||||
info "newest release tag: v$version"
|
|
||||||
|
|
||||||
cd "$dir"
|
|
||||||
sed -i -E "s/^pkgver=.*/pkgver=$version/" PKGBUILD
|
|
||||||
# The tarball for a tag contains the PKGBUILD, so the checksum can only be
|
|
||||||
# computed after the tag exists — never committed ahead of time.
|
|
||||||
updpkgsums >/dev/null 2>&1 || { warn "$pkg: updpkgsums failed (tag missing?)"; return 1; }
|
|
||||||
|
|
||||||
info "building"
|
|
||||||
if ! env PATH=/usr/bin:/bin:/usr/local/bin makepkg -Cf --noconfirm >/dev/null 2>&1; then
|
|
||||||
warn "$pkg: makepkg failed"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
local built
|
|
||||||
built="$(find . -maxdepth 1 -name "$pkg-$version-*.pkg.tar.zst" | head -1)"
|
|
||||||
[[ -n "$built" ]] || { warn "$pkg: no package produced"; return 1; }
|
|
||||||
|
|
||||||
# namcap's "ELF files outside of a valid path ('opt/')" is a false positive
|
|
||||||
# for /opt-installed apps, so only genuinely new errors are worth failing on.
|
|
||||||
local findings
|
|
||||||
findings="$(namcap "$built" 2>&1 | grep -E ' E: ' | grep -v "outside of a valid path" || true)"
|
|
||||||
if [[ -n "$findings" ]]; then
|
|
||||||
warn "$pkg: namcap errors:"
|
|
||||||
printf ' %s\n' "$findings"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
info "installing and launching"
|
|
||||||
sudo pacman -U --noconfirm "$built" >/dev/null 2>&1 \
|
|
||||||
|| { warn "$pkg: pacman -U failed"; return 1; }
|
|
||||||
verify_installed "$binary" "$expected" "$wrapper" || { warn "$pkg: launch check failed"; return 1; }
|
|
||||||
|
|
||||||
# Generated before the rule check, which verifies it matches the PKGBUILD.
|
|
||||||
makepkg --printsrcinfo > .SRCINFO
|
|
||||||
check_aur_rules "$pkg" || return 1
|
|
||||||
|
|
||||||
# The AUR repo is a separate remote living alongside the build dir; adding
|
|
||||||
# it here (rather than cloning into a fresh path) keeps the verified
|
|
||||||
# PKGBUILD and the thing being pushed byte-identical.
|
|
||||||
if [[ ! -d .git ]]; then
|
|
||||||
git init -q -b master .
|
|
||||||
git remote add origin "ssh://aur@$AUR_HOST/$pkg.git"
|
|
||||||
fi
|
|
||||||
git remote get-url origin >/dev/null 2>&1 \
|
|
||||||
|| git remote add origin "ssh://aur@$AUR_HOST/$pkg.git"
|
|
||||||
|
|
||||||
# Fetch first: an existing AUR package must be built on, not overwritten.
|
|
||||||
if git fetch -q origin master 2>/dev/null; then
|
|
||||||
git reset -q --soft FETCH_HEAD 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
cat > .gitignore <<'EOF'
|
|
||||||
# Build products; the AUR tracks only the recipe.
|
|
||||||
*.pkg.tar.zst
|
|
||||||
*.tar.gz
|
|
||||||
src/
|
|
||||||
pkg/
|
|
||||||
EOF
|
|
||||||
|
|
||||||
git add PKGBUILD .SRCINFO .gitignore
|
|
||||||
if git diff --cached --quiet; then
|
|
||||||
info "already up to date on the AUR; nothing to push"
|
|
||||||
PUBLISHED+=("$pkg v$version (unchanged)")
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
git -c user.name='Krzysztof Rudnicki' \
|
|
||||||
-c user.email='krzysztofrudnicki0@gmail.com' \
|
|
||||||
commit -q -m "$pkg $version-1"
|
|
||||||
|
|
||||||
if ((DRY_RUN)); then
|
|
||||||
info "DRY RUN — not pushing"
|
|
||||||
PUBLISHED+=("$pkg v$version (dry run)")
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
info "pushing to the AUR"
|
|
||||||
git push -q origin master 2>&1 | tail -3 || { warn "$pkg: push failed"; return 1; }
|
|
||||||
PUBLISHED+=("$pkg v$version")
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,107 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Waiting for AUR registration and SSH access to come up.
|
|
||||||
#
|
|
||||||
# Sourced by publish.sh, never executed: these functions read the readonly
|
|
||||||
# config (AUR_HOST, SSH_KEY, GATE, POLL_SECONDS) that the entry script declares, and
|
|
||||||
# a `readonly` re-run inside a twice-sourced lib would abort under `set -e`.
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
register_status() {
|
|
||||||
python3 "$GATE" /register 2>/dev/null | head -1
|
|
||||||
}
|
|
||||||
|
|
||||||
open_browser() {
|
|
||||||
local url="$1"
|
|
||||||
local browser
|
|
||||||
for browser in xdg-open thorium-browser chromium google-chrome-stable firefox librewolf; do
|
|
||||||
if command -v "$browser" >/dev/null 2>&1; then
|
|
||||||
"$browser" "$url" >/dev/null 2>&1 &
|
|
||||||
info "Opened $url in $browser"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
warn "No browser found; open this yourself: $url"
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_key() {
|
|
||||||
if [[ ! -f "$SSH_KEY" ]]; then
|
|
||||||
log "Generating an AUR SSH key (none at $SSH_KEY)"
|
|
||||||
ssh-keygen -t ed25519 -f "$SSH_KEY" -N '' -C 'kuhy@aur'
|
|
||||||
fi
|
|
||||||
if ! grep -q "Host $AUR_HOST" "$HOME/.ssh/config" 2>/dev/null; then
|
|
||||||
log "Adding $AUR_HOST to ~/.ssh/config"
|
|
||||||
mkdir -p "$HOME/.ssh"
|
|
||||||
cat >> "$HOME/.ssh/config" <<EOF
|
|
||||||
|
|
||||||
# AUR uses a dedicated key so it stays separate from the GitHub identity.
|
|
||||||
Host $AUR_HOST
|
|
||||||
User aur
|
|
||||||
IdentityFile $SSH_KEY
|
|
||||||
IdentitiesOnly yes
|
|
||||||
EOF
|
|
||||||
chmod 600 "$HOME/.ssh/config"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
ssh_works() {
|
|
||||||
# The AUR refuses interactive shells; "Interactive shell is disabled" on a
|
|
||||||
# successful key auth is the success signal, not an error.
|
|
||||||
ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new \
|
|
||||||
-o ConnectTimeout=15 "aur@$AUR_HOST" help 2>&1 |
|
|
||||||
grep -qiE 'interactive shell is disabled|Welcome to AUR'
|
|
||||||
}
|
|
||||||
|
|
||||||
wait_for_registration() {
|
|
||||||
local status
|
|
||||||
status="$(register_status)"
|
|
||||||
if [[ "$status" == "200" ]]; then
|
|
||||||
log "AUR registration is OPEN"
|
|
||||||
else
|
|
||||||
log "AUR registration is closed (HTTP $status). Polling every ${POLL_SECONDS}s."
|
|
||||||
info "Ctrl-C to stop; re-run any time, nothing is lost."
|
|
||||||
while :; do
|
|
||||||
sleep "$POLL_SECONDS"
|
|
||||||
status="$(register_status)"
|
|
||||||
if [[ "$status" == "200" ]]; then
|
|
||||||
log "Registration is BACK (HTTP 200)"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
printf ' %s still HTTP %s\n' "$(date +%H:%M)" "$status"
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
open_browser "https://$AUR_HOST/register"
|
|
||||||
cat <<EOF
|
|
||||||
|
|
||||||
------------------------------------------------------------------
|
|
||||||
Sign up, and paste this into the "SSH Public Key" field:
|
|
||||||
|
|
||||||
$(sed 's/^/ /' "$SSH_KEY.pub")
|
|
||||||
|
|
||||||
You will need to confirm the address from your email before the
|
|
||||||
account works. This script waits for that automatically.
|
|
||||||
------------------------------------------------------------------
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
wait_for_ssh() {
|
|
||||||
log "Waiting for the SSH key to authenticate against the AUR"
|
|
||||||
local waited=0
|
|
||||||
until ssh_works; do
|
|
||||||
sleep 30
|
|
||||||
waited=$((waited + 30))
|
|
||||||
if ((waited % 300 == 0)); then
|
|
||||||
info "still waiting ($((waited / 60)) min) — key not accepted yet"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
log "SSH authentication OK"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Phase 2: build, verify and publish one package
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@ -1,122 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Gates a package must pass before it is allowed near the AUR.
|
|
||||||
#
|
|
||||||
# Sourced by publish.sh, never executed: these functions read the readonly
|
|
||||||
# config (AUR_ROOT, DRY_RUN) that the entry script declares, and
|
|
||||||
# a `readonly` re-run inside a twice-sourced lib would abort under `set -e`.
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
newest_tag() {
|
|
||||||
# Newest clean vX.Y.Z on the remote. Build tags (vX.Y.Z-build.N) and the
|
|
||||||
# grafted upstream Flutter SDK tags in diet-guard are excluded on purpose.
|
|
||||||
git -C "$1" ls-remote --tags origin 2>/dev/null |
|
|
||||||
awk '{print $2}' |
|
|
||||||
sed 's|refs/tags/||' |
|
|
||||||
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' |
|
|
||||||
sed 's/^v//' |
|
|
||||||
sort -V |
|
|
||||||
tail -1
|
|
||||||
}
|
|
||||||
|
|
||||||
verify_installed() {
|
|
||||||
local binary="$1" expected="$2" wrapper="$3" output
|
|
||||||
|
|
||||||
# An instance left over from a previous run would make the wrapper print
|
|
||||||
# "already running" and hand off, which passes trivially and turns the
|
|
||||||
# strongest gate in this script into the weakest. Clear it first so the
|
|
||||||
# check always exercises a real cold start.
|
|
||||||
#
|
|
||||||
# Resolved through /proc/<pid>/exe rather than any form of name or
|
|
||||||
# command-line matching. Every argv-based approach selects the wrong
|
|
||||||
# processes here:
|
|
||||||
# * `pkill -x` compares against the kernel's 15-character comm field, and
|
|
||||||
# two of these wrappers are longer (habit_stack_desktop is 19), so a
|
|
||||||
# name match would silently never fire.
|
|
||||||
# * `pkill -f`, and equally `ps -eo args` piped through a pattern, match
|
|
||||||
# any process whose command line merely *mentions* the path — this
|
|
||||||
# script, the shell running it, and the matching tool itself all
|
|
||||||
# qualify, so the run kills its own shell instead of the app. A
|
|
||||||
# $$/$PPID guard does not cover it, because the offending shell is
|
|
||||||
# often a more distant ancestor.
|
|
||||||
# /proc/<pid>/exe is a kernel-maintained symlink to the binary actually
|
|
||||||
# being executed, so it cannot collide with an incidental mention.
|
|
||||||
# The wrapper name is unique per app, so the /opt/<pkg>/ segment is left as
|
|
||||||
# a glob instead of threading the package name through another parameter.
|
|
||||||
#
|
|
||||||
# The link is read without `-f`: it is already fully resolved, and by the
|
|
||||||
# time this runs `pacman -U` has replaced the binary, so a leftover process
|
|
||||||
# points at an unlinked inode that the kernel reports with a " (deleted)"
|
|
||||||
# suffix. That suffix has to be stripped or the match silently misses the
|
|
||||||
# one process this exists to kill.
|
|
||||||
local pid exe killed=()
|
|
||||||
for pid in /proc/[0-9]*; do
|
|
||||||
pid="${pid#/proc/}"
|
|
||||||
exe="$(readlink "/proc/$pid/exe" 2>/dev/null)" || continue
|
|
||||||
exe="${exe% (deleted)}"
|
|
||||||
[[ "$exe" == /opt/*/bin/"$wrapper" ]] || continue
|
|
||||||
kill -TERM "$pid" 2>/dev/null && killed+=("$pid")
|
|
||||||
done
|
|
||||||
if ((${#killed[@]})); then
|
|
||||||
info "stopped a running instance so the launch check is meaningful"
|
|
||||||
# `kill` only reports that the signal was sent. Waiting for the process
|
|
||||||
# to actually go keeps a slow exit from failing the cold start below on
|
|
||||||
# a port that is still bound.
|
|
||||||
local waited=0
|
|
||||||
while ((waited < 100)) && kill -0 "${killed[@]}" 2>/dev/null; do
|
|
||||||
sleep 0.1
|
|
||||||
((waited++))
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
output="$(timeout 15 "/usr/bin/$binary" 2>&1 | head -3 || true)"
|
|
||||||
if grep -qF "$expected" <<<"$output"; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
warn "unexpected startup output: $output"
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# The AUR does not review uploads, but "packages that violate the rules may be
|
|
||||||
# deleted without warning" (AUR submission guidelines). These are the rules that
|
|
||||||
# are mechanically checkable, enforced here so a violation blocks the push
|
|
||||||
# rather than surfacing later as a deletion request.
|
|
||||||
check_aur_rules() {
|
|
||||||
local pkg="$1" problems=()
|
|
||||||
|
|
||||||
grep -q '^# Maintainer:' PKGBUILD \
|
|
||||||
|| problems+=("missing '# Maintainer:' comment on line 1")
|
|
||||||
|
|
||||||
grep -qE "^arch=\(.*x86_64" PKGBUILD \
|
|
||||||
|| problems+=("x86_64 not in arch=() — packages without it are not allowed")
|
|
||||||
|
|
||||||
# `replaces` makes pacman rip out the named package on the next -Sy, which
|
|
||||||
# is only correct for a genuine rename. `conflicts` is the right tool.
|
|
||||||
grep -q '^replaces=' PKGBUILD \
|
|
||||||
&& problems+=("uses replaces=; AUR rules reserve it for renames — use conflicts=")
|
|
||||||
|
|
||||||
# A package already in an official repo must never be duplicated.
|
|
||||||
if pacman -Si "$pkg" >/dev/null 2>&1; then
|
|
||||||
problems+=("$pkg exists in an official repo — must not be submitted")
|
|
||||||
fi
|
|
||||||
|
|
||||||
grep -qE '^(pkgdesc|url|license)=' PKGBUILD \
|
|
||||||
|| problems+=("missing pkgdesc/url/license")
|
|
||||||
|
|
||||||
# A stale .SRCINFO is the single most common upload rejection.
|
|
||||||
if ! diff -q <(makepkg --printsrcinfo) .SRCINFO >/dev/null 2>&1; then
|
|
||||||
problems+=(".SRCINFO does not match PKGBUILD")
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ((${#problems[@]})); then
|
|
||||||
warn "$pkg: AUR rule violations:"
|
|
||||||
printf ' - %s\n' "${problems[@]}"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
info "AUR rule checks passed"
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,148 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Publish kuhy's three Flutter apps to the AUR, hands-off.
|
|
||||||
#
|
|
||||||
# Run this once AUR registration is back. It:
|
|
||||||
# 1. Waits for https://aur.archlinux.org/register to stop returning 503,
|
|
||||||
# then opens it in a browser and prints the SSH key to paste.
|
|
||||||
# (Signup is the ONLY manual step: it needs an email confirmation, and
|
|
||||||
# creating accounts is not something this script will do for you.)
|
|
||||||
# 2. Waits for `ssh aur@aur.archlinux.org` to authenticate.
|
|
||||||
# 3. From there, fully unattended, for each package:
|
|
||||||
# newest release tag -> pkgver -> updpkgsums -> makepkg -C -> namcap
|
|
||||||
# -> pacman -U -> launch check -> .SRCINFO -> clone AUR repo
|
|
||||||
# -> commit -> push.
|
|
||||||
#
|
|
||||||
# Every package is BUILT, INSTALLED and LAUNCHED before it is pushed, so a
|
|
||||||
# broken package cannot reach the AUR. A package that fails any gate is
|
|
||||||
# skipped and reported; the others still publish.
|
|
||||||
#
|
|
||||||
# Idempotent and resumable: re-running skips work that is already done, and
|
|
||||||
# an already-published package is updated rather than duplicated.
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
readonly SCRIPT_DIR
|
|
||||||
readonly GATE="$SCRIPT_DIR/anubis_gate.py"
|
|
||||||
readonly AUR_HOST="aur.archlinux.org"
|
|
||||||
readonly SSH_KEY="$HOME/.ssh/aur"
|
|
||||||
readonly AUR_ROOT="$HOME/sdk/aur"
|
|
||||||
|
|
||||||
# package:repo:wrapper-process:cli-name:expected-startup-string
|
|
||||||
# The wrapper process name is NOT derivable from the package name
|
|
||||||
# (diet-guard-app runs diet_guard_desktop), so it is listed explicitly.
|
|
||||||
readonly PACKAGES=(
|
|
||||||
"todo-flutter:$HOME/src/todo:todo_desktop:todo:serving on http://localhost:8730"
|
|
||||||
"habit-stack:$HOME/src/habit_stack:habit_stack_desktop:habit-stack:serving on http://localhost:8731"
|
|
||||||
"diet-guard-app:$HOME/src/diet-guard:diet_guard_desktop:diet-guard-app:serving on http://localhost:8732"
|
|
||||||
)
|
|
||||||
|
|
||||||
POLL_SECONDS=300
|
|
||||||
SKIP_WAIT=0
|
|
||||||
DRY_RUN=0
|
|
||||||
ONLY=""
|
|
||||||
FAILED=()
|
|
||||||
PUBLISHED=()
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat <<EOF
|
|
||||||
Usage: $(basename "$0") [OPTIONS]
|
|
||||||
|
|
||||||
Options:
|
|
||||||
--skip-wait Assume the account + SSH key already work; go straight
|
|
||||||
to building and publishing.
|
|
||||||
--dry-run Do everything except the final 'git push' to the AUR.
|
|
||||||
--only NAME Publish just one package (todo-flutter, habit-stack,
|
|
||||||
diet-guard-app).
|
|
||||||
--poll SECONDS How often to re-check /register (default: $POLL_SECONDS).
|
|
||||||
-h, --help Show this help.
|
|
||||||
EOF
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
log() { printf '\n\033[1;34m==>\033[0m %s\n' "$*"; }
|
|
||||||
info() { printf ' %s\n' "$*"; }
|
|
||||||
warn() { printf '\033[1;33m ! %s\033[0m\n' "$*" >&2; }
|
|
||||||
die() { printf '\033[1;31m==> ERROR: %s\033[0m\n' "$*" >&2; exit 1; }
|
|
||||||
|
|
||||||
require_tools() {
|
|
||||||
local missing=()
|
|
||||||
local tool
|
|
||||||
for tool in git makepkg updpkgsums namcap ssh python3 curl; do
|
|
||||||
command -v "$tool" >/dev/null 2>&1 || missing+=("$tool")
|
|
||||||
done
|
|
||||||
if ((${#missing[@]})); then
|
|
||||||
info "Installing missing tools: ${missing[*]}"
|
|
||||||
# namcap/updpkgsums come from namcap + pacman-contrib; the rest are base.
|
|
||||||
sudo pacman -S --needed --noconfirm namcap pacman-contrib git openssh python \
|
|
||||||
|| die "could not install: ${missing[*]}"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Phase 1: wait for registration, then for the key to work
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# The rest lives in lib/, sourced (not executed) so every function sees the
|
|
||||||
# readonly config above and the mutable FAILED/PUBLISHED arrays main() reads.
|
|
||||||
# shellcheck source=lib/aur_registration.sh
|
|
||||||
source "$SCRIPT_DIR/lib/aur_registration.sh"
|
|
||||||
# shellcheck source=lib/aur_verify.sh
|
|
||||||
source "$SCRIPT_DIR/lib/aur_verify.sh"
|
|
||||||
# shellcheck source=lib/aur_publish_one.sh
|
|
||||||
source "$SCRIPT_DIR/lib/aur_publish_one.sh"
|
|
||||||
|
|
||||||
main() {
|
|
||||||
require_tools
|
|
||||||
[[ -f "$GATE" ]] || die "missing $GATE"
|
|
||||||
mkdir -p "$AUR_ROOT"
|
|
||||||
|
|
||||||
ensure_key
|
|
||||||
if ((SKIP_WAIT)); then
|
|
||||||
ssh_works || die "SSH key is not accepted yet; drop --skip-wait to wait for it"
|
|
||||||
else
|
|
||||||
wait_for_registration
|
|
||||||
wait_for_ssh
|
|
||||||
fi
|
|
||||||
|
|
||||||
local entry pkg repo wrapper binary expected
|
|
||||||
for entry in "${PACKAGES[@]}"; do
|
|
||||||
IFS=: read -r pkg repo wrapper binary expected <<<"$entry"
|
|
||||||
[[ -z "$ONLY" || "$ONLY" == "$pkg" ]] || continue
|
|
||||||
if ! publish_one "$pkg" "$repo" "$wrapper" "$binary" "$expected"; then
|
|
||||||
FAILED+=("$pkg")
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
log "Summary"
|
|
||||||
if ((${#PUBLISHED[@]})); then
|
|
||||||
printf ' published: %s\n' "${PUBLISHED[@]}"
|
|
||||||
fi
|
|
||||||
if ((${#FAILED[@]})); then
|
|
||||||
printf '\033[1;31m FAILED: %s\033[0m\n' "${FAILED[@]}"
|
|
||||||
info "Nothing broken was pushed; fix and re-run (safe to repeat)."
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if ((${#PUBLISHED[@]} == 0)); then
|
|
||||||
warn "nothing to do"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
printf '\n Check: https://aur.archlinux.org/packages/todo-flutter\n'
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case $1 in
|
|
||||||
--skip-wait) SKIP_WAIT=1; shift ;;
|
|
||||||
--dry-run) DRY_RUN=1; shift ;;
|
|
||||||
--only) ONLY="$2"; shift 2 ;;
|
|
||||||
--poll) POLL_SECONDS="$2"; shift 2 ;;
|
|
||||||
-h|--help) usage ;;
|
|
||||||
*) die "Unknown option: $1" ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
@ -1,143 +0,0 @@
|
|||||||
# Pre-commit Configuration for code_runner
|
|
||||||
# Install: pre-commit install && pre-commit install --hook-type pre-push
|
|
||||||
# Run: pre-commit run --all-files
|
|
||||||
# Update: pre-commit autoupdate
|
|
||||||
|
|
||||||
# NOTE: this subproject lives inside the ~/src/utils monorepo, whose git root is
|
|
||||||
# one level up. pre-commit always runs hooks from the git root, so every path
|
|
||||||
# below is written relative to THAT, not to this directory, and the top-level
|
|
||||||
# `files` key keeps these hooks off the sibling subprojects. CI invokes this
|
|
||||||
# config exactly the same way:
|
|
||||||
# pre-commit run --config code_runner/.pre-commit-config.yaml --all-files
|
|
||||||
files: ^code_runner/
|
|
||||||
|
|
||||||
default_language_version:
|
|
||||||
python: python3
|
|
||||||
default_stages: [pre-commit]
|
|
||||||
fail_fast: false
|
|
||||||
|
|
||||||
repos:
|
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
||||||
rev: v4.6.0
|
|
||||||
hooks:
|
|
||||||
- id: trailing-whitespace
|
|
||||||
args: [--markdown-linebreak-ext=md]
|
|
||||||
- id: end-of-file-fixer
|
|
||||||
- id: check-yaml
|
|
||||||
- id: check-json
|
|
||||||
- id: check-toml
|
|
||||||
- id: check-added-large-files
|
|
||||||
args: [--maxkb=2000]
|
|
||||||
- id: check-merge-conflict
|
|
||||||
- id: detect-private-key
|
|
||||||
- id: debug-statements
|
|
||||||
- id: name-tests-test
|
|
||||||
args: [--pytest-test-first]
|
|
||||||
- id: check-ast
|
|
||||||
- id: mixed-line-ending
|
|
||||||
args: [--fix=lf]
|
|
||||||
- id: requirements-txt-fixer
|
|
||||||
|
|
||||||
- repo: local
|
|
||||||
hooks:
|
|
||||||
- id: file-length-cap
|
|
||||||
name: file length <= 250 lines
|
|
||||||
entry: bash scripts/check_file_length.sh
|
|
||||||
language: system
|
|
||||||
pass_filenames: true
|
|
||||||
- id: subproject-scope
|
|
||||||
name: subproject scope regex still matches
|
|
||||||
entry: scripts/check_subproject_scope.sh code_runner
|
|
||||||
language: script
|
|
||||||
# always_run so the top-level `files` filter cannot skip the one hook
|
|
||||||
# whose job is to notice that filter has stopped matching anything.
|
|
||||||
always_run: true
|
|
||||||
pass_filenames: false
|
|
||||||
- id: no-noqa
|
|
||||||
name: Block noqa comments
|
|
||||||
entry: '(?i)#\s*(noqa|type:\s*ignore)'
|
|
||||||
language: pygrep
|
|
||||||
types: [python]
|
|
||||||
- id: no-ruff-noqa
|
|
||||||
name: Block ruff noqa file-level comments
|
|
||||||
entry: '(?i)#\s*ruff:\s*noqa'
|
|
||||||
language: pygrep
|
|
||||||
types: [python]
|
|
||||||
|
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
||||||
rev: v0.17.2
|
|
||||||
hooks:
|
|
||||||
- id: ruff
|
|
||||||
args: [--fix, --unsafe-fixes, --exit-non-zero-on-fix, --show-fixes]
|
|
||||||
types_or: [python, pyi]
|
|
||||||
- id: ruff-format
|
|
||||||
types_or: [python, pyi]
|
|
||||||
|
|
||||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
|
||||||
rev: v1.13.0
|
|
||||||
hooks:
|
|
||||||
- id: mypy
|
|
||||||
args:
|
|
||||||
- --ignore-missing-imports
|
|
||||||
- --no-error-summary
|
|
||||||
- --disable-error-code=no-untyped-def
|
|
||||||
- --disable-error-code=no-untyped-call
|
|
||||||
- --disable-error-code=var-annotated
|
|
||||||
- --disable-error-code=no-any-unimported
|
|
||||||
- --disable-error-code=type-arg
|
|
||||||
- --disable-error-code=no-any-return
|
|
||||||
- --disable-error-code=misc
|
|
||||||
- --disable-error-code=unused-ignore
|
|
||||||
- --disable-error-code=unreachable
|
|
||||||
- --disable-error-code=assignment
|
|
||||||
- --disable-error-code=no-redef
|
|
||||||
- --disable-error-code=attr-defined
|
|
||||||
- --disable-error-code=arg-type
|
|
||||||
- --disable-error-code=union-attr
|
|
||||||
- --disable-error-code=call-overload
|
|
||||||
- --disable-error-code=return-value
|
|
||||||
- --disable-error-code=redundant-cast
|
|
||||||
- --disable-error-code=empty-body
|
|
||||||
- --disable-error-code=list-item
|
|
||||||
|
|
||||||
- repo: https://github.com/pylint-dev/pylint
|
|
||||||
rev: v4.0.8
|
|
||||||
hooks:
|
|
||||||
- id: pylint
|
|
||||||
args:
|
|
||||||
- --rcfile=code_runner/pyproject.toml
|
|
||||||
- --fail-under=10
|
|
||||||
- --jobs=4
|
|
||||||
additional_dependencies:
|
|
||||||
- pytest
|
|
||||||
|
|
||||||
- repo: https://github.com/PyCQA/bandit
|
|
||||||
rev: 1.9.4
|
|
||||||
hooks:
|
|
||||||
- id: bandit
|
|
||||||
args:
|
|
||||||
- -c
|
|
||||||
- code_runner/pyproject.toml
|
|
||||||
- --severity-level=high
|
|
||||||
- --confidence-level=medium
|
|
||||||
- --skip=B113
|
|
||||||
additional_dependencies: ["bandit[toml]"]
|
|
||||||
exclude: ^(code_runner/code_runner/tests/|.*test.*\.py$)
|
|
||||||
|
|
||||||
- repo: local
|
|
||||||
hooks:
|
|
||||||
- id: pytest-coverage
|
|
||||||
name: pytest with coverage enforcement
|
|
||||||
entry: scripts/run_subproject_tests.sh code_runner
|
|
||||||
language: script
|
|
||||||
types: [python]
|
|
||||||
pass_filenames: false
|
|
||||||
require_serial: true
|
|
||||||
stages: [pre-push]
|
|
||||||
|
|
||||||
- repo: https://github.com/codespell-project/codespell
|
|
||||||
rev: v2.3.0
|
|
||||||
hooks:
|
|
||||||
- id: codespell
|
|
||||||
args:
|
|
||||||
- --skip=*.json,*.lock,.git,__pycache__,.venv
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
# code_runner
|
|
||||||
|
|
||||||
Runs a learner's solution (Python 3, JavaScript, TypeScript) against a problem statement's examples inside bubblewrap: no network, read-only `/usr`, scratch dir, CPU/memory/process/file/time limits.
|
|
||||||
|
|
||||||
```python
|
|
||||||
import code_runner as cr
|
|
||||||
|
|
||||||
statement = ... # markdown from ~/.claude/skills/leetcode/fetch_problem.py
|
|
||||||
results = cr.run("python3", code, cr.extract_starter(statement), cr.parse_examples(statement))
|
|
||||||
```
|
|
||||||
|
|
||||||
`run(lang, code, starter, examples) -> list[CaseResult]` with `n, input, expected, actual, ok, stdout, error, ms`. One sandbox process per example. TypeScript runs through node's type stripping (no type check, no enums). Needs `bwrap`, `prlimit`, `/usr/bin/python3`, node >= 22.18. Install: `./install.sh`.
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
"""Run a learner's solution against a problem's examples in a bubblewrap sandbox."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from code_runner._examples import parse_examples
|
|
||||||
from code_runner._model import CaseResult, Example, Limits, RunnerError
|
|
||||||
from code_runner._run import run
|
|
||||||
from code_runner._starter import Entry, extract_starter, parse_starter
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"CaseResult",
|
|
||||||
"Entry",
|
|
||||||
"Example",
|
|
||||||
"Limits",
|
|
||||||
"RunnerError",
|
|
||||||
"extract_starter",
|
|
||||||
"parse_examples",
|
|
||||||
"parse_starter",
|
|
||||||
"run",
|
|
||||||
]
|
|
||||||
@ -1,89 +0,0 @@
|
|||||||
"""Output comparison: exact JSON equality, float tolerance, order rules."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import math
|
|
||||||
|
|
||||||
TOLERANCE = 1e-5 # LeetCode accepts floating answers within 1e-5
|
|
||||||
MAX_DEPTH = 64 # real answers nest a handful of levels; deeper is hostile
|
|
||||||
|
|
||||||
|
|
||||||
class TooDeepError(ValueError):
|
|
||||||
"""A compared value nests deeper than ``MAX_DEPTH``."""
|
|
||||||
|
|
||||||
|
|
||||||
def too_deep(value: object, limit: int = MAX_DEPTH) -> bool:
|
|
||||||
"""Whether ``value`` nests lists/dicts deeper than ``limit`` (iterative)."""
|
|
||||||
stack: list[tuple[object, int]] = [(value, 0)]
|
|
||||||
while stack:
|
|
||||||
node, depth = stack.pop()
|
|
||||||
if isinstance(node, list | dict):
|
|
||||||
if depth >= limit:
|
|
||||||
return True
|
|
||||||
children = node if isinstance(node, list) else node.values()
|
|
||||||
stack.extend((child, depth + 1) for child in children)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _as_number(value: object) -> float | None:
|
|
||||||
if isinstance(value, bool) or not isinstance(value, int | float):
|
|
||||||
return None
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _key(value: object) -> str:
|
|
||||||
return json.dumps(value, sort_keys=True, ensure_ascii=False)
|
|
||||||
|
|
||||||
|
|
||||||
def _deep_sorted(value: object) -> object:
|
|
||||||
if isinstance(value, list):
|
|
||||||
return sorted((_deep_sorted(item) for item in value), key=_key)
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _equal(expected: object, actual: object) -> bool:
|
|
||||||
want, got = _as_number(expected), _as_number(actual)
|
|
||||||
if want is not None and got is not None:
|
|
||||||
return math.isclose(want, got, rel_tol=TOLERANCE, abs_tol=TOLERANCE)
|
|
||||||
if isinstance(expected, list) and isinstance(actual, list):
|
|
||||||
return len(expected) == len(actual) and all(
|
|
||||||
_equal(e, a) for e, a in zip(expected, actual, strict=True)
|
|
||||||
)
|
|
||||||
if isinstance(expected, dict) and isinstance(actual, dict):
|
|
||||||
return expected.keys() == actual.keys() and all(
|
|
||||||
_equal(expected[k], actual[k]) for k in expected
|
|
||||||
)
|
|
||||||
return type(expected) is type(actual) and expected == actual
|
|
||||||
|
|
||||||
|
|
||||||
def _top_sorted(value: object) -> object:
|
|
||||||
if isinstance(value, list):
|
|
||||||
return sorted(value, key=_key)
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def matches(expected: object, actual: object, order: str) -> bool:
|
|
||||||
"""Whether ``actual`` is an accepted answer for ``expected``.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
expected: the statement's answer (JSON value).
|
|
||||||
actual: what the solution returned (JSON value).
|
|
||||||
order: "exact", "any" (list order free) or "deep" (nested order free too).
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: ``order`` is none of those.
|
|
||||||
TooDeepError: a value nests deeper than ``MAX_DEPTH`` (the recursive
|
|
||||||
walks below would blow the stack).
|
|
||||||
"""
|
|
||||||
if too_deep(expected) or too_deep(actual):
|
|
||||||
msg = f"value nested deeper than {MAX_DEPTH} levels"
|
|
||||||
raise TooDeepError(msg)
|
|
||||||
if order == "exact":
|
|
||||||
return _equal(expected, actual)
|
|
||||||
if order == "any":
|
|
||||||
return _equal(_top_sorted(expected), _top_sorted(actual))
|
|
||||||
if order == "deep":
|
|
||||||
return _equal(_deep_sorted(expected), _deep_sorted(actual))
|
|
||||||
msg = f"unknown order {order!r}"
|
|
||||||
raise ValueError(msg)
|
|
||||||
@ -1,75 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import ast
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
from typing import Final
|
|
||||||
|
|
||||||
from code_runner._model import Example, Order
|
|
||||||
from code_runner._split import split_top_level
|
|
||||||
|
|
||||||
_logger: Final = logging.getLogger(__name__)
|
|
||||||
_LABEL: Final = re.compile(r"^(Input|Output)\s*:\s*(.*)$")
|
|
||||||
_PAIR: Final = re.compile(r"^([A-Za-z_]\w*)\s*=\s*(.+)$", re.DOTALL)
|
|
||||||
_ANY_ORDER: Final = re.compile(r"any order|order of the output", re.IGNORECASE)
|
|
||||||
_DEEP_ORDER: Final = re.compile(
|
|
||||||
r"order of the (?:triplets|quadruplets|subsets|groups|pairs|elements)",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _value(text: str) -> object:
|
|
||||||
try:
|
|
||||||
return json.loads(text)
|
|
||||||
except ValueError:
|
|
||||||
try:
|
|
||||||
return ast.literal_eval(text)
|
|
||||||
except (ValueError, SyntaxError) as exc:
|
|
||||||
raise ValueError(text[:40]) from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _arguments(text: str) -> dict[str, object]:
|
|
||||||
args: dict[str, object] = {}
|
|
||||||
for part in split_top_level(text):
|
|
||||||
pair = _PAIR.match(part)
|
|
||||||
if pair is None:
|
|
||||||
msg = f"not a name = value pair: {part[:40]!r}"
|
|
||||||
raise ValueError(msg)
|
|
||||||
args[pair.group(1)] = _value(pair.group(2))
|
|
||||||
return args
|
|
||||||
|
|
||||||
|
|
||||||
def _labels(body: str) -> list[tuple[str, str]]:
|
|
||||||
cleaned = (
|
|
||||||
line.replace("**", "").replace("`", "").strip() for line in body.splitlines()
|
|
||||||
)
|
|
||||||
matched = (_LABEL.match(line) for line in cleaned)
|
|
||||||
return [(m.group(1), m.group(2).strip()) for m in matched if m is not None]
|
|
||||||
|
|
||||||
|
|
||||||
def _order(body: str) -> Order:
|
|
||||||
if _DEEP_ORDER.search(body):
|
|
||||||
return "deep"
|
|
||||||
return "any" if _ANY_ORDER.search(body) else "exact"
|
|
||||||
|
|
||||||
|
|
||||||
def parse_examples(statement: str) -> list[Example]:
|
|
||||||
body = re.split(r"^## ", statement, maxsplit=1, flags=re.MULTILINE)[0]
|
|
||||||
order = _order(body)
|
|
||||||
found: list[Example] = []
|
|
||||||
pending: str | None = None
|
|
||||||
number = 0
|
|
||||||
for label, text in _labels(body):
|
|
||||||
if label == "Input":
|
|
||||||
pending = text
|
|
||||||
continue
|
|
||||||
if pending is None:
|
|
||||||
continue
|
|
||||||
number += 1
|
|
||||||
try:
|
|
||||||
found.append(Example(number, _arguments(pending), _value(text), order))
|
|
||||||
except ValueError as exc:
|
|
||||||
_logger.warning("example %d skipped, not runnable: %s", number, exc)
|
|
||||||
pending = None
|
|
||||||
return found
|
|
||||||
@ -1,119 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from importlib import resources
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import shutil
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from code_runner._model import RunnerError
|
|
||||||
from code_runner._sandbox import WORK
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from code_runner._starter import Entry
|
|
||||||
|
|
||||||
MEGABYTE = 1024 * 1024
|
|
||||||
_NODE_FLAGS = (
|
|
||||||
"--permission",
|
|
||||||
f"--allow-fs-read={WORK}",
|
|
||||||
f"--allow-fs-write={WORK}",
|
|
||||||
"--max-old-space-size=128",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Language:
|
|
||||||
name: str
|
|
||||||
solution_file: str
|
|
||||||
harness_file: str
|
|
||||||
program: str
|
|
||||||
binary_env: str
|
|
||||||
binary_default: str
|
|
||||||
flags: tuple[str, ...]
|
|
||||||
memory_bytes: int
|
|
||||||
|
|
||||||
def resolve(self) -> Path:
|
|
||||||
for candidate in (
|
|
||||||
os.environ.get(self.binary_env),
|
|
||||||
self.binary_default,
|
|
||||||
shutil.which(self.program),
|
|
||||||
):
|
|
||||||
if candidate and Path(candidate).is_file():
|
|
||||||
return Path(candidate).resolve()
|
|
||||||
msg = f"{self.program} not found (set {self.binary_env})"
|
|
||||||
raise RunnerError(msg)
|
|
||||||
|
|
||||||
def command(self, binary: Path) -> list[str]:
|
|
||||||
return [str(binary), *self.flags, f"{WORK}/{self.harness_file}", WORK]
|
|
||||||
|
|
||||||
def ro_binds(self, binary: Path) -> tuple[str, ...]:
|
|
||||||
if binary.is_relative_to("/usr"):
|
|
||||||
return ()
|
|
||||||
return (str(binary.parent.parent),)
|
|
||||||
|
|
||||||
def prepare(
|
|
||||||
self, scratch: Path, code: str, entry: Entry, args: list[object]
|
|
||||||
) -> None:
|
|
||||||
harness = resources.files("code_runner").joinpath("harness", self.harness_file)
|
|
||||||
(scratch / self.harness_file).write_text(harness.read_text(encoding="utf-8"))
|
|
||||||
text = code
|
|
||||||
if self.name == "typescript":
|
|
||||||
text += f"\nmodule.exports = {{ __entry: {entry.cls or entry.name} }};\n"
|
|
||||||
(scratch / self.solution_file).write_text(text, encoding="utf-8")
|
|
||||||
spec = {
|
|
||||||
"cls": entry.cls,
|
|
||||||
"fn": entry.name,
|
|
||||||
"args": args,
|
|
||||||
"typescript": self.name == "typescript",
|
|
||||||
}
|
|
||||||
(scratch / "input.json").write_text(json.dumps(spec), encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
PYTHON = Language(
|
|
||||||
"python3",
|
|
||||||
"solution.py",
|
|
||||||
"harness.py",
|
|
||||||
"python3",
|
|
||||||
"CODE_RUNNER_PYTHON",
|
|
||||||
"/usr/bin/python3",
|
|
||||||
("-I", "-B"),
|
|
||||||
512 * MEGABYTE,
|
|
||||||
)
|
|
||||||
JAVASCRIPT = Language(
|
|
||||||
"javascript",
|
|
||||||
"solution.js",
|
|
||||||
"harness.js",
|
|
||||||
"node",
|
|
||||||
"CODE_RUNNER_NODE",
|
|
||||||
"/usr/bin/node",
|
|
||||||
_NODE_FLAGS,
|
|
||||||
2048 * MEGABYTE,
|
|
||||||
)
|
|
||||||
TYPESCRIPT = Language(
|
|
||||||
"typescript",
|
|
||||||
"solution.cts",
|
|
||||||
"harness.js",
|
|
||||||
"node",
|
|
||||||
"CODE_RUNNER_NODE",
|
|
||||||
"/usr/bin/node",
|
|
||||||
_NODE_FLAGS,
|
|
||||||
2048 * MEGABYTE,
|
|
||||||
)
|
|
||||||
_BY_NAME = {
|
|
||||||
"python3": PYTHON,
|
|
||||||
"python": PYTHON,
|
|
||||||
"javascript": JAVASCRIPT,
|
|
||||||
"js": JAVASCRIPT,
|
|
||||||
"typescript": TYPESCRIPT,
|
|
||||||
"ts": TYPESCRIPT,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_language(name: str) -> Language:
|
|
||||||
try:
|
|
||||||
return _BY_NAME[name.lower()]
|
|
||||||
except KeyError:
|
|
||||||
msg = f"unsupported language {name!r}; use python3, javascript or typescript"
|
|
||||||
raise RunnerError(msg) from None
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
# "any": list order is free; "deep": the order inside its items is free too.
|
|
||||||
Order = Literal["exact", "any", "deep"]
|
|
||||||
MAX_CAPTURE = 64 * 1024
|
|
||||||
|
|
||||||
|
|
||||||
class RunnerError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Example:
|
|
||||||
n: int
|
|
||||||
args: dict[str, object]
|
|
||||||
expected: object
|
|
||||||
order: Order = "exact"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CaseResult:
|
|
||||||
n: int
|
|
||||||
input: dict[str, object]
|
|
||||||
expected: object
|
|
||||||
actual: object
|
|
||||||
ok: bool
|
|
||||||
stdout: str = ""
|
|
||||||
error: str | None = None
|
|
||||||
ms: float = 0.0
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Limits:
|
|
||||||
timeout_s: float = 2.0
|
|
||||||
nproc: int = 64
|
|
||||||
fsize_bytes: int = 1024 * 1024
|
|
||||||
nofile: int = 256
|
|
||||||
@ -1,134 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Callable, Sequence
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import stat
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
from code_runner._compare import TooDeepError, matches, too_deep
|
|
||||||
from code_runner._languages import Language, get_language
|
|
||||||
from code_runner._model import MAX_CAPTURE, CaseResult, Example, Limits
|
|
||||||
from code_runner._sandbox import BoxResult, execute
|
|
||||||
from code_runner._starter import Entry, parse_starter
|
|
||||||
|
|
||||||
Box = Callable[[list[str], Path, Limits, int, tuple[str, ...]], BoxResult]
|
|
||||||
_OUT_OF_MEMORY = ("out of memory", "allocation failed")
|
|
||||||
_MAX_ERROR = 200
|
|
||||||
# Everything hostile JSON can raise on the host: deep nesting, huge numbers.
|
|
||||||
_HOSTILE = (ValueError, TypeError, RecursionError, MemoryError, OverflowError)
|
|
||||||
|
|
||||||
|
|
||||||
def _positional(example: Example, entry: Entry) -> list[object]:
|
|
||||||
if set(example.args) == set(entry.params):
|
|
||||||
return [example.args[name] for name in entry.params]
|
|
||||||
if len(example.args) == len(entry.params):
|
|
||||||
return list(example.args.values())
|
|
||||||
given, wanted = len(example.args), len(entry.params)
|
|
||||||
msg = f"ArgumentError: example gives {given} arguments, {entry.name} takes {wanted}"
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
|
|
||||||
def _read_result(scratch: Path) -> dict[str, object] | None:
|
|
||||||
# The scratch dir is the solution's: never follow a link it planted there.
|
|
||||||
flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK
|
|
||||||
try:
|
|
||||||
fd = os.open(scratch / "result.json", flags)
|
|
||||||
except OSError:
|
|
||||||
return None
|
|
||||||
with os.fdopen(fd, "rb") as handle:
|
|
||||||
if not stat.S_ISREG(os.fstat(handle.fileno()).st_mode):
|
|
||||||
return None
|
|
||||||
raw = handle.read(MAX_CAPTURE * 16)
|
|
||||||
try:
|
|
||||||
payload = json.loads(raw)
|
|
||||||
except _HOSTILE: # ValueError, or RecursionError on "[" * 200000
|
|
||||||
return None
|
|
||||||
if not isinstance(payload, dict) or "ok" not in payload:
|
|
||||||
return None
|
|
||||||
return None if too_deep(payload) else payload
|
|
||||||
|
|
||||||
|
|
||||||
def _crash(box: BoxResult) -> str:
|
|
||||||
low = box.stderr.lower()
|
|
||||||
if any(marker in low for marker in _OUT_OF_MEMORY):
|
|
||||||
return "MemoryLimitExceeded"
|
|
||||||
return f"Crashed (exit {box.returncode})"
|
|
||||||
|
|
||||||
|
|
||||||
def _took(ms: object, fallback: float) -> float:
|
|
||||||
if isinstance(ms, bool) or not isinstance(ms, int | float):
|
|
||||||
return fallback
|
|
||||||
try:
|
|
||||||
return float(ms)
|
|
||||||
except OverflowError: # an int too big for a float
|
|
||||||
return fallback
|
|
||||||
|
|
||||||
|
|
||||||
def _outcome(scratch: Path, box: BoxResult) -> tuple[object, str | None, float]:
|
|
||||||
if box.timed_out:
|
|
||||||
return None, "TimeLimitExceeded", box.elapsed_ms
|
|
||||||
payload = _read_result(scratch)
|
|
||||||
if payload is None:
|
|
||||||
return None, _crash(box), box.elapsed_ms
|
|
||||||
took = _took(payload.get("ms"), box.elapsed_ms)
|
|
||||||
if payload["ok"] is True:
|
|
||||||
return payload.get("value"), None, took
|
|
||||||
return None, str(payload.get("error", "Error"))[:_MAX_ERROR], took
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class _Job:
|
|
||||||
language: Language
|
|
||||||
binary: Path
|
|
||||||
code: str
|
|
||||||
entry: Entry
|
|
||||||
limits: Limits
|
|
||||||
box: Box
|
|
||||||
|
|
||||||
|
|
||||||
def _case(job: _Job, example: Example) -> CaseResult:
|
|
||||||
try:
|
|
||||||
args = _positional(example, job.entry)
|
|
||||||
except ValueError as exc:
|
|
||||||
return CaseResult(
|
|
||||||
example.n, example.args, example.expected, None, ok=False, error=str(exc)
|
|
||||||
)
|
|
||||||
language = job.language
|
|
||||||
with tempfile.TemporaryDirectory(prefix="code-runner-") as tmp:
|
|
||||||
scratch = Path(tmp)
|
|
||||||
language.prepare(scratch, job.code, job.entry, args)
|
|
||||||
done = job.box(
|
|
||||||
language.command(job.binary),
|
|
||||||
scratch,
|
|
||||||
job.limits,
|
|
||||||
language.memory_bytes,
|
|
||||||
language.ro_binds(job.binary),
|
|
||||||
)
|
|
||||||
actual, error, ms = _outcome(scratch, done)
|
|
||||||
ok = False
|
|
||||||
if error is None:
|
|
||||||
try:
|
|
||||||
ok = matches(example.expected, actual, example.order)
|
|
||||||
except (TooDeepError, RecursionError, MemoryError) as exc:
|
|
||||||
actual, error = None, f"InvalidResult: {type(exc).__name__}"
|
|
||||||
return CaseResult(
|
|
||||||
example.n, example.args, example.expected, actual, ok, done.stdout, error, ms
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run(
|
|
||||||
lang: str,
|
|
||||||
code: str,
|
|
||||||
starter: str,
|
|
||||||
examples: Sequence[Example],
|
|
||||||
*,
|
|
||||||
limits: Limits | None = None,
|
|
||||||
box: Box = execute,
|
|
||||||
) -> list[CaseResult]:
|
|
||||||
language = get_language(lang)
|
|
||||||
entry = parse_starter(language.name, starter)
|
|
||||||
job = _Job(language, language.resolve(), code, entry, limits or Limits(), box)
|
|
||||||
return [_case(job, example) for example in examples]
|
|
||||||
@ -1,147 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import functools
|
|
||||||
import math
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import signal
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
import time
|
|
||||||
from typing import IO
|
|
||||||
|
|
||||||
from code_runner._model import MAX_CAPTURE, Limits, RunnerError
|
|
||||||
|
|
||||||
WORK = "/work"
|
|
||||||
PRLIMIT = "/usr/bin/prlimit"
|
|
||||||
_ROOT_LINKS = ("bin", "sbin", "lib", "lib64")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class BoxResult:
|
|
||||||
returncode: int
|
|
||||||
timed_out: bool
|
|
||||||
stdout: str
|
|
||||||
stderr: str
|
|
||||||
elapsed_ms: float
|
|
||||||
|
|
||||||
|
|
||||||
def _root_links() -> list[str]:
|
|
||||||
args: list[str] = []
|
|
||||||
for name in _ROOT_LINKS:
|
|
||||||
path = Path("/") / name
|
|
||||||
if path.is_symlink():
|
|
||||||
args += ["--symlink", str(path.readlink()), str(path)]
|
|
||||||
elif path.is_dir():
|
|
||||||
args += ["--ro-bind", str(path), str(path)]
|
|
||||||
return args
|
|
||||||
|
|
||||||
|
|
||||||
def _base_argv() -> list[str]:
|
|
||||||
return [
|
|
||||||
"bwrap",
|
|
||||||
"--unshare-all", # no --share-net: no network at all, loopback included
|
|
||||||
"--die-with-parent",
|
|
||||||
"--new-session",
|
|
||||||
"--clearenv",
|
|
||||||
"--ro-bind",
|
|
||||||
"/usr",
|
|
||||||
"/usr",
|
|
||||||
*_root_links(),
|
|
||||||
"--proc",
|
|
||||||
"/proc",
|
|
||||||
"--dev",
|
|
||||||
"/dev",
|
|
||||||
"--tmpfs",
|
|
||||||
"/tmp",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def bwrap_argv(
|
|
||||||
command: list[str],
|
|
||||||
scratch: Path,
|
|
||||||
limits: Limits,
|
|
||||||
memory_bytes: int,
|
|
||||||
ro_binds: tuple[str, ...] = (),
|
|
||||||
) -> list[str]:
|
|
||||||
argv = _base_argv()
|
|
||||||
for bind in ro_binds:
|
|
||||||
argv += ["--ro-bind", bind, bind]
|
|
||||||
argv += ["--bind", str(scratch), WORK, "--remount-ro", "/", "--chdir", WORK]
|
|
||||||
for name, value in (
|
|
||||||
("HOME", WORK),
|
|
||||||
("PATH", "/usr/bin"),
|
|
||||||
("LANG", "C.UTF-8"),
|
|
||||||
("PYTHONIOENCODING", "utf-8"),
|
|
||||||
):
|
|
||||||
argv += ["--setenv", name, value]
|
|
||||||
cpu = math.ceil(limits.timeout_s) + 1
|
|
||||||
return [
|
|
||||||
*argv,
|
|
||||||
"--",
|
|
||||||
PRLIMIT,
|
|
||||||
f"--as={memory_bytes}",
|
|
||||||
f"--nproc={limits.nproc}",
|
|
||||||
f"--fsize={limits.fsize_bytes}",
|
|
||||||
f"--nofile={limits.nofile}",
|
|
||||||
f"--cpu={cpu}",
|
|
||||||
"--core=0",
|
|
||||||
"--",
|
|
||||||
*command,
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@functools.cache
|
|
||||||
def check_sandbox() -> None:
|
|
||||||
if not Path(PRLIMIT).is_file():
|
|
||||||
msg = f"{PRLIMIT} is missing (util-linux)"
|
|
||||||
raise RunnerError(msg)
|
|
||||||
try:
|
|
||||||
probe = subprocess.run(
|
|
||||||
[*_base_argv(), "/usr/bin/true"],
|
|
||||||
capture_output=True,
|
|
||||||
check=False,
|
|
||||||
timeout=20,
|
|
||||||
)
|
|
||||||
except OSError as exc:
|
|
||||||
msg = f"bubblewrap (bwrap) cannot start: {exc}"
|
|
||||||
raise RunnerError(msg) from exc
|
|
||||||
if probe.returncode != 0:
|
|
||||||
detail = probe.stderr.decode(errors="replace").strip()
|
|
||||||
msg = f"bubblewrap cannot create the sandbox: {detail}"
|
|
||||||
raise RunnerError(msg)
|
|
||||||
|
|
||||||
|
|
||||||
def _read(handle: IO[bytes]) -> str:
|
|
||||||
handle.seek(0)
|
|
||||||
return handle.read(MAX_CAPTURE).decode("utf-8", errors="replace")
|
|
||||||
|
|
||||||
|
|
||||||
def execute(
|
|
||||||
command: list[str],
|
|
||||||
scratch: Path,
|
|
||||||
limits: Limits,
|
|
||||||
memory_bytes: int,
|
|
||||||
ro_binds: tuple[str, ...] = (),
|
|
||||||
) -> BoxResult:
|
|
||||||
check_sandbox()
|
|
||||||
argv = bwrap_argv(command, scratch, limits, memory_bytes, ro_binds)
|
|
||||||
with tempfile.TemporaryFile() as out, tempfile.TemporaryFile() as err:
|
|
||||||
started = time.monotonic()
|
|
||||||
with subprocess.Popen(
|
|
||||||
argv,
|
|
||||||
stdin=subprocess.DEVNULL,
|
|
||||||
stdout=out,
|
|
||||||
stderr=err,
|
|
||||||
env={"PATH": "/usr/bin:/bin"},
|
|
||||||
start_new_session=True,
|
|
||||||
) as proc:
|
|
||||||
timed_out = False
|
|
||||||
try:
|
|
||||||
proc.wait(timeout=limits.timeout_s)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
timed_out = True
|
|
||||||
os.killpg(proc.pid, signal.SIGKILL)
|
|
||||||
elapsed = (time.monotonic() - started) * 1000
|
|
||||||
return BoxResult(proc.returncode, timed_out, _read(out), _read(err), elapsed)
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
_OPEN = "([{<"
|
|
||||||
_CLOSE = ")]}>"
|
|
||||||
|
|
||||||
|
|
||||||
def split_top_level(text: str) -> list[str]:
|
|
||||||
parts: list[str] = []
|
|
||||||
depth = 0
|
|
||||||
quote = ""
|
|
||||||
escaped = False
|
|
||||||
start = 0
|
|
||||||
for i, ch in enumerate(text):
|
|
||||||
if escaped:
|
|
||||||
escaped = False
|
|
||||||
elif quote:
|
|
||||||
escaped = ch == "\\"
|
|
||||||
quote = "" if ch == quote else quote
|
|
||||||
elif ch in "\"'":
|
|
||||||
quote = ch
|
|
||||||
elif ch in _OPEN:
|
|
||||||
depth += 1
|
|
||||||
elif ch in _CLOSE and not (ch == ">" and text[i - 1] in "=-"):
|
|
||||||
depth = max(depth - 1, 0)
|
|
||||||
elif ch == "," and depth == 0:
|
|
||||||
parts.append(text[start:i])
|
|
||||||
start = i + 1
|
|
||||||
parts.append(text[start:])
|
|
||||||
return [part.strip() for part in parts]
|
|
||||||
@ -1,108 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import re
|
|
||||||
|
|
||||||
from code_runner._model import RunnerError
|
|
||||||
from code_runner._split import split_top_level
|
|
||||||
|
|
||||||
_IDENT = r"[A-Za-z_$][\w$]*"
|
|
||||||
_BLOCK_COMMENT = re.compile(r"/\*.*?\*/", re.DOTALL)
|
|
||||||
_LINE_COMMENT = re.compile(r"^\s*(?://|#).*$", re.MULTILINE)
|
|
||||||
_PY_CLASS = re.compile(r"^class\s+(\w+)", re.MULTILINE)
|
|
||||||
_PY_DEF = re.compile(r"\bdef\s+(?!__)(\w+)\s*\(")
|
|
||||||
_PY_FUNC = re.compile(r"^def\s+(\w+)\s*\(", re.MULTILINE)
|
|
||||||
_JS_CLASS = re.compile(rf"^\s*class\s+({_IDENT})", re.MULTILINE)
|
|
||||||
_JS_METHOD = re.compile(
|
|
||||||
rf"^\s+(?:async\s+)?(?!constructor\b)({_IDENT})\s*\(", re.MULTILINE
|
|
||||||
)
|
|
||||||
_JS_ASSIGNED = re.compile(
|
|
||||||
rf"\b(?:var|let|const)\s+({_IDENT})\s*(?::[^=]+)?=\s*(?:async\s+)?(?:function\b\s*\w*\s*)?\("
|
|
||||||
)
|
|
||||||
_JS_DECLARED = re.compile(rf"\bfunction\s+({_IDENT})\s*\(")
|
|
||||||
_NOT_METHODS = {"if", "for", "while", "switch", "catch", "return", "function"}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Entry:
|
|
||||||
cls: str | None
|
|
||||||
name: str
|
|
||||||
params: tuple[str, ...]
|
|
||||||
|
|
||||||
|
|
||||||
def _no_entry(lang: str) -> RunnerError:
|
|
||||||
return RunnerError(f"no entry point found in the {lang} starter code")
|
|
||||||
|
|
||||||
|
|
||||||
def _balanced(text: str, open_at: int) -> str:
|
|
||||||
depth = 0
|
|
||||||
for i in range(open_at, len(text)):
|
|
||||||
ch = text[i]
|
|
||||||
if ch in "([{" or (ch == "<" and depth > 0):
|
|
||||||
depth += 1
|
|
||||||
elif ch in ")]}" or (ch == ">" and text[i - 1] != "=" and depth > 1):
|
|
||||||
depth -= 1
|
|
||||||
if depth == 0:
|
|
||||||
return text[open_at + 1 : i]
|
|
||||||
return text[open_at + 1 :]
|
|
||||||
|
|
||||||
|
|
||||||
def _params(code: str, open_at: int) -> tuple[str, ...]:
|
|
||||||
names: list[str] = []
|
|
||||||
for part in split_top_level(_balanced(code, open_at)):
|
|
||||||
found = re.match(_IDENT, part.strip())
|
|
||||||
if found:
|
|
||||||
names.append(found.group())
|
|
||||||
return tuple(names)
|
|
||||||
|
|
||||||
|
|
||||||
def _python(text: str) -> Entry | None:
|
|
||||||
code = _LINE_COMMENT.sub("", text)
|
|
||||||
owner = _PY_CLASS.search(code)
|
|
||||||
if owner is None:
|
|
||||||
found = _PY_FUNC.search(code)
|
|
||||||
return found and Entry(None, found.group(1), _params(code, found.end() - 1))
|
|
||||||
found = _PY_DEF.search(code, owner.end())
|
|
||||||
if found is None:
|
|
||||||
return None
|
|
||||||
params = _params(code, found.end() - 1)
|
|
||||||
return Entry(
|
|
||||||
owner.group(1), found.group(1), tuple(p for p in params if p != "self")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _javascript(text: str) -> Entry | None:
|
|
||||||
code = _LINE_COMMENT.sub("", _BLOCK_COMMENT.sub("", text))
|
|
||||||
owner = _JS_CLASS.search(code)
|
|
||||||
if owner is not None:
|
|
||||||
for method in _JS_METHOD.finditer(code, owner.end()):
|
|
||||||
if method.group(1) not in _NOT_METHODS:
|
|
||||||
return Entry(
|
|
||||||
owner.group(1), method.group(1), _params(code, method.end() - 1)
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
found = _JS_ASSIGNED.search(code) or _JS_DECLARED.search(code)
|
|
||||||
return found and Entry(None, found.group(1), _params(code, found.end() - 1))
|
|
||||||
|
|
||||||
|
|
||||||
def parse_starter(lang: str, text: str) -> Entry:
|
|
||||||
entry = _python(text) if lang == "python3" else _javascript(text)
|
|
||||||
if entry is None:
|
|
||||||
raise _no_entry(lang)
|
|
||||||
return entry
|
|
||||||
|
|
||||||
|
|
||||||
def extract_starter(statement: str) -> str:
|
|
||||||
lines = statement.splitlines(keepends=True)
|
|
||||||
heads = [i for i, line in enumerate(lines) if line.startswith("## Starter code")]
|
|
||||||
if not heads:
|
|
||||||
return ""
|
|
||||||
body = lines[heads[0] + 1 :]
|
|
||||||
if body and body[0].startswith("```"):
|
|
||||||
body = body[1:]
|
|
||||||
end = next(
|
|
||||||
(i for i, line in enumerate(body) if line.startswith("```")), len(body)
|
|
||||||
)
|
|
||||||
return "".join(body[:end])
|
|
||||||
end = next((i for i, line in enumerate(body) if line.startswith("## ")), len(body))
|
|
||||||
return "".join(body[:end])
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
// Runs inside the sandbox: call the solution once, write result.json.
|
|
||||||
const fs = require("node:fs");
|
|
||||||
const path = require("node:path");
|
|
||||||
|
|
||||||
const root = process.argv[2];
|
|
||||||
const spec = JSON.parse(fs.readFileSync(path.join(root, "input.json"), "utf8"));
|
|
||||||
const IDENT = /^[A-Za-z_$][\w$]*$/;
|
|
||||||
|
|
||||||
function describe(e) {
|
|
||||||
const name = e && typeof e.name === "string" ? e.name : "Error";
|
|
||||||
const message = e && e.message !== undefined ? e.message : String(e);
|
|
||||||
return `${name}: ${message}`.slice(0, 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeResult(payload) {
|
|
||||||
// Unique name + rename: a symlink planted at result.json is replaced, never followed.
|
|
||||||
const unique = path.join(root, `.result-${process.pid}-${Math.random().toString(36).slice(2)}`);
|
|
||||||
fs.writeFileSync(unique, JSON.stringify(payload), { flag: "wx" });
|
|
||||||
fs.renameSync(unique, path.join(root, "result.json"));
|
|
||||||
}
|
|
||||||
|
|
||||||
function load(target) {
|
|
||||||
if (!IDENT.test(target)) throw new TypeError("bad entry name");
|
|
||||||
if (spec.typescript) return require(path.join(root, "solution.cts")).__entry;
|
|
||||||
const source = fs.readFileSync(path.join(root, "solution.js"), "utf8");
|
|
||||||
return new Function(`${source}\n;return ${target};`)();
|
|
||||||
}
|
|
||||||
|
|
||||||
function call() {
|
|
||||||
const entry = load(spec.cls || spec.fn);
|
|
||||||
if (spec.cls) return new entry()[spec.fn](...spec.args);
|
|
||||||
return entry(...spec.args);
|
|
||||||
}
|
|
||||||
|
|
||||||
const started = process.hrtime.bigint();
|
|
||||||
let payload;
|
|
||||||
try {
|
|
||||||
const value = call();
|
|
||||||
payload = { ok: true, value: value === undefined ? null : JSON.parse(JSON.stringify(value)) };
|
|
||||||
} catch (e) {
|
|
||||||
payload = { ok: false, error: describe(e) };
|
|
||||||
}
|
|
||||||
payload.ms = Number(process.hrtime.bigint() - started) / 1e6;
|
|
||||||
writeResult(payload);
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
"""Runs inside the sandbox: call the solution once, write result.json."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import time
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
PRELUDE = (
|
|
||||||
"from typing import *\n"
|
|
||||||
"import bisect, collections, functools, heapq, itertools, math\n"
|
|
||||||
"import operator, re, string\n"
|
|
||||||
)
|
|
||||||
MAX_MESSAGE = 300
|
|
||||||
|
|
||||||
|
|
||||||
def describe(exc: BaseException) -> str:
|
|
||||||
return f"{type(exc).__name__}: {exc}"[:MAX_MESSAGE]
|
|
||||||
|
|
||||||
|
|
||||||
def write_result(root: Path, payload: dict[str, object]) -> None:
|
|
||||||
# Unique name + rename: a symlink the solution planted at result.json is
|
|
||||||
# replaced by this file, never followed.
|
|
||||||
handle, name = tempfile.mkstemp(dir=root, prefix=".result-")
|
|
||||||
with os.fdopen(handle, "w", encoding="utf-8") as out:
|
|
||||||
json.dump(payload, out)
|
|
||||||
Path(name).replace(root / "result.json")
|
|
||||||
|
|
||||||
|
|
||||||
def call(root: Path, spec: dict[str, Any]) -> object:
|
|
||||||
namespace: dict[str, Any] = {"__name__": "solution"}
|
|
||||||
exec(PRELUDE, namespace)
|
|
||||||
source = (root / "solution.py").read_text(encoding="utf-8")
|
|
||||||
exec(compile(source, "solution.py", "exec"), namespace)
|
|
||||||
owner, name = spec["cls"], str(spec["fn"])
|
|
||||||
if owner is None:
|
|
||||||
target = namespace.get(name)
|
|
||||||
else:
|
|
||||||
cls = namespace.get(str(owner))
|
|
||||||
target = getattr(cls() if cls else None, name, None)
|
|
||||||
if target is None:
|
|
||||||
message = f"name {owner or name!r} is not defined"
|
|
||||||
raise NameError(message)
|
|
||||||
return target(*spec["args"])
|
|
||||||
|
|
||||||
|
|
||||||
def main(directory: str) -> None:
|
|
||||||
root = Path(directory)
|
|
||||||
spec = json.loads((root / "input.json").read_text(encoding="utf-8"))
|
|
||||||
started = time.perf_counter()
|
|
||||||
try:
|
|
||||||
value = call(root, spec)
|
|
||||||
payload: dict[str, object] = {
|
|
||||||
"ok": True,
|
|
||||||
"value": json.loads(json.dumps(value)),
|
|
||||||
}
|
|
||||||
except BaseException as exc:
|
|
||||||
payload = {"ok": False, "error": describe(exc)}
|
|
||||||
payload["ms"] = (time.perf_counter() - started) * 1000
|
|
||||||
sys.stdout.flush()
|
|
||||||
write_result(root, payload)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main(sys.argv[1])
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
"""Shared fixtures: real-sandbox tests are skipped where bubblewrap cannot run."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import shutil
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from code_runner._model import Example, RunnerError
|
|
||||||
from code_runner._sandbox import check_sandbox
|
|
||||||
|
|
||||||
|
|
||||||
def _bwrap_works() -> bool:
|
|
||||||
try:
|
|
||||||
check_sandbox()
|
|
||||||
except RunnerError:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
needs_sandbox = pytest.mark.skipif(not _bwrap_works(), reason="bwrap/userns unusable")
|
|
||||||
needs_node = pytest.mark.skipif(
|
|
||||||
not _bwrap_works() or not shutil.which("node"), reason="node or bwrap missing"
|
|
||||||
)
|
|
||||||
|
|
||||||
TWO_SUM_STARTER = {
|
|
||||||
"python3": "class Solution:\n def twoSum(self, nums: list[int], target: int) -> list[int]:\n",
|
|
||||||
"javascript": "var twoSum = function(nums, target) {\n\n};",
|
|
||||||
"typescript": "function twoSum(nums: number[], target: number): number[] {\n};",
|
|
||||||
}
|
|
||||||
TWO_SUM_CODE = {
|
|
||||||
"python3": (
|
|
||||||
"class Solution:\n"
|
|
||||||
" def twoSum(self, nums, target):\n"
|
|
||||||
" seen = {}\n"
|
|
||||||
" for i, n in enumerate(nums):\n"
|
|
||||||
" if target - n in seen:\n"
|
|
||||||
" return [i, seen[target - n]]\n"
|
|
||||||
" seen[n] = i\n"
|
|
||||||
),
|
|
||||||
"javascript": (
|
|
||||||
"var twoSum = function(nums, target) {\n"
|
|
||||||
" const seen = new Map();\n"
|
|
||||||
" for (let i = 0; i < nums.length; i++) {\n"
|
|
||||||
" if (seen.has(target - nums[i])) return [i, seen.get(target - nums[i])];\n"
|
|
||||||
" seen.set(nums[i], i);\n"
|
|
||||||
" }\n"
|
|
||||||
"};"
|
|
||||||
),
|
|
||||||
"typescript": (
|
|
||||||
"function twoSum(nums: number[], target: number): number[] {\n"
|
|
||||||
" const seen = new Map<number, number>();\n"
|
|
||||||
" for (let i = 0; i < nums.length; i++) {\n"
|
|
||||||
" const j = seen.get(target - nums[i]);\n"
|
|
||||||
" if (j !== undefined) return [i, j];\n"
|
|
||||||
" seen.set(nums[i], i);\n"
|
|
||||||
" }\n"
|
|
||||||
" return [];\n"
|
|
||||||
"}"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def two_sum_examples() -> list[Example]:
|
|
||||||
return [
|
|
||||||
Example(1, {"nums": [2, 7, 11, 15], "target": 9}, [0, 1], "any"),
|
|
||||||
Example(2, {"nums": [3, 2, 4], "target": 6}, [1, 2], "any"),
|
|
||||||
Example(3, {"nums": [3, 3], "target": 6}, [0, 1], "any"),
|
|
||||||
]
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from code_runner._compare import MAX_DEPTH, TooDeepError, matches, too_deep
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("expected", "actual", "order", "want"),
|
|
||||||
[
|
|
||||||
([0, 1], [0, 1], "exact", True),
|
|
||||||
([0, 1], [1, 0], "exact", False),
|
|
||||||
([0, 1], [1, 0], "any", True),
|
|
||||||
([0, 1], [1, 0, 2], "any", False),
|
|
||||||
([[1, 2], [3]], [[3], [1, 2]], "any", True),
|
|
||||||
([[1, 2], [3]], [[3], [2, 1]], "any", False),
|
|
||||||
([[1, 2], [3]], [[3], [2, 1]], "deep", True),
|
|
||||||
([[1, 2], [3]], [[3], [2, 9]], "deep", False),
|
|
||||||
(3, 3.0, "exact", True),
|
|
||||||
(0.1 + 0.2, 0.3, "exact", True),
|
|
||||||
(2.0, 2.001, "exact", False),
|
|
||||||
(True, 1, "exact", False),
|
|
||||||
(1, True, "exact", False),
|
|
||||||
(True, True, "exact", True),
|
|
||||||
(None, None, "exact", True),
|
|
||||||
(None, 0, "exact", False),
|
|
||||||
("ab", "ab", "exact", True),
|
|
||||||
("ab", "ba", "exact", False),
|
|
||||||
("1", 1, "exact", False),
|
|
||||||
({"a": [1]}, {"a": [1]}, "exact", True),
|
|
||||||
({"a": 1}, {"b": 1}, "exact", False),
|
|
||||||
({"a": 1}, {"a": 2}, "exact", False),
|
|
||||||
(5, 5, "any", True),
|
|
||||||
([1], 1, "any", False),
|
|
||||||
([1.0, 2.0], [2.0, 1.0000001], "any", True),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_matches(expected: object, actual: object, order: str, *, want: bool) -> None:
|
|
||||||
assert matches(expected, actual, order) is want
|
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_order_is_refused() -> None:
|
|
||||||
with pytest.raises(ValueError, match="order"):
|
|
||||||
matches(1, 1, "sideways")
|
|
||||||
|
|
||||||
|
|
||||||
def _nest(depth: int) -> object:
|
|
||||||
value: object = 1
|
|
||||||
for _ in range(depth):
|
|
||||||
value = [value]
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("order", ["exact", "any", "deep"])
|
|
||||||
def test_deep_nesting_is_refused_not_recursed(order: str) -> None:
|
|
||||||
deep = _nest(100_000) # would raise RecursionError in the recursive walks
|
|
||||||
with pytest.raises(TooDeepError):
|
|
||||||
matches(deep, deep, order)
|
|
||||||
with pytest.raises(TooDeepError):
|
|
||||||
matches(1, deep, order)
|
|
||||||
|
|
||||||
|
|
||||||
def test_nesting_up_to_the_limit_still_compares() -> None:
|
|
||||||
assert matches(_nest(MAX_DEPTH), _nest(MAX_DEPTH), "deep") is True
|
|
||||||
assert too_deep(_nest(MAX_DEPTH + 1)) is True
|
|
||||||
assert too_deep({"a": [{"b": _nest(MAX_DEPTH)}]}) is True
|
|
||||||
assert too_deep({"a": [{"b": 1}]}) is False
|
|
||||||
@ -1,106 +0,0 @@
|
|||||||
from code_runner._examples import parse_examples
|
|
||||||
from code_runner._model import Example
|
|
||||||
|
|
||||||
TWO_SUM = """# 1. Two Sum (Easy)
|
|
||||||
Tags: Array, Hash Table
|
|
||||||
|
|
||||||
Return indices.
|
|
||||||
|
|
||||||
You can return the answer in any order.
|
|
||||||
|
|
||||||
**Example 1:**
|
|
||||||
|
|
||||||
**Input:** `nums = [2,7,11,15], target = 9`
|
|
||||||
|
|
||||||
**Output:** `[0,1]`
|
|
||||||
|
|
||||||
**Explanation:** 2 + 7.
|
|
||||||
|
|
||||||
**Example 2:**
|
|
||||||
|
|
||||||
**Input:** `nums = [3,2,4], target = 6`
|
|
||||||
|
|
||||||
**Output:** `[1,2]`
|
|
||||||
|
|
||||||
## Starter code (python3)
|
|
||||||
```
|
|
||||||
class Solution:
|
|
||||||
def twoSum(self, nums, target):
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
|
|
||||||
FENCED = """# 5. Pal
|
|
||||||
|
|
||||||
Output order does not matter.
|
|
||||||
|
|
||||||
**Example 1:**
|
|
||||||
```
|
|
||||||
Input: s = "a,b", flag = true
|
|
||||||
Output: "b"
|
|
||||||
Explanation: Output: not an example
|
|
||||||
```
|
|
||||||
**Example 2:**
|
|
||||||
```
|
|
||||||
Input: s = 'x', flag = false
|
|
||||||
Output: None
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
|
|
||||||
THREE_SUM = """# 15. 3Sum
|
|
||||||
Notice that the order of the output and the order of the triplets does not matter.
|
|
||||||
**Example 1:**
|
|
||||||
**Input:** nums = [-1,0,1,2,-1,-4]
|
|
||||||
**Output:** [[-1,-1,2],[-1,0,1]]
|
|
||||||
"""
|
|
||||||
|
|
||||||
SKIPPED = """# 2. Add Two Numbers
|
|
||||||
**Example 1:**
|
|
||||||
**Input:** l1 = 1->2
|
|
||||||
**Output:** [1,2]
|
|
||||||
**Example 2:**
|
|
||||||
**Input:** a = 1, b = 2
|
|
||||||
**Output:** 3.00000
|
|
||||||
**Example 3:**
|
|
||||||
**Input:** a = 1
|
|
||||||
**Output:** 1->2
|
|
||||||
**Example 4:**
|
|
||||||
**Input:** a
|
|
||||||
**Output:** 1
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def test_two_sum_examples_are_typed_and_ordered_any() -> None:
|
|
||||||
found = parse_examples(TWO_SUM)
|
|
||||||
assert found == [
|
|
||||||
Example(1, {"nums": [2, 7, 11, 15], "target": 9}, [0, 1], "any"),
|
|
||||||
Example(2, {"nums": [3, 2, 4], "target": 6}, [1, 2], "any"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_fenced_examples_and_python_literals() -> None:
|
|
||||||
found = parse_examples(FENCED)
|
|
||||||
assert [e.args for e in found] == [
|
|
||||||
{"s": "a,b", "flag": True},
|
|
||||||
{"s": "x", "flag": False},
|
|
||||||
]
|
|
||||||
assert [e.expected for e in found] == ["b", None]
|
|
||||||
assert {e.order for e in found} == {"exact"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_triplets_sentence_means_deep_order() -> None:
|
|
||||||
assert parse_examples(THREE_SUM)[0].order == "deep"
|
|
||||||
|
|
||||||
|
|
||||||
def test_unparseable_examples_are_skipped_but_numbering_is_kept() -> None:
|
|
||||||
found = parse_examples(SKIPPED)
|
|
||||||
assert found == [Example(2, {"a": 1, "b": 2}, 3.0, "exact")]
|
|
||||||
|
|
||||||
|
|
||||||
def test_output_without_input_and_empty_statement() -> None:
|
|
||||||
assert parse_examples("**Output:** 1\n") == []
|
|
||||||
assert parse_examples("") == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_starter_section_is_never_scanned() -> None:
|
|
||||||
text = "# t\n## Starter code (python3)\n```\nInput: a = 1\nOutput: 2\n```\n"
|
|
||||||
assert parse_examples(text) == []
|
|
||||||
@ -1,235 +0,0 @@
|
|||||||
"""Branches that a healthy sandbox never reaches: crashes, bad results, no bwrap."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import dataclasses
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import subprocess
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from code_runner import Example, Limits, RunnerError, run
|
|
||||||
from code_runner._examples import parse_examples
|
|
||||||
from code_runner._languages import JAVASCRIPT, PYTHON, get_language
|
|
||||||
from code_runner._run import Box, _read_result
|
|
||||||
from code_runner._sandbox import BoxResult, bwrap_argv, check_sandbox
|
|
||||||
from code_runner._starter import parse_starter
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Iterator
|
|
||||||
|
|
||||||
STARTER = "def f(a):\n"
|
|
||||||
EXAMPLE = Example(1, {"a": 1}, 1)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _fresh_sandbox_check() -> Iterator[None]:
|
|
||||||
check_sandbox.cache_clear()
|
|
||||||
yield
|
|
||||||
check_sandbox.cache_clear()
|
|
||||||
|
|
||||||
|
|
||||||
def _fake(result: BoxResult, files: dict[str, str] | None = None) -> Box:
|
|
||||||
def box(
|
|
||||||
command: list[str],
|
|
||||||
scratch: Path,
|
|
||||||
limits: Limits,
|
|
||||||
memory: int,
|
|
||||||
binds: tuple[str, ...],
|
|
||||||
) -> BoxResult:
|
|
||||||
for name, text in (files or {}).items():
|
|
||||||
(scratch / name).write_text(text)
|
|
||||||
return result
|
|
||||||
|
|
||||||
return box
|
|
||||||
|
|
||||||
|
|
||||||
def _box(**kw: object) -> BoxResult:
|
|
||||||
fields = {
|
|
||||||
"returncode": 0,
|
|
||||||
"timed_out": False,
|
|
||||||
"stdout": "",
|
|
||||||
"stderr": "",
|
|
||||||
"elapsed_ms": 7.0,
|
|
||||||
}
|
|
||||||
return BoxResult(**(fields | kw))
|
|
||||||
|
|
||||||
|
|
||||||
def _run(box: Box) -> object:
|
|
||||||
return run("python3", "", STARTER, [EXAMPLE], box=box)[0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_crash_without_result_names_the_exit_code() -> None:
|
|
||||||
result = _run(_fake(_box(returncode=139)))
|
|
||||||
assert (result.ok, result.actual, result.error, result.ms) == (
|
|
||||||
False,
|
|
||||||
None,
|
|
||||||
"Crashed (exit 139)",
|
|
||||||
7.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_out_of_memory_crash_is_named() -> None:
|
|
||||||
stderr = "FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory"
|
|
||||||
assert (
|
|
||||||
_run(_fake(_box(returncode=134, stderr=stderr))).error == "MemoryLimitExceeded"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_timeout_wins_over_a_result_file() -> None:
|
|
||||||
files = {"result.json": '{"ok": true, "value": 1}'}
|
|
||||||
result = _run(_fake(_box(timed_out=True, elapsed_ms=1000.0), files))
|
|
||||||
assert (result.error, result.ms) == ("TimeLimitExceeded", 1000.0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_result_file_variants() -> None:
|
|
||||||
good = _run(
|
|
||||||
_fake(_box(stdout="hi"), {"result.json": '{"ok": true, "value": 1, "ms": 2.5}'})
|
|
||||||
)
|
|
||||||
assert (good.ok, good.ms, good.stdout) == (True, 2.5, "hi")
|
|
||||||
no_ms = _run(_fake(_box(), {"result.json": '{"ok": true, "value": 1}'}))
|
|
||||||
assert no_ms.ms == 7.0
|
|
||||||
failed = _run(_fake(_box(), {"result.json": '{"ok": false}'}))
|
|
||||||
assert failed.error == "Error"
|
|
||||||
named = _run(
|
|
||||||
_fake(_box(), {"result.json": '{"ok": false, "error": "KeyError: x"}'})
|
|
||||||
)
|
|
||||||
assert named.error == "KeyError: x"
|
|
||||||
|
|
||||||
|
|
||||||
def test_hostile_result_files_cost_one_example_not_the_run() -> None:
|
|
||||||
hostile = {
|
|
||||||
"bomb": "[" * 200_000,
|
|
||||||
"deep": '{"ok": true, "value": ' + "[" * 5000 + "]" * 5000 + "}",
|
|
||||||
"huge_ms": '{"ok": true, "value": 1, "ms": 1' + "0" * 400 + "}",
|
|
||||||
"bool_ms": '{"ok": true, "value": 1, "ms": true}',
|
|
||||||
"long_error": '{"ok": false, "error": "' + "x" * 5000 + '"}',
|
|
||||||
}
|
|
||||||
results = {
|
|
||||||
name: _run(_fake(_box(), {"result.json": text}))
|
|
||||||
for name, text in hostile.items()
|
|
||||||
}
|
|
||||||
assert results["bomb"].error == "Crashed (exit 0)"
|
|
||||||
assert results["deep"].error == "Crashed (exit 0)"
|
|
||||||
assert (results["huge_ms"].ok, results["huge_ms"].ms) == (True, 7.0)
|
|
||||||
assert results["bool_ms"].ms == 7.0
|
|
||||||
assert len(results["long_error"].error) == 200
|
|
||||||
|
|
||||||
|
|
||||||
def test_too_deep_expected_or_actual_is_that_examples_error() -> None:
|
|
||||||
deep: object = 1
|
|
||||||
for _ in range(100):
|
|
||||||
deep = [deep]
|
|
||||||
files = {"result.json": '{"ok": true, "value": 1}'}
|
|
||||||
cases = [Example(1, {"a": 1}, deep), EXAMPLE]
|
|
||||||
results = run("python3", "", STARTER, cases, box=_fake(_box(), files))
|
|
||||||
assert (results[0].ok, results[0].actual) == (False, None)
|
|
||||||
assert results[0].error == "InvalidResult: TooDeepError"
|
|
||||||
assert results[1].ok is True # the run went on
|
|
||||||
|
|
||||||
|
|
||||||
def test_recursion_in_compare_is_that_examples_error(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
def boom(*args: object) -> bool:
|
|
||||||
raise RecursionError
|
|
||||||
|
|
||||||
monkeypatch.setattr("code_runner._run.matches", boom)
|
|
||||||
files = {"result.json": '{"ok": true, "value": 1}'}
|
|
||||||
result = _run(_fake(_box(), files))
|
|
||||||
assert result.error == "InvalidResult: RecursionError"
|
|
||||||
|
|
||||||
|
|
||||||
def test_unreadable_result_files_are_a_crash(tmp_path: Path) -> None:
|
|
||||||
secret = tmp_path / "secret"
|
|
||||||
secret.write_text('{"ok": true, "value": "stolen"}')
|
|
||||||
(tmp_path / "result.json").symlink_to(secret)
|
|
||||||
assert _read_result(tmp_path) is None
|
|
||||||
(tmp_path / "result.json").unlink()
|
|
||||||
os.mkfifo(tmp_path / "result.json")
|
|
||||||
assert _read_result(tmp_path) is None
|
|
||||||
(tmp_path / "result.json").unlink()
|
|
||||||
for text in ("not json", "[1]", '{"no": "ok"}'):
|
|
||||||
(tmp_path / "result.json").write_text(text)
|
|
||||||
assert _read_result(tmp_path) is None
|
|
||||||
assert _read_result(tmp_path / "missing") is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_binary_resolution(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
||||||
ghost = dataclasses.replace(
|
|
||||||
PYTHON, binary_default="/nope", program="no-such-program-xyz"
|
|
||||||
)
|
|
||||||
monkeypatch.delenv(ghost.binary_env, raising=False)
|
|
||||||
with pytest.raises(RunnerError, match="no-such-program-xyz not found"):
|
|
||||||
ghost.resolve()
|
|
||||||
tool = tmp_path / "bin" / "python3"
|
|
||||||
tool.parent.mkdir()
|
|
||||||
tool.write_text("#!/bin/sh\n")
|
|
||||||
monkeypatch.setenv(ghost.binary_env, str(tool))
|
|
||||||
assert ghost.resolve() == tool
|
|
||||||
assert ghost.ro_binds(tool) == (str(tool.parent.parent),)
|
|
||||||
assert PYTHON.ro_binds(Path("/usr/bin/python3")) == ()
|
|
||||||
assert get_language("TS").name == "typescript"
|
|
||||||
|
|
||||||
|
|
||||||
def _has(argv: list[str], seq: list[str]) -> bool:
|
|
||||||
return any(argv[i : i + len(seq)] == seq for i in range(len(argv)))
|
|
||||||
|
|
||||||
|
|
||||||
def test_argv_binds_links_and_limits(
|
|
||||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.setattr("code_runner._sandbox._ROOT_LINKS", ("usr", "no-such-root-dir"))
|
|
||||||
argv = bwrap_argv(["prog"], tmp_path, Limits(timeout_s=1.2), 123, ("/opt/x",))
|
|
||||||
assert _has(argv, ["--ro-bind", "/usr", "/usr"])
|
|
||||||
assert _has(argv, ["--ro-bind", "/opt/x", "/opt/x"])
|
|
||||||
assert _has(argv, ["--bind", str(tmp_path), "/work"])
|
|
||||||
assert "--share-net" not in argv
|
|
||||||
assert {"--unshare-all", "--die-with-parent", "--clearenv", "--remount-ro"} <= set(
|
|
||||||
argv
|
|
||||||
)
|
|
||||||
assert "--as=123" in argv
|
|
||||||
assert "--cpu=3" in argv
|
|
||||||
assert argv[-1] == "prog"
|
|
||||||
|
|
||||||
|
|
||||||
def test_sandbox_preflight_failures(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.setattr("code_runner._sandbox.PRLIMIT", "/no/such/prlimit")
|
|
||||||
with pytest.raises(RunnerError, match="prlimit"):
|
|
||||||
check_sandbox()
|
|
||||||
monkeypatch.undo()
|
|
||||||
|
|
||||||
def missing(*args: object, **kwargs: object) -> None:
|
|
||||||
message = "bwrap"
|
|
||||||
raise FileNotFoundError(message)
|
|
||||||
|
|
||||||
monkeypatch.setattr(subprocess, "run", missing)
|
|
||||||
with pytest.raises(RunnerError, match="cannot start"):
|
|
||||||
check_sandbox()
|
|
||||||
|
|
||||||
def refused(*args: object, **kwargs: object) -> subprocess.CompletedProcess[bytes]:
|
|
||||||
return subprocess.CompletedProcess(args, 1, b"", b"bwrap: No permissions\n")
|
|
||||||
|
|
||||||
monkeypatch.setattr(subprocess, "run", refused)
|
|
||||||
with pytest.raises(RunnerError, match="No permissions"):
|
|
||||||
check_sandbox()
|
|
||||||
|
|
||||||
|
|
||||||
def test_statement_and_starter_edge_cases() -> None:
|
|
||||||
escaped = parse_examples('**Input:** s = "a\\"b,c", t = 1\n**Output:** "x"\n')
|
|
||||||
assert escaped[0].args == {"s": 'a"b,c', "t": 1}
|
|
||||||
assert parse_starter("python3", "def f(a, b").params == ("a", "b")
|
|
||||||
skipped = "class S {\n if (x) {\n solve(a) {\n }\n}\n"
|
|
||||||
assert parse_starter("javascript", skipped).name == "solve"
|
|
||||||
|
|
||||||
|
|
||||||
def test_typescript_class_entry_is_exported(tmp_path: Path) -> None:
|
|
||||||
entry = parse_starter(
|
|
||||||
"typescript", "class Solution {\n solve(a: number): number {\n }\n}\n"
|
|
||||||
)
|
|
||||||
get_language("typescript").prepare(tmp_path, "class Solution {}", entry, [1])
|
|
||||||
assert "__entry: Solution" in (tmp_path / "solution.cts").read_text()
|
|
||||||
JAVASCRIPT.prepare(tmp_path, "x", entry, [1])
|
|
||||||
assert (tmp_path / "solution.js").read_text() == "x"
|
|
||||||
@ -1,206 +0,0 @@
|
|||||||
"""Escape attempts: each one runs hostile code in the real sandbox and must fail."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
import socket
|
|
||||||
import subprocess
|
|
||||||
import time
|
|
||||||
from typing import Any, cast
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from code_runner import CaseResult, Example, Limits, run
|
|
||||||
from code_runner.tests.conftest import needs_node, needs_sandbox
|
|
||||||
|
|
||||||
pytestmark = needs_sandbox
|
|
||||||
STARTER = "def f(a):\n"
|
|
||||||
FAST = Limits(timeout_s=1.0)
|
|
||||||
|
|
||||||
|
|
||||||
def _one(code: str, lang: str = "python3", limits: Limits = FAST) -> CaseResult:
|
|
||||||
starter = {"python3": STARTER, "javascript": "var f = function(a) {};"}[lang]
|
|
||||||
return run(lang, code, starter, [Example(1, {"a": 0}, 1)], limits=limits)[0]
|
|
||||||
|
|
||||||
|
|
||||||
def _pgrep(pattern: str) -> int:
|
|
||||||
done = subprocess.run(
|
|
||||||
["/usr/bin/pgrep", "-fc", pattern], capture_output=True, text=True, check=False
|
|
||||||
)
|
|
||||||
return int(done.stdout.strip() or 0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_network_not_even_host_loopback() -> None:
|
|
||||||
with socket.socket() as listener:
|
|
||||||
listener.bind(("127.0.0.1", 0))
|
|
||||||
listener.listen()
|
|
||||||
listener.settimeout(0)
|
|
||||||
port = listener.getsockname()[1]
|
|
||||||
code = (
|
|
||||||
"import socket\n"
|
|
||||||
"def f(a):\n"
|
|
||||||
" out = []\n"
|
|
||||||
f" for host, port in [('127.0.0.1', {port}), ('1.1.1.1', 53), ('::1', {port})]:\n"
|
|
||||||
" try:\n"
|
|
||||||
" socket.create_connection((host, port), timeout=1).close()\n"
|
|
||||||
" out.append('connected')\n"
|
|
||||||
" except OSError:\n"
|
|
||||||
" out.append('blocked')\n"
|
|
||||||
" return out\n"
|
|
||||||
)
|
|
||||||
result = _one(code)
|
|
||||||
with pytest.raises(BlockingIOError):
|
|
||||||
listener.accept()
|
|
||||||
assert result.actual == ["blocked", "blocked", "blocked"]
|
|
||||||
|
|
||||||
|
|
||||||
@needs_node
|
|
||||||
def test_no_network_from_node() -> None:
|
|
||||||
with socket.socket() as listener:
|
|
||||||
listener.bind(("127.0.0.1", 0))
|
|
||||||
listener.listen()
|
|
||||||
port = listener.getsockname()[1]
|
|
||||||
code = (
|
|
||||||
"var f = function(a) {\n"
|
|
||||||
" const net = process.getBuiltinModule('node:net');\n"
|
|
||||||
" let seen = 'pending';\n"
|
|
||||||
f" const s = net.connect({port}, '127.0.0.1');\n"
|
|
||||||
" s.on('connect', () => { seen = 'connected'; });\n"
|
|
||||||
" s.on('error', () => {});\n"
|
|
||||||
" return seen;\n"
|
|
||||||
"};"
|
|
||||||
)
|
|
||||||
result = _one(code, "javascript")
|
|
||||||
listener.settimeout(0.2)
|
|
||||||
with pytest.raises(TimeoutError):
|
|
||||||
listener.accept()
|
|
||||||
assert result.actual == "pending"
|
|
||||||
|
|
||||||
|
|
||||||
def test_writes_outside_scratch_never_reach_the_host(tmp_path: Path) -> None:
|
|
||||||
marker = tmp_path / "marker"
|
|
||||||
code = (
|
|
||||||
"def f(a):\n"
|
|
||||||
" out = []\n"
|
|
||||||
f" for path in ['/usr/escape', '/etc/escape', '/escape', {str(marker)!r}]:\n"
|
|
||||||
" try:\n"
|
|
||||||
" open(path, 'w').write('x')\n"
|
|
||||||
" out.append('wrote')\n"
|
|
||||||
" except OSError:\n"
|
|
||||||
" out.append('denied')\n"
|
|
||||||
" return out\n"
|
|
||||||
)
|
|
||||||
result = _one(code)
|
|
||||||
assert result.actual == ["denied", "denied", "denied", "denied"]
|
|
||||||
assert not marker.exists()
|
|
||||||
assert not Path("/usr/escape").exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_host_files_and_environment_are_invisible(
|
|
||||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
secret = tmp_path / "secret.txt"
|
|
||||||
secret.write_text("hunter2")
|
|
||||||
monkeypatch.setenv("CODE_RUNNER_SECRET", "hunter2")
|
|
||||||
code = (
|
|
||||||
"import os\n"
|
|
||||||
"def f(a):\n"
|
|
||||||
f" return [os.path.exists({str(secret)!r}), os.path.exists({str(Path.home())!r}),\n"
|
|
||||||
" 'CODE_RUNNER_SECRET' in os.environ, sorted(os.environ), os.getpid() < 20,\n"
|
|
||||||
" os.path.exists('/proc/1/environ') and 'hunter2' in open('/proc/1/environ').read()]\n"
|
|
||||||
)
|
|
||||||
values = cast("list[Any]", _one(code).actual)
|
|
||||||
exists, home, env_leak, names, own_pidns, proc_leak = values
|
|
||||||
assert (exists, home, env_leak, own_pidns, proc_leak) == (
|
|
||||||
False,
|
|
||||||
False,
|
|
||||||
False,
|
|
||||||
True,
|
|
||||||
False,
|
|
||||||
)
|
|
||||||
assert set(names) <= {"HOME", "LANG", "PATH", "PWD", "PYTHONIOENCODING"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_symlink_in_scratch_cannot_exfiltrate_a_host_file(tmp_path: Path) -> None:
|
|
||||||
secret = tmp_path / "secret.txt"
|
|
||||||
secret.write_text("hunter2-secret-contents")
|
|
||||||
code = (
|
|
||||||
"import os\n"
|
|
||||||
"def f(a):\n"
|
|
||||||
f" os.symlink({str(secret)!r}, '/work/result.json')\n"
|
|
||||||
" return 7\n"
|
|
||||||
)
|
|
||||||
result = _one(code)
|
|
||||||
assert "hunter2" not in repr(result)
|
|
||||||
assert result.actual == 7 # the harness replaced the link, never followed it
|
|
||||||
|
|
||||||
|
|
||||||
def test_fork_bomb_is_contained_and_leaves_nothing_behind() -> None:
|
|
||||||
before = _pgrep("python3 -I /work/harness.py")
|
|
||||||
code = (
|
|
||||||
"import os, subprocess\n"
|
|
||||||
"def f(a):\n"
|
|
||||||
" subprocess.Popen(['sleep', '97'])\n"
|
|
||||||
" while True:\n"
|
|
||||||
" os.fork()\n"
|
|
||||||
)
|
|
||||||
started = time.monotonic()
|
|
||||||
result = _one(code)
|
|
||||||
assert time.monotonic() - started < 6
|
|
||||||
assert result.ok is False
|
|
||||||
assert result.error is not None
|
|
||||||
assert result.error.startswith(("TimeLimitExceeded", "BlockingIOError"))
|
|
||||||
time.sleep(0.3)
|
|
||||||
assert _pgrep("python3 -I /work/harness.py") == before
|
|
||||||
assert _pgrep("^sleep 97$") == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_memory_bomb_is_a_visible_error() -> None:
|
|
||||||
result = _one("def f(a):\n return len(bytearray(8 * 1024**3))\n")
|
|
||||||
assert result.ok is False
|
|
||||||
assert result.error is not None
|
|
||||||
assert result.error.startswith("MemoryError")
|
|
||||||
|
|
||||||
|
|
||||||
@needs_node
|
|
||||||
def test_node_heap_bomb_is_a_visible_error() -> None:
|
|
||||||
code = "var f = function(a) { const x = []; for (;;) x.push(new Array(1e6).fill(1)); };"
|
|
||||||
result = _one(code, "javascript", Limits(timeout_s=5.0))
|
|
||||||
assert result.ok is False
|
|
||||||
assert result.error in {"MemoryLimitExceeded", "RangeError: Invalid array length"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_output_flood_is_capped() -> None:
|
|
||||||
code = "def f(a):\n while True:\n print('x' * 1000)\n"
|
|
||||||
started = time.monotonic()
|
|
||||||
result = _one(code)
|
|
||||||
assert time.monotonic() - started < 6
|
|
||||||
assert result.ok is False
|
|
||||||
assert len(result.stdout) <= 64 * 1024
|
|
||||||
|
|
||||||
|
|
||||||
def test_disk_fill_is_capped_by_the_file_size_limit() -> None:
|
|
||||||
code = (
|
|
||||||
"def f(a):\n"
|
|
||||||
" with open('/work/big', 'wb') as fh:\n"
|
|
||||||
" while True:\n"
|
|
||||||
" fh.write(b'x' * 65536)\n"
|
|
||||||
)
|
|
||||||
result = _one(code)
|
|
||||||
assert result.ok is False
|
|
||||||
assert result.error is not None
|
|
||||||
assert "OSError" in result.error
|
|
||||||
|
|
||||||
|
|
||||||
def test_timeout_kills_every_process_the_solution_started() -> None:
|
|
||||||
code = (
|
|
||||||
"import subprocess\n"
|
|
||||||
"def f(a):\n"
|
|
||||||
" subprocess.Popen(['sleep', '97'])\n"
|
|
||||||
" while True:\n"
|
|
||||||
" pass\n"
|
|
||||||
)
|
|
||||||
result = _one(code)
|
|
||||||
assert result.error == "TimeLimitExceeded"
|
|
||||||
time.sleep(0.3)
|
|
||||||
assert _pgrep("^sleep 97$") == 0
|
|
||||||
@ -1,227 +0,0 @@
|
|||||||
"""End to end through the real sandbox: every language, every kind of outcome."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from code_runner import CaseResult, Example, Limits, RunnerError, run
|
|
||||||
from code_runner.tests.conftest import (
|
|
||||||
TWO_SUM_CODE,
|
|
||||||
TWO_SUM_STARTER,
|
|
||||||
needs_node,
|
|
||||||
needs_sandbox,
|
|
||||||
)
|
|
||||||
|
|
||||||
LANGS = [
|
|
||||||
pytest.param("python3", marks=needs_sandbox),
|
|
||||||
pytest.param("javascript", marks=needs_node),
|
|
||||||
pytest.param("typescript", marks=needs_node),
|
|
||||||
]
|
|
||||||
FAST = Limits(timeout_s=1.0)
|
|
||||||
|
|
||||||
|
|
||||||
def _two_sum(lang: str, code: str | None, examples: list[Example]) -> list[CaseResult]:
|
|
||||||
return run(lang, code or TWO_SUM_CODE[lang], TWO_SUM_STARTER[lang], examples)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("lang", LANGS)
|
|
||||||
def test_correct_solution_passes_every_example(
|
|
||||||
lang: str, two_sum_examples: list[Example]
|
|
||||||
) -> None:
|
|
||||||
results = _two_sum(lang, None, two_sum_examples)
|
|
||||||
assert [r.ok for r in results] == [True, True, True]
|
|
||||||
assert [r.n for r in results] == [1, 2, 3]
|
|
||||||
first = results[0]
|
|
||||||
assert first.input == {"nums": [2, 7, 11, 15], "target": 9}
|
|
||||||
assert first.expected == [0, 1]
|
|
||||||
assert sorted(first.actual) == [0, 1] # any order: [1, 0] is fine
|
|
||||||
assert first.error is None
|
|
||||||
assert first.ms >= 0
|
|
||||||
|
|
||||||
|
|
||||||
WRONG = {
|
|
||||||
"python3": "class Solution:\n def twoSum(self, nums, target):\n return [0, 0]\n",
|
|
||||||
"javascript": "var twoSum = function(nums, target) { return [0, 0]; };",
|
|
||||||
"typescript": "function twoSum(nums: number[], target: number): number[] { return [0, 0]; }",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("lang", LANGS)
|
|
||||||
def test_wrong_answer_is_reported_with_actual_value(
|
|
||||||
lang: str, two_sum_examples: list[Example]
|
|
||||||
) -> None:
|
|
||||||
results = _two_sum(lang, WRONG[lang], two_sum_examples)
|
|
||||||
assert [r.ok for r in results] == [False, False, False]
|
|
||||||
assert results[0].actual == [0, 0]
|
|
||||||
assert results[0].error is None
|
|
||||||
|
|
||||||
|
|
||||||
THROWS = {
|
|
||||||
"python3": "class Solution:\n def twoSum(self, nums, target):\n return nums[99]\n",
|
|
||||||
"javascript": "var twoSum = function(nums, target) { throw new RangeError('boom'); };",
|
|
||||||
"typescript": "function twoSum(nums: number[], target: number): number[] { throw new RangeError('boom'); }",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("lang", LANGS)
|
|
||||||
def test_exception_becomes_an_error_per_case(
|
|
||||||
lang: str, two_sum_examples: list[Example]
|
|
||||||
) -> None:
|
|
||||||
results = _two_sum(lang, THROWS[lang], two_sum_examples[:1])
|
|
||||||
only = results[0]
|
|
||||||
assert only.ok is False
|
|
||||||
assert only.actual is None
|
|
||||||
assert only.error is not None
|
|
||||||
assert only.error.split(":")[0] in {"IndexError", "RangeError"}
|
|
||||||
|
|
||||||
|
|
||||||
BROKEN = {
|
|
||||||
"python3": "class Solution:\n def twoSum(self, nums, target) return 1\n",
|
|
||||||
"javascript": "var twoSum = function(nums, target) { return ; ;; }}",
|
|
||||||
"typescript": "function twoSum(nums: number[], target: number): number[] { return [ }",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("lang", LANGS)
|
|
||||||
def test_syntax_error_is_reported(lang: str, two_sum_examples: list[Example]) -> None:
|
|
||||||
results = _two_sum(lang, BROKEN[lang], two_sum_examples[:2])
|
|
||||||
assert [r.ok for r in results] == [False, False]
|
|
||||||
assert all(r.error and r.error.startswith("SyntaxError") for r in results)
|
|
||||||
|
|
||||||
|
|
||||||
PRINTS = {
|
|
||||||
"python3": "class Solution:\n def twoSum(self, nums, target):\n print('hi', target)\n return [0, 1]\n",
|
|
||||||
"javascript": "var twoSum = function(nums, target) { console.log('hi', target); return [0, 1]; };",
|
|
||||||
"typescript": "function twoSum(nums: number[], target: number): number[] { console.log('hi', target); return [0, 1]; }",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("lang", LANGS)
|
|
||||||
def test_prints_land_in_stdout_per_case(
|
|
||||||
lang: str, two_sum_examples: list[Example]
|
|
||||||
) -> None:
|
|
||||||
results = _two_sum(lang, PRINTS[lang], two_sum_examples[:2])
|
|
||||||
assert results[0].stdout.strip() == "hi 9"
|
|
||||||
assert results[1].stdout.strip() == "hi 6"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("lang", LANGS)
|
|
||||||
def test_missing_entry_point_in_code_is_a_case_error(
|
|
||||||
lang: str, two_sum_examples: list[Example]
|
|
||||||
) -> None:
|
|
||||||
code = {
|
|
||||||
"python3": "x = 1\n",
|
|
||||||
"javascript": "var other = 1;",
|
|
||||||
"typescript": "const other = 1;",
|
|
||||||
}
|
|
||||||
results = _two_sum(lang, code[lang], two_sum_examples[:1])
|
|
||||||
assert results[0].ok is False
|
|
||||||
assert results[0].error is not None
|
|
||||||
assert "twoSum" in results[0].error or "Solution" in results[0].error
|
|
||||||
|
|
||||||
|
|
||||||
def test_class_style_javascript_and_function_style_python() -> None:
|
|
||||||
js = run(
|
|
||||||
"javascript",
|
|
||||||
"class Solution { solve(a, b) { return a + b; } }",
|
|
||||||
"class Solution {\n solve(a, b) {\n }\n}",
|
|
||||||
[Example(1, {"a": 1, "b": 2}, 3)],
|
|
||||||
)
|
|
||||||
py = run(
|
|
||||||
"python3",
|
|
||||||
"def add(a, b):\n return a + b\n",
|
|
||||||
"def add(a: int, b: int) -> int:\n",
|
|
||||||
[Example(1, {"a": 1, "b": 2}, 3)],
|
|
||||||
)
|
|
||||||
assert js[0].ok is True
|
|
||||||
assert py[0].ok is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_python_prelude_and_aliases() -> None:
|
|
||||||
code = "class Solution:\n def f(self, xs: List[int]) -> int:\n return collections.Counter(xs)[1]\n"
|
|
||||||
starter = "class Solution:\n def f(self, xs: List[int]) -> int:\n"
|
|
||||||
out = run("python", code, starter, [Example(1, {"xs": [1, 1, 2]}, 2)])
|
|
||||||
assert out[0].ok is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_positional_fallback_when_statement_names_differ() -> None:
|
|
||||||
out = run(
|
|
||||||
"python3",
|
|
||||||
"def add(a, b):\n return a - b\n",
|
|
||||||
"def add(a, b):\n",
|
|
||||||
[Example(1, {"x": 5, "y": 2}, 3)],
|
|
||||||
)
|
|
||||||
assert out[0].ok is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_argument_count_mismatch_is_a_case_error_without_running() -> None:
|
|
||||||
out = run(
|
|
||||||
"python3",
|
|
||||||
"def add(a, b):\n return a\n",
|
|
||||||
"def add(a, b):\n",
|
|
||||||
[Example(1, {"only": 5}, 3)],
|
|
||||||
)
|
|
||||||
assert out[0].ok is False
|
|
||||||
assert out[0].error is not None
|
|
||||||
assert out[0].error.startswith("ArgumentError")
|
|
||||||
|
|
||||||
|
|
||||||
def test_non_json_result_is_a_case_error() -> None:
|
|
||||||
out = run(
|
|
||||||
"python3",
|
|
||||||
"def f(a):\n return {1, 2}\n",
|
|
||||||
"def f(a):\n",
|
|
||||||
[Example(1, {"a": 1}, [1, 2])],
|
|
||||||
)
|
|
||||||
assert out[0].error is not None
|
|
||||||
assert out[0].error.startswith("TypeError")
|
|
||||||
|
|
||||||
|
|
||||||
def test_deep_order_and_float_tolerance() -> None:
|
|
||||||
out = run(
|
|
||||||
"python3",
|
|
||||||
"def f(a):\n return [[2, 1], [0, -1]]\n",
|
|
||||||
"def f(a):\n",
|
|
||||||
[Example(1, {"a": 0}, [[-1, 0], [1, 2]], "deep")],
|
|
||||||
)
|
|
||||||
assert out[0].ok is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_timeout_is_per_case() -> None:
|
|
||||||
out = run(
|
|
||||||
"python3",
|
|
||||||
"def f(a):\n while a:\n pass\n return 1\n",
|
|
||||||
"def f(a):\n",
|
|
||||||
[Example(1, {"a": 1}, 1), Example(2, {"a": 0}, 1)],
|
|
||||||
limits=FAST,
|
|
||||||
)
|
|
||||||
assert out[0].error == "TimeLimitExceeded"
|
|
||||||
assert out[0].ok is False
|
|
||||||
assert out[0].ms >= 900
|
|
||||||
assert out[1].ok is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_unsupported_language_and_empty_examples() -> None:
|
|
||||||
with pytest.raises(RunnerError, match="language"):
|
|
||||||
run("cobol", "", "", [])
|
|
||||||
assert run("python3", "def f(a): pass\n", "def f(a):\n", []) == []
|
|
||||||
|
|
||||||
|
|
||||||
@needs_sandbox
|
|
||||||
def test_json_bomb_result_costs_that_example_only() -> None:
|
|
||||||
code = (
|
|
||||||
"import os\n"
|
|
||||||
"def f(a):\n"
|
|
||||||
" if a:\n"
|
|
||||||
" open('/work/result.json', 'w').write('[' * 200000)\n"
|
|
||||||
" os._exit(0)\n"
|
|
||||||
" return 1\n"
|
|
||||||
)
|
|
||||||
out = run(
|
|
||||||
"python3",
|
|
||||||
code,
|
|
||||||
"def f(a):\n",
|
|
||||||
[Example(1, {"a": 1}, 1), Example(2, {"a": 0}, 1)],
|
|
||||||
)
|
|
||||||
assert (out[0].ok, out[0].error) == (False, "Crashed (exit 0)")
|
|
||||||
assert out[1].ok is True
|
|
||||||
@ -1,78 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from code_runner._model import RunnerError
|
|
||||||
from code_runner._starter import Entry, extract_starter, parse_starter
|
|
||||||
|
|
||||||
PY_CLASS = """class Solution:
|
|
||||||
def twoSum(self, nums: list[int], target: int) -> list[int]:
|
|
||||||
"""
|
|
||||||
PY_COMMENTED = """# Definition for singly-linked list.
|
|
||||||
# class ListNode:
|
|
||||||
# def __init__(self, val=0, next=None):
|
|
||||||
class Solution:
|
|
||||||
def solve(self, grid: Dict[str, Tuple[int, int]], k: int = 3, *rest) -> int:
|
|
||||||
pass
|
|
||||||
"""
|
|
||||||
PY_FUNC = "def add(a: int, b: int = 2) -> int:\n pass\n"
|
|
||||||
JS_VAR = """/**
|
|
||||||
* @param {number[]} nums
|
|
||||||
*/
|
|
||||||
var twoSum = function(nums, target) {
|
|
||||||
|
|
||||||
};"""
|
|
||||||
JS_FUNC = "function isPalindrome(x, y = 3) {\n}\n"
|
|
||||||
JS_ARROW = "const f = (a, b) => {\n}\n"
|
|
||||||
JS_CLASS = "class Solution {\n constructor() {}\n solve(nums, ...rest) {\n }\n}\n"
|
|
||||||
TS_FUNC = "function twoSum(nums: number[], target: number): number[] {\n};"
|
|
||||||
TS_ARROW = "const f = (a: Map<string, number>, b?: number): number => {\n}\n"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("lang", "text", "want"),
|
|
||||||
[
|
|
||||||
("python3", PY_CLASS, Entry("Solution", "twoSum", ("nums", "target"))),
|
|
||||||
("python3", PY_COMMENTED, Entry("Solution", "solve", ("grid", "k"))),
|
|
||||||
("python3", PY_FUNC, Entry(None, "add", ("a", "b"))),
|
|
||||||
("javascript", JS_VAR, Entry(None, "twoSum", ("nums", "target"))),
|
|
||||||
("javascript", JS_FUNC, Entry(None, "isPalindrome", ("x", "y"))),
|
|
||||||
("javascript", JS_ARROW, Entry(None, "f", ("a", "b"))),
|
|
||||||
("javascript", JS_CLASS, Entry("Solution", "solve", ("nums",))),
|
|
||||||
("typescript", TS_FUNC, Entry(None, "twoSum", ("nums", "target"))),
|
|
||||||
("typescript", TS_ARROW, Entry(None, "f", ("a", "b"))),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_parse_starter(lang: str, text: str, want: Entry) -> None:
|
|
||||||
assert parse_starter(lang, text) == want
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("lang", "text"),
|
|
||||||
[
|
|
||||||
("python3", "x = 1\n"),
|
|
||||||
("python3", "class Solution:\n pass\n"),
|
|
||||||
("javascript", "// nothing here\n"),
|
|
||||||
("javascript", "class Solution {\n constructor() {}\n}\n"),
|
|
||||||
("typescript", ""),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_starter_without_entry_is_refused(lang: str, text: str) -> None:
|
|
||||||
with pytest.raises(RunnerError, match="entry point"):
|
|
||||||
parse_starter(lang, text)
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_starter_takes_the_fenced_block() -> None:
|
|
||||||
statement = (
|
|
||||||
"# 1. Two Sum (Easy)\n\nbody\n\n## Starter code (python3)\n```\n"
|
|
||||||
+ PY_CLASS
|
|
||||||
+ "```\n\n## Site hints\nnope\n"
|
|
||||||
)
|
|
||||||
assert extract_starter(statement) == PY_CLASS
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_starter_without_section_is_empty() -> None:
|
|
||||||
assert extract_starter("# 1. Two Sum\n\nno starter\n") == ""
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_starter_unfenced_runs_to_next_section() -> None:
|
|
||||||
text = "intro\n## Starter code (javascript)\nvar f = function(a) {};\n## Hints\nx\n"
|
|
||||||
assert extract_starter(text) == "var f = function(a) {};\n"
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Editable user install so /usr/bin/python3 (the tutor server, the CLI) can
|
|
||||||
# `import code_runner`. Stdlib only; idempotent.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
readonly REPO_DIR
|
|
||||||
readonly SYSTEM_PYTHON="/usr/bin/python3"
|
|
||||||
|
|
||||||
"$SYSTEM_PYTHON" -m pip install --user --no-deps --break-system-packages -e "$REPO_DIR"
|
|
||||||
cd /
|
|
||||||
"$SYSTEM_PYTHON" -c 'import code_runner; print(code_runner.__file__)'
|
|
||||||
@ -1,198 +0,0 @@
|
|||||||
[build-system]
|
|
||||||
requires = ["setuptools>=61"]
|
|
||||||
build-backend = "setuptools.build_meta"
|
|
||||||
|
|
||||||
[project]
|
|
||||||
name = "code_runner"
|
|
||||||
version = "0.1.1"
|
|
||||||
description = "Run a learner solution (Python, JavaScript, TypeScript) against a problem's examples in a bubblewrap sandbox"
|
|
||||||
requires-python = ">=3.14"
|
|
||||||
# Stdlib only on purpose: the tutor server imports it from the system python.
|
|
||||||
dependencies = []
|
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
|
||||||
include = ["code_runner*"]
|
|
||||||
|
|
||||||
[tool.setuptools.package-data]
|
|
||||||
code_runner = ["py.typed", "harness/*"]
|
|
||||||
|
|
||||||
[tool.ruff]
|
|
||||||
target-version = "py314"
|
|
||||||
include = ["*.py", "**/*.py"]
|
|
||||||
exclude = [".git", ".venv", "__pycache__", "build", "dist", ".eggs"]
|
|
||||||
|
|
||||||
[tool.ruff.lint]
|
|
||||||
select = ["ALL"]
|
|
||||||
ignore = [
|
|
||||||
"CPY001",
|
|
||||||
"D100",
|
|
||||||
"D101",
|
|
||||||
"D102",
|
|
||||||
"D103",
|
|
||||||
"D104",
|
|
||||||
"D105",
|
|
||||||
"D107",
|
|
||||||
"D203",
|
|
||||||
"D213",
|
|
||||||
"COM812",
|
|
||||||
"ISC001",
|
|
||||||
"S603",
|
|
||||||
]
|
|
||||||
fixable = ["ALL"]
|
|
||||||
unfixable = []
|
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
|
||||||
"scripts/*.py" = ["INP001"]
|
|
||||||
# The sandbox-side harness is a program of its own: it reads its job from files.
|
|
||||||
# The sandbox mounts its own private tmpfs at /tmp.
|
|
||||||
"**/_sandbox.py" = ["S108"]
|
|
||||||
"**/harness/*.py" = ["INP001", "T201", "S102", "BLE001", "PTH123"]
|
|
||||||
# Tests embed the learner programs they run as source strings.
|
|
||||||
"**/tests/**/*.py" = ["ARG", "D", "E501", "PLC0415", "PLR2004", "S101", "SLF001"]
|
|
||||||
"**/test_*.py" = ["ARG", "D", "E501", "PLC0415", "PLR2004", "S101", "SLF001"]
|
|
||||||
|
|
||||||
[tool.ruff.lint.pylint]
|
|
||||||
max-args = 6
|
|
||||||
|
|
||||||
[tool.ruff.lint.pydocstyle]
|
|
||||||
convention = "google"
|
|
||||||
|
|
||||||
[tool.ruff.lint.isort]
|
|
||||||
force-single-line = false
|
|
||||||
force-sort-within-sections = true
|
|
||||||
known-first-party = ["code_runner"]
|
|
||||||
|
|
||||||
[tool.ruff.lint.flake8-quotes]
|
|
||||||
docstring-quotes = "double"
|
|
||||||
inline-quotes = "double"
|
|
||||||
|
|
||||||
[tool.ruff.lint.flake8-tidy-imports]
|
|
||||||
ban-relative-imports = "all"
|
|
||||||
|
|
||||||
[tool.ruff.format]
|
|
||||||
quote-style = "double"
|
|
||||||
indent-style = "space"
|
|
||||||
skip-magic-trailing-comma = false
|
|
||||||
line-ending = "auto"
|
|
||||||
docstring-code-format = true
|
|
||||||
|
|
||||||
[tool.mypy]
|
|
||||||
python_version = "3.14"
|
|
||||||
strict = true
|
|
||||||
warn_return_any = true
|
|
||||||
warn_unused_configs = true
|
|
||||||
disallow_untyped_defs = true
|
|
||||||
disallow_incomplete_defs = true
|
|
||||||
check_untyped_defs = true
|
|
||||||
disallow_untyped_decorators = true
|
|
||||||
no_implicit_optional = true
|
|
||||||
warn_redundant_casts = true
|
|
||||||
warn_unused_ignores = true
|
|
||||||
warn_no_return = true
|
|
||||||
warn_unreachable = true
|
|
||||||
disallow_any_unimported = true
|
|
||||||
disallow_any_explicit = false
|
|
||||||
disallow_any_generics = true
|
|
||||||
disallow_subclassing_any = true
|
|
||||||
strict_equality = true
|
|
||||||
extra_checks = true
|
|
||||||
ignore_missing_imports = true
|
|
||||||
show_error_codes = true
|
|
||||||
color_output = true
|
|
||||||
exclude = [".venv/"]
|
|
||||||
|
|
||||||
[tool.pylint.main]
|
|
||||||
# The repo root on the path, so pylint resolves the package's own imports the
|
|
||||||
# same way pytest does instead of reporting import-error for them.
|
|
||||||
init-hook = "import sys; sys.path.insert(0, 'code_runner')"
|
|
||||||
analyse-fallback-blocks = true
|
|
||||||
persistent = true
|
|
||||||
jobs = 0
|
|
||||||
py-version = "3.14"
|
|
||||||
ignore = [".venv", "__pycache__"]
|
|
||||||
# Test suites intentionally use patterns (protected-access, fixture-arg
|
|
||||||
# shadowing) that don't apply to source code; ruff's per-file-ignores carry
|
|
||||||
# the equivalent exemptions, so tests stay linted -- by ruff. This is the same
|
|
||||||
# source-only pylint scope wake-alarm / diet-guard / testsAndMisc use. It has
|
|
||||||
# to be `ignore-paths`, not `ignore = ["tests"]`: `ignore` only matches the
|
|
||||||
# basename of what pylint is *given*, and pre-commit hands it explicit file
|
|
||||||
# paths, so a directory name in `ignore` never fires under the hook.
|
|
||||||
# harness/ runs inside the sandbox and must exec the solution it is given.
|
|
||||||
ignore-paths = ["^(.*/)?tests/.*$", "^(.*/)?harness/.*$"]
|
|
||||||
ignore-patterns = [".*\\.pyi$"]
|
|
||||||
|
|
||||||
[tool.pylint.messages_control]
|
|
||||||
enable = "all"
|
|
||||||
disable = [
|
|
||||||
"missing-module-docstring",
|
|
||||||
"missing-class-docstring",
|
|
||||||
"missing-function-docstring",
|
|
||||||
"use-implicit-booleaness-not-comparison-to-zero",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.pylint.design]
|
|
||||||
max-args = 6
|
|
||||||
min-public-methods = 0
|
|
||||||
max-module-lines = 1000
|
|
||||||
max-attributes = 10
|
|
||||||
|
|
||||||
[tool.pylint.typecheck]
|
|
||||||
generated-members = [
|
|
||||||
".*\\.assert_called_once_with",
|
|
||||||
".*\\.assert_called_once",
|
|
||||||
".*\\.assert_called",
|
|
||||||
".*\\.assert_not_called",
|
|
||||||
".*\\.assert_any_call",
|
|
||||||
".*\\.call_args",
|
|
||||||
".*\\.call_args_list",
|
|
||||||
".*\\.call_count",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.bandit]
|
|
||||||
exclude_dirs = ["tests", ".venv"]
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
|
||||||
markers = ["sandbox: needs bubblewrap with unprivileged user namespaces (skipped otherwise)"]
|
|
||||||
testpaths = ["code_runner/tests"]
|
|
||||||
python_files = ["test_*.py", "*_test.py"]
|
|
||||||
python_classes = ["Test*"]
|
|
||||||
python_functions = ["test_*"]
|
|
||||||
addopts = [
|
|
||||||
"-v",
|
|
||||||
"--strict-markers",
|
|
||||||
"--strict-config",
|
|
||||||
"-ra",
|
|
||||||
"--cov=code_runner",
|
|
||||||
"--cov-branch",
|
|
||||||
"--cov-report=term-missing",
|
|
||||||
"--cov-report=lcov",
|
|
||||||
]
|
|
||||||
filterwarnings = [
|
|
||||||
"error",
|
|
||||||
"ignore::DeprecationWarning",
|
|
||||||
"default::pytest.PytestUnraisableExceptionWarning",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.coverage.run]
|
|
||||||
source = ["code_runner"]
|
|
||||||
branch = true
|
|
||||||
omit = [
|
|
||||||
# Runs inside the sandbox in another process; behaviour is tested through run().
|
|
||||||
"*/harness/*",
|
|
||||||
"*/__pycache__/*",
|
|
||||||
"*/tests/*",
|
|
||||||
"*/.venv/*",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.coverage.report]
|
|
||||||
fail_under = 100
|
|
||||||
show_missing = true
|
|
||||||
skip_covered = false
|
|
||||||
exclude_lines = [
|
|
||||||
"pragma: no cover",
|
|
||||||
"raise NotImplementedError",
|
|
||||||
"raise AssertionError",
|
|
||||||
"if TYPE_CHECKING:",
|
|
||||||
'if __name__ == "__main__":',
|
|
||||||
]
|
|
||||||
partial_branches = ["pragma: no branch"]
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
# Exact pins, enforced by the shared dependency-freshness gate: a `>=`
|
|
||||||
# floor is satisfied by anything newer, so it tells a checker nothing
|
|
||||||
# about whether this repo is actually current.
|
|
||||||
bandit[toml]==1.9.4
|
|
||||||
mypy==2.4.1
|
|
||||||
pre-commit==4.7.0
|
|
||||||
pylint==4.1.3
|
|
||||||
pytest==9.2.0
|
|
||||||
pytest-cov==7.1.0
|
|
||||||
ruff==0.17.3
|
|
||||||
@ -7,8 +7,11 @@ import 'store.dart';
|
|||||||
/// Writes atomically -- to a per-process temp file, then `rename`d over the
|
/// Writes atomically -- to a per-process temp file, then `rename`d over the
|
||||||
/// real path -- so a concurrent reader (e.g. a background sync isolate writing
|
/// real path -- so a concurrent reader (e.g. a background sync isolate writing
|
||||||
/// while the foreground app also writes) never observes a half-written file.
|
/// while the foreground app also writes) never observes a half-written file.
|
||||||
/// The pid in the temp name keeps two writers from colliding. This generalizes
|
/// The pid in the temp name keeps two processes from colliding; within one
|
||||||
/// diet_guard's hand-rolled `LogStorageService` write scheme.
|
/// instance, writes are serialized in issue order (last issued wins), so
|
||||||
|
/// overlapping calls (e.g. per-keystroke saves) never share the temp file
|
||||||
|
/// mid-flight. This generalizes diet_guard's hand-rolled `LogStorageService`
|
||||||
|
/// write scheme.
|
||||||
///
|
///
|
||||||
/// Lives behind the `package:crdt_sync/crdt_sync_io.dart` entrypoint, not the
|
/// Lives behind the `package:crdt_sync/crdt_sync_io.dart` entrypoint, not the
|
||||||
/// main barrel, because it imports `dart:io` and so is unavailable on web --
|
/// main barrel, because it imports `dart:io` and so is unavailable on web --
|
||||||
@ -20,6 +23,10 @@ class FileLogPersistence implements LogPersistence {
|
|||||||
|
|
||||||
final File _file;
|
final File _file;
|
||||||
|
|
||||||
|
/// Completes when the most recently issued write has settled. Never
|
||||||
|
/// rejects, so one failed write cannot block the ones queued after it.
|
||||||
|
Future<void> _tail = Future<void>.value();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<String?> read() async {
|
Future<String?> read() async {
|
||||||
if (!_file.existsSync()) return null;
|
if (!_file.existsSync()) return null;
|
||||||
@ -31,7 +38,14 @@ class FileLogPersistence implements LogPersistence {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> write(String text) async {
|
Future<void> write(String text) {
|
||||||
|
final result = _tail.then((_) => _writeNow(text));
|
||||||
|
// The caller still sees its own error via [result]; the chain ignores it.
|
||||||
|
_tail = result.then((_) {}, onError: (Object _) {});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _writeNow(String text) async {
|
||||||
await _file.parent.create(recursive: true);
|
await _file.parent.create(recursive: true);
|
||||||
final tmp = File('${_file.path}.$pid.tmp');
|
final tmp = File('${_file.path}.$pid.tmp');
|
||||||
await tmp.writeAsString(text);
|
await tmp.writeAsString(text);
|
||||||
|
|||||||
@ -2,7 +2,7 @@ name: crdt_sync
|
|||||||
description: >-
|
description: >-
|
||||||
Shared CRDT merge scheme + GitHub-Contents-API sync transport for
|
Shared CRDT merge scheme + GitHub-Contents-API sync transport for
|
||||||
cross-device apps. Pure Dart, no Flutter dependency.
|
cross-device apps. Pure Dart, no Flutter dependency.
|
||||||
version: 0.12.0
|
version: 0.12.1
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@ -128,5 +128,32 @@ void main() {
|
|||||||
await store.write('{"hello":"world"}');
|
await store.write('{"hello":"world"}');
|
||||||
expect(await store.read(), '{"hello":"world"}');
|
expect(await store.read(), '{"hello":"world"}');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('50 overlapping writes all succeed and the last issued wins',
|
||||||
|
() async {
|
||||||
|
// Regression: every call shared one `<file>.<pid>.tmp`, so the first
|
||||||
|
// rename moved it away and a later rename threw PathNotFoundException.
|
||||||
|
final store = FileLogPersistence(File('${dir.path}/log.json'));
|
||||||
|
await Future.wait([
|
||||||
|
for (var i = 0; i < 50; i++) store.write('{"n":$i}'),
|
||||||
|
]);
|
||||||
|
expect(await store.read(), '{"n":49}');
|
||||||
|
expect(dir.listSync().map((e) => e.uri.pathSegments.last),
|
||||||
|
['log.json']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed write rejects its caller but does not block later writes',
|
||||||
|
() async {
|
||||||
|
final target = File('${dir.path}/log.json');
|
||||||
|
// A directory at the target path makes the rename fail.
|
||||||
|
Directory(target.path).createSync();
|
||||||
|
final store = FileLogPersistence(target);
|
||||||
|
await expectLater(
|
||||||
|
store.write('{"n":0}'), throwsA(isA<FileSystemException>()));
|
||||||
|
|
||||||
|
Directory(target.path).deleteSync();
|
||||||
|
await store.write('{"n":1}');
|
||||||
|
expect(await store.read(), '{"n":1}');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ description: >-
|
|||||||
persisted per-install device id, and a one-call bootstrap. Removes the
|
persisted per-install device id, and a one-call bootstrap. Removes the
|
||||||
~150 lines of firebase_backend.dart every consuming app was copying.
|
~150 lines of firebase_backend.dart every consuming app was copying.
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 0.5.0
|
version: 0.5.1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.2
|
sdk: ^3.12.2
|
||||||
@ -13,7 +13,7 @@ dependencies:
|
|||||||
crdt_sync:
|
crdt_sync:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/kuhyx/utils
|
url: https://github.com/kuhyx/utils
|
||||||
ref: crdt_sync_dart-v0.12.0
|
ref: crdt_sync_dart-v0.12.1
|
||||||
path: crdt_sync_dart
|
path: crdt_sync_dart
|
||||||
crypto: 3.0.8
|
crypto: 3.0.8
|
||||||
flutter:
|
flutter:
|
||||||
|
|||||||
@ -4,13 +4,13 @@ description: >-
|
|||||||
Flutter apps: system Save-as on Android/iOS (never cloud-only), a save
|
Flutter apps: system Save-as on Android/iOS (never cloud-only), a save
|
||||||
dialog on desktop, a download on web, plus a versioned export envelope.
|
dialog on desktop, a download on web, plus a versioned export envelope.
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 0.2.1
|
version: 0.2.2
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.2
|
sdk: ^3.12.2
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
file_picker: 13.3.2
|
file_picker: 13.3.3
|
||||||
file_selector: 1.1.0
|
file_selector: 1.1.0
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|||||||
@ -91,6 +91,8 @@ NPM_ACCEPT: Final = "application/vnd.npm.install-v1+json"
|
|||||||
# --- Cache ------------------------------------------------------------------
|
# --- Cache ------------------------------------------------------------------
|
||||||
|
|
||||||
CACHE_PATH_ENV: Final = "DEP_FRESHNESS_CACHE"
|
CACHE_PATH_ENV: Final = "DEP_FRESHNESS_CACHE"
|
||||||
|
# Seconds; overrides every ecosystem TTL (a repo that wants a once-a-day gate).
|
||||||
|
TTL_ENV: Final = "DEP_FRESHNESS_TTL_SECONDS"
|
||||||
DEFAULT_CACHE_DIR: Final = "~/.cache/dep-freshness"
|
DEFAULT_CACHE_DIR: Final = "~/.cache/dep-freshness"
|
||||||
CACHE_FILE: Final = "registry.json"
|
CACHE_FILE: Final = "registry.json"
|
||||||
TTL_SECONDS: Final = 6 * 3600
|
TTL_SECONDS: Final = 6 * 3600
|
||||||
|
|||||||
@ -21,6 +21,7 @@ from dep_freshness._tables import (
|
|||||||
DEFAULT_CACHE_DIR,
|
DEFAULT_CACHE_DIR,
|
||||||
GITCOMMIT,
|
GITCOMMIT,
|
||||||
GITTAG,
|
GITTAG,
|
||||||
|
TTL_ENV,
|
||||||
TTL_GITTAG_SECONDS,
|
TTL_GITTAG_SECONDS,
|
||||||
TTL_SECONDS,
|
TTL_SECONDS,
|
||||||
)
|
)
|
||||||
@ -33,7 +34,14 @@ def cache_dir() -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def ttl_for(ecosystem: str) -> int:
|
def ttl_for(ecosystem: str) -> int:
|
||||||
"""Git refs move rarely and cost a subprocess, so they cache longer."""
|
"""Git refs move rarely and cost a subprocess, so they cache longer.
|
||||||
|
|
||||||
|
`DEP_FRESHNESS_TTL_SECONDS` overrides both; garbage is ignored, because a
|
||||||
|
typo in an env var must not break a commit hook.
|
||||||
|
"""
|
||||||
|
override = os.environ.get(TTL_ENV, "")
|
||||||
|
if override.isdigit() and int(override) > 0:
|
||||||
|
return int(override)
|
||||||
return TTL_GITTAG_SECONDS if ecosystem in (GITTAG, GITCOMMIT) else TTL_SECONDS
|
return TTL_GITTAG_SECONDS if ecosystem in (GITTAG, GITCOMMIT) else TTL_SECONDS
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from dep_freshness._tables import TTL_SECONDS
|
||||||
from dep_freshness.cache import Cache, Entry, ttl_for
|
from dep_freshness.cache import Cache, Entry, ttl_for
|
||||||
from dep_freshness.models import Dep
|
from dep_freshness.models import Dep
|
||||||
from dep_freshness.registries import http
|
from dep_freshness.registries import http
|
||||||
@ -139,3 +140,16 @@ def _offline(*_args):
|
|||||||
|
|
||||||
def _explode(*_args):
|
def _explode(*_args):
|
||||||
raise RuntimeError("boom")
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ttl_env_overrides_the_registry_ttl(monkeypatch) -> None:
|
||||||
|
"""A repo can stretch the refresh window (once-a-day pre-push gates)."""
|
||||||
|
monkeypatch.setenv("DEP_FRESHNESS_TTL_SECONDS", "86400")
|
||||||
|
assert ttl_for("npm") == 86400
|
||||||
|
assert ttl_for("gittag") == 86400
|
||||||
|
|
||||||
|
|
||||||
|
def test_ttl_env_garbage_falls_back_to_the_default(monkeypatch) -> None:
|
||||||
|
"""A malformed value must never break a commit hook."""
|
||||||
|
monkeypatch.setenv("DEP_FRESHNESS_TTL_SECONDS", "soon")
|
||||||
|
assert ttl_for("npm") == TTL_SECONDS
|
||||||
|
|||||||
@ -1,165 +0,0 @@
|
|||||||
# Dopamine UX — session prompts
|
|
||||||
|
|
||||||
Ten pasteable prompts derived from *"The Dopamine Architecture: How UX Designers
|
|
||||||
Hold User Attention in 2026"* (london-post.co.uk), applied to kuhy's repos.
|
|
||||||
|
|
||||||
Paste **one file's contents** into a fresh Claude session. Each is
|
|
||||||
self-contained: it restates the findings it depends on, so no session needs the
|
|
||||||
context of another.
|
|
||||||
|
|
||||||
## The two tests — every change must pass both
|
|
||||||
|
|
||||||
The article's four mechanics (variable rewards, micro-animations, sound design,
|
|
||||||
near-miss) are in scope, aimed at **kuhy's own stated goals**. They are gated by
|
|
||||||
two questions, not by an allow/deny list:
|
|
||||||
|
|
||||||
1. **Is the displayed state true?** A progress ring at 93% is legitimate *iff*
|
|
||||||
you are actually at 93%. A streak is legitimate iff it counts real days.
|
|
||||||
Dopamine may come from framing, timing and salience — **never from a
|
|
||||||
fabricated number**.
|
|
||||||
2. **Is the variance real?** A random pick from a real pool (next workout, next
|
|
||||||
task) is legitimate. Injected random bonus points to manufacture uncertainty
|
|
||||||
are not.
|
|
||||||
|
|
||||||
### The load-bearing corollary
|
|
||||||
|
|
||||||
**Reward the recording, never the number recorded.** In diet-guard the
|
|
||||||
satisfying moment fires on *logging a meal*, not on hitting a calorie target —
|
|
||||||
otherwise the app rewards eating less rather than recording honestly, and
|
|
||||||
corrupts its own data. Same shape in workout_app (reward logging a workout, not
|
|
||||||
beating a PB) and todo (reward capture, not completion count).
|
|
||||||
|
|
||||||
## Running order
|
|
||||||
|
|
||||||
`01` gates everything. `07` gates `08`. Everything else is independent.
|
|
||||||
|
|
||||||
| # | File | Repo | Findings |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 01 | `01-motion-tokens.md` | `~/src/utils` | 3 |
|
|
||||||
| 02 | `02-structural-check.md` | `~/src/utils` | 2 |
|
|
||||||
| 03 | todo note `85ca329b-9809-412c-a47f-eddf055322cf` | `~/src/diet-guard` | 4 |
|
|
||||||
| 04 | todo note `73a20837-6480-4b31-b966-1898a4e7272e` | `~/src/screen-locker` | 3 |
|
|
||||||
| 05 | todo note `e50d6a03-ae7d-4da5-bbd3-c33b17f6f074` | `~/src/screen-locker/stronglift_replacement/workout_app` | 4 |
|
|
||||||
| 06 | `06-todo.md` | `~/src/todo` | 3 |
|
|
||||||
| 07 | `07-dufs-theme.md` | `~/src/dufs-cloud` | 2 |
|
|
||||||
| 08 | todo note `e301e090-b2ee-4e26-8387-00f356b63521` | `~/src/dufs-cloud` | 4 |
|
|
||||||
| 09 | todo note `9e1e4922-9f29-4bad-bcf4-96826aa8ffa4` | `~/src/wake-alarm` | 2 |
|
|
||||||
|
|
||||||
**27 ranked findings total.**
|
|
||||||
|
|
||||||
### Dependency graph
|
|
||||||
|
|
||||||
```
|
|
||||||
01-motion-tokens ──┬─→ 03, 04, 05, 09 (transcribe tokens locally)
|
|
||||||
├─→ 06 (bump design_system-v0.2.0)
|
|
||||||
└─→ 07 ─→ 08 (bump web_ui-v0.3.2)
|
|
||||||
02-structural-check (independent; run after 01 so it has motion tokens to check)
|
|
||||||
```
|
|
||||||
|
|
||||||
## The top three findings fleet-wide
|
|
||||||
|
|
||||||
Ranked by value-over-effort from the survey data:
|
|
||||||
|
|
||||||
1. **screen-locker streak render** (prompt 04) — `snapshot.streak` is already
|
|
||||||
populated and no renderer reads it. Zero new state, zero conftest change,
|
|
||||||
zero new dependency.
|
|
||||||
2. **diet-guard `_onLogMeal` feedback** (prompt 03) — the flagship case.
|
|
||||||
Dependency-free via `HapticFeedback` from `flutter/services.dart`.
|
|
||||||
3. **workout_app finish-workout cue** (prompt 05) — `audioplayers`, `vibration`,
|
|
||||||
the `assets:` block and the VIBRATE permission are **already present**. Only
|
|
||||||
the call is missing.
|
|
||||||
|
|
||||||
## Distributed copies, and the completion protocol
|
|
||||||
|
|
||||||
Each prompt is **also copied into the repo it acts on**, at
|
|
||||||
`<repo>/prompts/dopamine-ux-*.md`, so you can open a repo and say
|
|
||||||
"do dopamine-ux-<name>" without going hunting. **This directory stays the source
|
|
||||||
of truth** — if a prompt needs correcting, fix it here and re-distribute.
|
|
||||||
|
|
||||||
| Prompt | Distributed to |
|
|
||||||
|---|---|
|
|
||||||
| 01 | `~/src/utils/prompts/dopamine-ux-01-motion-tokens.md` — deleted (done) |
|
|
||||||
| 02 | `~/src/utils/prompts/dopamine-ux-02-structural-check.md` |
|
|
||||||
| 03 | `~/src/diet-guard/prompts/dopamine-ux-diet-guard.md` |
|
|
||||||
| 04 | `~/src/screen-locker/prompts/dopamine-ux-04-screen-locker.md` — deleted (done) |
|
|
||||||
| 05 | `~/src/screen-locker/prompts/dopamine-ux-05-workout-app.md` — deleted (done) |
|
|
||||||
| 06 | `~/src/todo/prompts/dopamine-ux-todo.md` |
|
|
||||||
| 07 | `~/src/dufs-cloud/prompts/dopamine-ux-07-theme.md` |
|
|
||||||
| 08 | `~/src/dufs-cloud/prompts/dopamine-ux-08-motion.md` |
|
|
||||||
| 09 | `~/src/wake-alarm/prompts/dopamine-ux-wake-alarm.md` |
|
|
||||||
|
|
||||||
When a session finishes a prompt it **deletes its repo copy** (in the same
|
|
||||||
commit as the implementation) and **appends a row below**. A finished prompt left
|
|
||||||
in a repo is indistinguishable from a pending one, and the next session re-runs
|
|
||||||
it. Partial completion is never recorded as DONE — the file is edited to state
|
|
||||||
what remains instead.
|
|
||||||
|
|
||||||
### Completion log
|
|
||||||
|
|
||||||
| Prompt | Status | Impl commit | Note |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 01-motion-tokens.md | DONE 2026-08-22 | `f741896` | `motion.md` frozen; `AppDuration`/`AppCurve` + `--duration-*`/`--ease-*` shipped. Tags: `design_system-v0.2.0`, `web_ui-v0.3.2`. |
|
|
||||||
| 02-structural-check.md | not started | — | — |
|
|
||||||
| todo note `85ca329b-9809-412c-a47f-eddf055322cf` | not started | — | — |
|
|
||||||
| todo note `73a20837-6480-4b31-b966-1898a4e7272e` | DONE 2026-11-01 | `89c65bd` | Status window `_section_streak` + week-transition reward text (kept in `extra_benefits_state.json`); the dead 09:00 early-bird claim is not shown. Per kuhy: no daily-streak state, no shutdown time, no freezes. Commit is on a worktree branch, unpushed. |
|
|
||||||
| todo note `e50d6a03-ae7d-4da5-bbd3-c33b17f6f074` | DONE 2026-11-01 | `e938201` | Set/warmup `selectionClick`, finish `mediumImpact` + generated chime (music_theory, pinned tag) + dialog arrival, `AppDuration`/`AppCurve` in theme.dart, streak counts up, reduced motion honoured, one Settings "Sound" switch for chime and break-end (foreground service too). Worktree commits, unpushed; `62c1c2c` covers old export/import gaps. |
|
|
||||||
| 06-todo.md | PARTIAL 2026-11-02 (step 1 DONE; step 2 sound REJECTED by kuhy) | `kuhyx/todo-app 2ffae26` | haptic + checkmark on capture, appRoute motion tokens, animated wizard bar; no sound by decision |
|
|
||||||
| 07-dufs-theme.md | not started | — | — |
|
|
||||||
| todo note `e301e090-b2ee-4e26-8387-00f356b63521` | not started | — | — |
|
|
||||||
| todo note `9e1e4922-9f29-4bad-bcf4-96826aa8ffa4` | not started | — | — |
|
|
||||||
|
|
||||||
## Keeping these prompts honest
|
|
||||||
|
|
||||||
Every path and symbol cited across these files is machine-checked:
|
|
||||||
|
|
||||||
```
|
|
||||||
python3 ~/src/utils/dopamine-ux/verify_prompts.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Exit 0 means every cited path resolves and every cited symbol is still greppable
|
|
||||||
in the file it is attributed to. **Re-run it after any refactor of the surveyed
|
|
||||||
repos** — a prompt pointing at a renamed function is worse than no prompt, and a
|
|
||||||
path check alone cannot catch that (which is why it greps symbols too).
|
|
||||||
|
|
||||||
Verified 2026-08-16: 146 paths, 30 symbols, all passing.
|
|
||||||
|
|
||||||
## Rules that apply to every prompt
|
|
||||||
|
|
||||||
1. **Tag cuts and version bumps are explicit steps.** `todo` consumes
|
|
||||||
`design_system` at git ref `design_system-v0.1.0`; `dufs-cloud/web` consumes
|
|
||||||
`@kuhyx/web-ui` at `web_ui-v0.3.1`. Adding tokens to those packages does
|
|
||||||
nothing for consumers until a new tag is cut. Prompt 01 ends by cutting
|
|
||||||
`design_system-v0.2.0` and `web_ui-v0.3.2`; prompts 06 and 08 begin by
|
|
||||||
bumping to those literal refs.
|
|
||||||
2. **Anchor on symbols, not line numbers.** Line numbers in these prompts are
|
|
||||||
"as of 2026-08-16" and will drift. The symbol name is the real anchor —
|
|
||||||
grep for it rather than trusting the line.
|
|
||||||
3. **Reduce-motion is mandatory and is NOT the sound toggle.** Web:
|
|
||||||
`@media (prefers-reduced-motion: reduce)`. Flutter:
|
|
||||||
`MediaQuery.of(context).disableAnimations`. The OS motion preference is
|
|
||||||
honoured automatically; the sound opt-out is a separate user-facing toggle.
|
|
||||||
4. **Motion and haptics first, sound second.** Haptics need no dependency;
|
|
||||||
sound needs a pubspec dep, an `assets:` block, and a settings toggle. Each
|
|
||||||
prompt stages them separately so a short session still lands the valuable
|
|
||||||
half, and sound stays independently revertable.
|
|
||||||
5. **Sound ships on (opt-out), never in screen-locker enforcement paths.**
|
|
||||||
6. **250-line cap** applies to code and prose alike (`~/src/utils/file_length`).
|
|
||||||
|
|
||||||
## Source
|
|
||||||
|
|
||||||
Article findings, verbatim from the piece:
|
|
||||||
|
|
||||||
- **Variable reward schedules** — unpredictable outcomes produce stronger
|
|
||||||
dopaminergic activity than guaranteed ones. Duolingo streaks, feed ordering,
|
|
||||||
pull-to-refresh.
|
|
||||||
- **Micro-animations** — "a button that bounces on press, a like counter that
|
|
||||||
ticks upward with a slight delay". Replaces latency with feedback, so waiting
|
|
||||||
reads as responsiveness rather than frustration.
|
|
||||||
- **Sound design** — "operates below the threshold of conscious attention";
|
|
||||||
the article's judgement is that it is more powerful than visual design and
|
|
||||||
gets less critical attention.
|
|
||||||
- **Near-miss effects** — a fitness ring at ninety-three per cent creates
|
|
||||||
discomfort that drives re-engagement, and works *even when the user is
|
|
||||||
explicitly told the mechanism*.
|
|
||||||
- Core claim: **dopamine is released in anticipation of a reward, not on
|
|
||||||
receipt. The uncertainty is the trigger.**
|
|
||||||
@ -1,209 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Verify every path and symbol cited in the dopamine-ux prompt files.
|
|
||||||
|
|
||||||
A copy-pasteable prompt that points at a file which does not exist is worse than
|
|
||||||
no prompt, so this gate adjudicates with an exit code rather than by eye.
|
|
||||||
|
|
||||||
Two checks:
|
|
||||||
1. Every cited on-disk path resolves.
|
|
||||||
2. Every cited symbol is greppable in the file it is attributed to. A path
|
|
||||||
check alone cannot catch a stale line number; a symbol grep can.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
PROMPT_DIR = Path.home() / "src/utils" / "dopamine-ux"
|
|
||||||
HOME = Path.home()
|
|
||||||
|
|
||||||
PATH_RE = re.compile(r"`(~?/[^`\s]+|[\w.\-]+/[\w./\-]+\.\w+)`")
|
|
||||||
|
|
||||||
# Not on-disk paths despite matching the shape above.
|
|
||||||
SKIP_SUBSTRINGS = (
|
|
||||||
"://",
|
|
||||||
"$",
|
|
||||||
"*",
|
|
||||||
"<",
|
|
||||||
">",
|
|
||||||
"…",
|
|
||||||
"devices/", # Firebase/crdt key path
|
|
||||||
"settings/advanced", # Firebase key path
|
|
||||||
"android.permission",
|
|
||||||
"android.intent",
|
|
||||||
"/sdcard/",
|
|
||||||
"/etc/",
|
|
||||||
"/dev/input/",
|
|
||||||
"/opt/",
|
|
||||||
"com.kuhy",
|
|
||||||
"app-release.apk",
|
|
||||||
"build/app/outputs", # build outputs
|
|
||||||
"flutter/services.dart",
|
|
||||||
"flutter/material.dart", # package imports
|
|
||||||
)
|
|
||||||
|
|
||||||
# Files a prompt CREATES. Citing them forward is correct, so absence is expected
|
|
||||||
# -- but pairing each with its creating prompt still catches a typo.
|
|
||||||
CREATED_BY_PROMPT = {
|
|
||||||
"~/src/utils/unified-design-system/motion.md": "01",
|
|
||||||
"~/src/utils/unified-design-system/scripts/structural_check.py": "02",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Cited precisely to state that they do NOT exist.
|
|
||||||
ASSERTED_ABSENT = {
|
|
||||||
"~/gatelock",
|
|
||||||
# Distributed prompt copies are `git rm`d on completion (see the protocol in
|
|
||||||
# 00-INDEX.md), but 00-INDEX.md keeps citing them in its "Distributed copies"
|
|
||||||
# table. Asserting ABSENT rather than dropping the row keeps the check
|
|
||||||
# two-directional: a finished prompt reappearing in a repo is itself a
|
|
||||||
# failure, because the next session would re-run it. Move a row here as each
|
|
||||||
# prompt completes.
|
|
||||||
"~/src/utils/prompts/dopamine-ux-01-motion-tokens.md",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Generic prose fragments rather than citations of one specific file.
|
|
||||||
PROSE_FRAGMENTS = {"lib/ui/theme.dart", "lib/main.dart", "pubspec.yaml"}
|
|
||||||
|
|
||||||
# Symbols each prompt claims exist, as (repo-relative file, symbol).
|
|
||||||
SYMBOL_CLAIMS = [
|
|
||||||
("~/src/diet-guard/app/lib/screens/log_meal_screen.dart", "_onLogMeal"),
|
|
||||||
("~/src/diet-guard/app/lib/widgets/today_progress_card.dart", "TodayProgressCard"),
|
|
||||||
("~/src/diet-guard/app/lib/widgets/streak_summary_row.dart", "StreakSummaryRow"),
|
|
||||||
("~/src/diet-guard/app/lib/screens/log_meal_progress.dart", "buildTodayProgress"),
|
|
||||||
("~/src/diet-guard/app/lib/services/app_settings_service.dart", "_writeToDisk"),
|
|
||||||
("~/src/screen-locker/screen_locker/_status_types.py", "StatusSnapshot"),
|
|
||||||
("~/src/screen-locker/screen_locker/_extra_benefits.py", "process_week_transition"),
|
|
||||||
("~/src/screen-locker/screen_locker/_extra_benefits.py", "current_streak"),
|
|
||||||
("~/src/screen-locker/screen_locker/_unlock_view.py", "unlock_screen"),
|
|
||||||
("~/src/screen-locker/screen_locker/_log_mixin.py", "write_signed_entry"),
|
|
||||||
("~/src/screen-locker/screen_locker/tests/conftest.py", "_ISOLATED_STATE"),
|
|
||||||
("~/src/screen-locker/screen_locker/_weekly_check.py", "WEEKLY_WORKOUT_MINIMUM"),
|
|
||||||
(
|
|
||||||
"~/src/screen-locker/stronglift_replacement/workout_app/lib/screens/"
|
|
||||||
"workout_screen_session.dart",
|
|
||||||
"_playBreakEndCue",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"~/src/screen-locker/stronglift_replacement/workout_app/lib/screens/"
|
|
||||||
"workout_screen_finish.dart",
|
|
||||||
"_persistFinishedWorkout",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"~/src/screen-locker/stronglift_replacement/workout_app/lib/widgets/"
|
|
||||||
"workout_summary_dialog.dart",
|
|
||||||
"WorkoutSummaryDialog",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"~/src/screen-locker/stronglift_replacement/workout_app/lib/services/"
|
|
||||||
"storage_service.dart",
|
|
||||||
"_getSetting",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"~/src/screen-locker/stronglift_replacement/workout_app/lib/widgets/"
|
|
||||||
"exercise_tile_rows.dart",
|
|
||||||
"AnimatedContainer",
|
|
||||||
),
|
|
||||||
("~/src/todo/lib/data/app_settings.dart", "withAdvancedMode"),
|
|
||||||
("~/src/todo/lib/ui/settings_screen.dart", "SwitchListTile"),
|
|
||||||
("~/src/dufs-cloud/web/src/hooks/use-cloud-index.ts", "walkInto"),
|
|
||||||
("~/src/dufs-cloud/app/lib/services/cloud_index.dart", "buildCloudIndex"),
|
|
||||||
("~/src/dufs-cloud/web/src/lib/download.ts", "buildSelectionZip"),
|
|
||||||
("~/src/dufs-cloud/app/lib/services/download_zip.dart", "buildSelectionZip"),
|
|
||||||
("~/src/wake-alarm/phone_app/lib/screens/home_screen.dart", "_setPhoneAlarm"),
|
|
||||||
("~/src/wake-alarm/wake_alarm/_audio.py", "_play_on_all_sinks"),
|
|
||||||
("~/src/utils/unified-design-system/scripts/palette_check.py", "NON_COLOUR_CSS"),
|
|
||||||
("~/src/utils/unified-design-system/scripts/palette_map.py", "PALETTE"),
|
|
||||||
("~/src/utils/design_system/lib/src/feedback.dart", "showToast"),
|
|
||||||
("~/src/utils/design_system/lib/src/tokens.dart", "AppSpacing"),
|
|
||||||
("~/src/utils/gatelock/gatelock/_window.py", "LockConfig"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def expand(raw: str) -> Path:
|
|
||||||
return HOME / raw[2:] if raw.startswith("~/") else Path(raw)
|
|
||||||
|
|
||||||
|
|
||||||
def candidate_paths(text: str) -> set[str]:
|
|
||||||
return {
|
|
||||||
raw
|
|
||||||
for raw in PATH_RE.findall(text)
|
|
||||||
if not any(s in raw for s in SKIP_SUBSTRINGS)
|
|
||||||
and raw not in PROSE_FRAGMENTS
|
|
||||||
and not re.search(r"\.\w+:\d+$", raw) # trailing :line suffix
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def prompt_roots(text: str) -> list[Path]:
|
|
||||||
roots = []
|
|
||||||
for label in ("Repo", "App", "Flutter app"):
|
|
||||||
for m in re.finditer(rf"{label}: `(~/[^`]+)`", text):
|
|
||||||
roots.append(expand(m.group(1)))
|
|
||||||
return roots
|
|
||||||
|
|
||||||
|
|
||||||
def check_paths() -> list[str]:
|
|
||||||
failures = []
|
|
||||||
checked = 0
|
|
||||||
for prompt in sorted(PROMPT_DIR.glob("*.md")):
|
|
||||||
text = prompt.read_text()
|
|
||||||
roots = prompt_roots(text)
|
|
||||||
for raw in sorted(candidate_paths(text)):
|
|
||||||
if raw in ASSERTED_ABSENT:
|
|
||||||
if expand(raw).exists():
|
|
||||||
failures.append(
|
|
||||||
f"{prompt.name}: `{raw}` is asserted ABSENT but EXISTS"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
if raw in CREATED_BY_PROMPT:
|
|
||||||
owner = CREATED_BY_PROMPT[raw]
|
|
||||||
cites_owner = re.search(rf"[Pp]rompt {owner}", text) is not None
|
|
||||||
if not prompt.name.startswith(owner) and not cites_owner:
|
|
||||||
failures.append(
|
|
||||||
f"{prompt.name}: cites `{raw}` without noting prompt "
|
|
||||||
f"{owner} creates it"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
checked += 1
|
|
||||||
if raw.startswith(("~/", "/")):
|
|
||||||
if not expand(raw).exists():
|
|
||||||
failures.append(f"{prompt.name}: `{raw}` MISSING")
|
|
||||||
elif not any((r / raw).exists() for r in roots):
|
|
||||||
failures.append(
|
|
||||||
f"{prompt.name}: `{raw}` not found under any of "
|
|
||||||
f"{[str(r) for r in roots]}"
|
|
||||||
)
|
|
||||||
print(f"paths: checked {checked}")
|
|
||||||
return failures
|
|
||||||
|
|
||||||
|
|
||||||
def check_symbols() -> list[str]:
|
|
||||||
failures = []
|
|
||||||
for raw_file, symbol in SYMBOL_CLAIMS:
|
|
||||||
path = expand(raw_file)
|
|
||||||
if not path.exists():
|
|
||||||
failures.append(f"symbol source MISSING: {raw_file}")
|
|
||||||
continue
|
|
||||||
if symbol not in path.read_text(errors="replace"):
|
|
||||||
failures.append(f"symbol `{symbol}` NOT FOUND in {raw_file}")
|
|
||||||
print(f"symbols: checked {len(SYMBOL_CLAIMS)}")
|
|
||||||
return failures
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
n_prompts = len(list(PROMPT_DIR.glob("*.md")))
|
|
||||||
print(f"verifying {n_prompts} prompt files in {PROMPT_DIR}")
|
|
||||||
failures = check_paths() + check_symbols()
|
|
||||||
if failures:
|
|
||||||
print(f"\nFAIL -- {len(failures)} problem(s):")
|
|
||||||
for f in failures:
|
|
||||||
print(f" {f}")
|
|
||||||
return 1
|
|
||||||
print("\nPASS -- every cited path exists and every cited symbol is present")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@ -199,6 +199,7 @@ their `max_gaming_minutes` + pure bonuses again (240/120 min = 2/min; full day =
|
|||||||
from the start day, cutting all of it, 180 with LeetCode's formerly free hour (0.20.0); maturity never delays it. Ledger `~/.local/share/focus_project/ledger.json`:
|
from the start day, cutting all of it, 180 with LeetCode's formerly free hour (0.20.0); maturity never delays it. Ledger `~/.local/share/focus_project/ledger.json`:
|
||||||
signed `credit` rows, `entry_id` `<project>-<YYYYMMDD>-m<n>`, `detail.minutes` (positive int),
|
signed `credit` rows, `entry_id` `<project>-<YYYYMMDD>-m<n>`, `detail.minutes` (positive int),
|
||||||
`detail.ended_at` (else `day`). `focus_project` unusable = no project (logged).
|
`detail.ended_at` (else `day`). `focus_project` unusable = no project (logged).
|
||||||
|
Unbacked timer time pays 0 from 2026-11-08 (focus_project 0.10.0).
|
||||||
|
|
||||||
## Adding a gate
|
## Adding a gate
|
||||||
|
|
||||||
|
|||||||
88
earned_time/earned_time/tests/test_focus_backing.py
Normal file
88
earned_time/earned_time/tests/test_focus_backing.py
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Regression: a timer-only focus day after the anti-cheat cutover pays 0.
|
||||||
|
|
||||||
|
Runs the REAL ``focus_project`` (the sibling package of this monorepo):
|
||||||
|
timer rows -> harvester -> signed ledger -> ``credit_units``. earned_time
|
||||||
|
itself does not change; this pins that the gate reaches pay.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
import importlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from earned_time import credit_units, earners_for
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from types import ModuleType
|
||||||
|
|
||||||
|
DAY = date(2026, 11, 9) # after focus_project.BACKING_FROM
|
||||||
|
_SIBLING = Path(__file__).resolve().parents[3] / "focus_project"
|
||||||
|
|
||||||
|
|
||||||
|
def at(hour: int, minute: int) -> float:
|
||||||
|
return datetime(2026, 11, 9, hour, minute).astimezone().timestamp()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def real_fp(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, key_file: Path
|
||||||
|
) -> ModuleType:
|
||||||
|
"""The real focus_project on redirected state (HOME, data dir, key)."""
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
||||||
|
monkeypatch.setenv("FOCUS_PROJECT_DATA", str(tmp_path / "data"))
|
||||||
|
monkeypatch.setenv("FOCUS_PROJECT_KEY", str(key_file))
|
||||||
|
monkeypatch.setenv("FOCUS_PROJECT_LEETCODE_LEDGER", str(tmp_path / "lc.json"))
|
||||||
|
monkeypatch.delitem(sys.modules, "focus_project") # the conftest's fake
|
||||||
|
monkeypatch.syspath_prepend(str(_SIBLING))
|
||||||
|
fp = importlib.import_module("focus_project")
|
||||||
|
fp.create_project(
|
||||||
|
"hr",
|
||||||
|
name="HR",
|
||||||
|
start=DAY,
|
||||||
|
end=DAY,
|
||||||
|
replaces=["leetcode"],
|
||||||
|
daily_minutes=120,
|
||||||
|
unlock_minutes=30,
|
||||||
|
)
|
||||||
|
return fp
|
||||||
|
|
||||||
|
|
||||||
|
def pay(fp: ModuleType, tmp_path: Path, key_file: Path, *rows: object) -> int | None:
|
||||||
|
log = tmp_path / "data" / "activity.jsonl"
|
||||||
|
log.write_text("".join(json.dumps(r) + "\n" for r in rows), encoding="utf-8")
|
||||||
|
harvest = importlib.import_module("focus_project.harvest")
|
||||||
|
assert harvest.harvest(now=at(23, 0)) == 0
|
||||||
|
(item,) = (e for e in earners_for(DAY) if e.name == "focus")
|
||||||
|
return credit_units(item, tmp_path / "data" / "ledger.json", key_file, DAY)
|
||||||
|
|
||||||
|
|
||||||
|
def row(source: str, kind: str, ts: float) -> dict[str, object]:
|
||||||
|
return {"ts": ts, "source": source, "kind": kind, "project": "hr"}
|
||||||
|
|
||||||
|
|
||||||
|
TIMER = (
|
||||||
|
row("timer", "start", at(9, 0)),
|
||||||
|
row("timer", "checkin", at(9, 5)),
|
||||||
|
row("timer", "stop", at(9, 10)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_timer_only_day_pays_zero(
|
||||||
|
real_fp: ModuleType, tmp_path: Path, key_file: Path
|
||||||
|
) -> None:
|
||||||
|
assert date(2026, 11, 8) == real_fp.BACKING_FROM
|
||||||
|
assert pay(real_fp, tmp_path, key_file, *TIMER) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_backed_timer_day_still_pays(
|
||||||
|
real_fp: ModuleType, tmp_path: Path, key_file: Path
|
||||||
|
) -> None:
|
||||||
|
beat = row("tutor", "heartbeat", at(9, 5))
|
||||||
|
assert pay(real_fp, tmp_path, key_file, *TIMER, beat) == 10
|
||||||
@ -36,5 +36,18 @@ uncovers today. A NEW project starting today may replace gates and set any targe
|
|||||||
new obligation, not a reduction (but see the floor).
|
new obligation, not a reduction (but see the floor).
|
||||||
Floor (0.7.1, coordinator decision for kuhy): `daily_minutes >= 30 x replaced gates` (min 30; `book` = `reading`),
|
Floor (0.7.1, coordinator decision for kuhy): `daily_minutes >= 30 x replaced gates` (min 30; `book` = `reading`),
|
||||||
enforced on create and update (`daily_floor(replaces)`), so a day cannot be made cheap.
|
enforced on create and update (`daily_floor(replaces)`), so a day cannot be made cheap.
|
||||||
|
Backed timer (0.10.0, days from `BACKING_FROM` = 2026-11-08): a timer span earns only if a
|
||||||
|
same-project `tutor`/`sql_drills`/`leetcode` beat (or accepted submission) is in it, 5 min
|
||||||
|
before it, or bridges it (beats <= 20 min apart). `unbacked_minutes_for(day)` /
|
||||||
|
`status()["unbacked_minutes"]` show the rest. A beat backs only the spans it touches (the tutor chain makes a graded card back the whole stretch). Known gap: any coach turn backs.
|
||||||
Open the work: `python3 -m focus_project.open [--no-site]` (tutor focus screen, `work_url`).
|
Open the work: `python3 -m focus_project.open [--no-site]` (tutor focus screen, `work_url`).
|
||||||
Install: `./install.sh` (stdlib only, editable user install for `/usr/bin/python3`).
|
Install: `./install.sh` (stdlib only, editable user install for `/usr/bin/python3`).
|
||||||
|
|
||||||
|
Interview prep (`kind="interview"`, 0.9): `create_project(..., kind="interview", test_at=, invite_url=, trips=, weekend_answers=)`;
|
||||||
|
`end` must be the test day. Budget: 30 min weekdays / 120 Sat-Sun (`daily_on`), exempt from `daily_floor`, unlock clamped.
|
||||||
|
Milestones are generated, never stored: `milestones(project, now)` -> `rules`, `trip_ask:<sat>`, `phone:<trip start>`, `mock`, `slot`
|
||||||
|
with states pending/done/missed/late_done/unknown. `owed_lock(now)` = earliest overdue uncompleted one (never on test day; wire it
|
||||||
|
after focus-guard's sick/free-day stand-down). `status(day)` gains `milestones` and `missed`.
|
||||||
|
Evidence is append-only (`evidence/<id>.jsonl` + copied files): `record_evidence(id, milestone, kind, value, file=, now=)`
|
||||||
|
(`file`, `tap`, `score`, `slot`, `slot_confirm`, tutor's `phone_check`). `add_trip` / `answer_weekend` only get stricter (ValueError).
|
||||||
|
`run_checks(id)` runs the `phone` source (reads the latest `phone_check` row) and latches changes; `register_source` swaps it.
|
||||||
|
|||||||
@ -23,26 +23,44 @@ from focus_project._api import (
|
|||||||
stands_down,
|
stands_down,
|
||||||
)
|
)
|
||||||
from focus_project._errors import RegistryError
|
from focus_project._errors import RegistryError
|
||||||
|
from focus_project._milestones import Milestone, milestones, owed_lock
|
||||||
from focus_project._model import Limits, Project
|
from focus_project._model import Limits, Project
|
||||||
|
from focus_project._mutate import add_trip, answer_weekend, record_evidence
|
||||||
|
from focus_project._trips import Trip
|
||||||
from focus_project._update import Update, apply_update, update_project
|
from focus_project._update import Update, apply_update, update_project
|
||||||
from focus_project.activity import record_activity
|
from focus_project.activity import record_activity
|
||||||
from focus_project.minutes import minutes_for
|
from focus_project.checks import register_source, run_checks
|
||||||
|
from focus_project.evidence import Evidence
|
||||||
|
from focus_project.minutes import minutes_for, unbacked_minutes_for
|
||||||
|
from focus_project.sources.activity import BACKING_FROM
|
||||||
from focus_project.status import status
|
from focus_project.status import status
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"BACKING_FROM",
|
||||||
|
"Evidence",
|
||||||
"Limits",
|
"Limits",
|
||||||
|
"Milestone",
|
||||||
"Project",
|
"Project",
|
||||||
"RegistryError",
|
"RegistryError",
|
||||||
|
"Trip",
|
||||||
"Update",
|
"Update",
|
||||||
"active",
|
"active",
|
||||||
|
"add_trip",
|
||||||
|
"answer_weekend",
|
||||||
"apply_update",
|
"apply_update",
|
||||||
"create_project",
|
"create_project",
|
||||||
"daily_floor",
|
"daily_floor",
|
||||||
"end_project",
|
"end_project",
|
||||||
"list_projects",
|
"list_projects",
|
||||||
|
"milestones",
|
||||||
"minutes_for",
|
"minutes_for",
|
||||||
|
"owed_lock",
|
||||||
"record_activity",
|
"record_activity",
|
||||||
|
"record_evidence",
|
||||||
|
"register_source",
|
||||||
|
"run_checks",
|
||||||
"stands_down",
|
"stands_down",
|
||||||
"status",
|
"status",
|
||||||
|
"unbacked_minutes_for",
|
||||||
"update_project",
|
"update_project",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -4,19 +4,24 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from datetime import date # noqa: TC003
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from urllib.parse import urlsplit
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from focus_project import _clock, _store
|
from focus_project import _clock, _interview, _store
|
||||||
from focus_project._errors import RegistryError
|
from focus_project._errors import RegistryError
|
||||||
|
from focus_project._kind import FOCUS
|
||||||
from focus_project._model import GATES, Project, normalize_gate
|
from focus_project._model import GATES, Project, normalize_gate
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from focus_project._trips import Trip
|
||||||
|
|
||||||
_LOG = logging.getLogger(__name__)
|
_LOG = logging.getLogger(__name__)
|
||||||
_ID = re.compile(r"[a-z0-9][a-z0-9-]*")
|
_ID = re.compile(r"[a-z0-9][a-z0-9-]*")
|
||||||
MAX_WORK_URL = 2000
|
MAX_WORK_URL = _interview.MAX_URL
|
||||||
# A project must ask real work: the focus earner pays per minute of ``daily``.
|
# A project must ask real work: the focus earner pays per minute of ``daily``.
|
||||||
MIN_DAILY_PER_GATE = 30
|
MIN_DAILY_PER_GATE = 30
|
||||||
|
|
||||||
@ -53,17 +58,7 @@ def stands_down(gate: str, day: date) -> bool:
|
|||||||
|
|
||||||
def _validate_work_url(url: str) -> None:
|
def _validate_work_url(url: str) -> None:
|
||||||
"""Empty, or a plain http(s) URL with a host: it goes to ``xdg-open``."""
|
"""Empty, or a plain http(s) URL with a host: it goes to ``xdg-open``."""
|
||||||
if not url:
|
_interview.check_http_url(url, "work_url")
|
||||||
return
|
|
||||||
parts = urlsplit(url) if url.isprintable() and " " not in url else None
|
|
||||||
if (
|
|
||||||
parts is None
|
|
||||||
or len(url) > MAX_WORK_URL
|
|
||||||
or parts.scheme not in {"http", "https"}
|
|
||||||
or not parts.netloc
|
|
||||||
):
|
|
||||||
msg = "work_url must be an http(s) URL without spaces (or empty)"
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
|
|
||||||
def daily_floor(replaces: tuple[str, ...]) -> int:
|
def daily_floor(replaces: tuple[str, ...]) -> int:
|
||||||
@ -85,8 +80,9 @@ def _validate(project: Project) -> None:
|
|||||||
if not project.replaces or not set(project.replaces) <= GATES:
|
if not project.replaces or not set(project.replaces) <= GATES:
|
||||||
msg = f"replaces must be a non-empty subset of gates {sorted(GATES)}"
|
msg = f"replaces must be a non-empty subset of gates {sorted(GATES)}"
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
|
_interview.validate(project)
|
||||||
floor = daily_floor(project.replaces)
|
floor = daily_floor(project.replaces)
|
||||||
if project.daily_minutes < floor:
|
if project.kind == FOCUS and project.daily_minutes < floor:
|
||||||
msg = (
|
msg = (
|
||||||
f"daily_minutes must be at least {floor} "
|
f"daily_minutes must be at least {floor} "
|
||||||
f"({MIN_DAILY_PER_GATE} per replaced gate)"
|
f"({MIN_DAILY_PER_GATE} per replaced gate)"
|
||||||
@ -109,8 +105,19 @@ def create_project( # noqa: PLR0913
|
|||||||
subjects: list[str] | None = None,
|
subjects: list[str] | None = None,
|
||||||
site_patterns: list[str] | None = None,
|
site_patterns: list[str] | None = None,
|
||||||
work_url: str = "",
|
work_url: str = "",
|
||||||
|
kind: str = FOCUS,
|
||||||
|
test_at: datetime | None = None,
|
||||||
|
invite_url: str = "",
|
||||||
|
trips: list[Trip] | None = None,
|
||||||
|
weekend_answers: dict[date, bool] | None = None,
|
||||||
) -> Project:
|
) -> Project:
|
||||||
"""Add a project; ValueError on bad input, a duplicate id or an overlap."""
|
"""Add a project; ValueError on bad input, a duplicate id or an overlap.
|
||||||
|
|
||||||
|
``kind="interview"`` takes ``test_at`` (naive local; its date must be
|
||||||
|
``end``), ``invite_url``, ``trips`` and ``weekend_answers``; an interview
|
||||||
|
is exempt from :func:`daily_floor` and runs the 30/120 budget.
|
||||||
|
"""
|
||||||
|
created_ms = time.time_ns() // 1_000_000
|
||||||
project = Project(
|
project = Project(
|
||||||
id=project_id,
|
id=project_id,
|
||||||
name=name,
|
name=name,
|
||||||
@ -122,7 +129,15 @@ def create_project( # noqa: PLR0913
|
|||||||
subjects=tuple(subjects or ()),
|
subjects=tuple(subjects or ()),
|
||||||
site_patterns=tuple(site_patterns or ()),
|
site_patterns=tuple(site_patterns or ()),
|
||||||
work_url=work_url,
|
work_url=work_url,
|
||||||
created_ms=time.time_ns() // 1_000_000,
|
created_ms=created_ms,
|
||||||
|
kind=kind,
|
||||||
|
test_at=test_at,
|
||||||
|
invite_url=invite_url,
|
||||||
|
trips=tuple(
|
||||||
|
t if t.entered_ms else replace(t, entered_ms=created_ms)
|
||||||
|
for t in trips or ()
|
||||||
|
),
|
||||||
|
weekend_answers=tuple(sorted((weekend_answers or {}).items())),
|
||||||
)
|
)
|
||||||
_validate(project)
|
_validate(project)
|
||||||
with _store.locked():
|
with _store.locked():
|
||||||
|
|||||||
89
focus_project/focus_project/_interview.py
Normal file
89
focus_project/focus_project/_interview.py
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Validation of the interview-only fields."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from focus_project._kind import FOCUS, KINDS, WEEKDAY_MINUTES
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from focus_project._model import Project
|
||||||
|
|
||||||
|
MAX_URL = 2000
|
||||||
|
SATURDAY = 5
|
||||||
|
|
||||||
|
|
||||||
|
def check_http_url(url: str, label: str) -> None:
|
||||||
|
"""Empty, or a plain http(s) URL with a host: it goes to ``xdg-open``."""
|
||||||
|
if not url:
|
||||||
|
return
|
||||||
|
parts = urlsplit(url) if url.isprintable() and " " not in url else None
|
||||||
|
if (
|
||||||
|
parts is None
|
||||||
|
or len(url) > MAX_URL
|
||||||
|
or parts.scheme not in {"http", "https"}
|
||||||
|
or not parts.netloc
|
||||||
|
):
|
||||||
|
msg = f"{label} must be an http(s) URL without spaces (or empty)"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def saturdays(project: Project) -> list[date]:
|
||||||
|
"""The Saturdays whose weekend lies inside D1..T-1."""
|
||||||
|
if project.test_at is None:
|
||||||
|
return []
|
||||||
|
last = project.test_at.date() - timedelta(days=1)
|
||||||
|
day = project.start + timedelta(days=(SATURDAY - project.start.weekday()) % 7)
|
||||||
|
found: list[date] = []
|
||||||
|
while day <= last:
|
||||||
|
found.append(day)
|
||||||
|
day += timedelta(days=7)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _has_interview_fields(project: Project) -> bool:
|
||||||
|
return bool(
|
||||||
|
project.test_at
|
||||||
|
or project.invite_url
|
||||||
|
or project.trips
|
||||||
|
or project.weekend_answers
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_interview(project: Project) -> None:
|
||||||
|
if project.test_at is None or project.test_at.tzinfo is not None:
|
||||||
|
msg = "an interview needs test_at as a naive local datetime"
|
||||||
|
raise ValueError(msg)
|
||||||
|
test_day = project.test_at.date()
|
||||||
|
if project.end != test_day:
|
||||||
|
msg = f"end must be the test day {test_day}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
if project.daily_minutes < WEEKDAY_MINUTES:
|
||||||
|
msg = f"daily_minutes must be at least {WEEKDAY_MINUTES}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
for trip in project.trips:
|
||||||
|
if trip.end < trip.start or trip.start > test_day:
|
||||||
|
msg = f"trip {trip.start}..{trip.end} must start by the test day"
|
||||||
|
raise ValueError(msg)
|
||||||
|
weekends = saturdays(project)
|
||||||
|
for day, _ in project.weekend_answers:
|
||||||
|
if day not in weekends:
|
||||||
|
msg = f"{day} is not a weekend inside the project before the test day"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def validate(project: Project) -> None:
|
||||||
|
"""ValueError unless the kind and its interview fields are coherent."""
|
||||||
|
if project.kind not in KINDS:
|
||||||
|
msg = f"kind must be one of {sorted(KINDS)}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
check_http_url(project.invite_url, "invite_url")
|
||||||
|
if project.kind == FOCUS:
|
||||||
|
if _has_interview_fields(project):
|
||||||
|
msg = "test_at, invite_url, trips, weekend_answers need kind='interview'"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return
|
||||||
|
_validate_interview(project)
|
||||||
23
focus_project/focus_project/_kind.py
Normal file
23
focus_project/focus_project/_kind.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Project kinds and the interview budget (the one place the numbers live)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
FOCUS = "focus"
|
||||||
|
INTERVIEW = "interview"
|
||||||
|
KINDS = frozenset({FOCUS, INTERVIEW})
|
||||||
|
|
||||||
|
# kuhy, 2026-11-10: near-zero weekday energy, weekends are for learning.
|
||||||
|
WEEKDAY_MINUTES = 30
|
||||||
|
WEEKEND_MINUTES = 120
|
||||||
|
_FIRST_WEEKEND_DAY = 5 # Saturday
|
||||||
|
|
||||||
|
|
||||||
|
def interview_minutes(day: date) -> int:
|
||||||
|
"""The daily target of an interview project on ``day`` (Sat/Sun = 120)."""
|
||||||
|
return WEEKEND_MINUTES if day.weekday() >= _FIRST_WEEKEND_DAY else WEEKDAY_MINUTES
|
||||||
235
focus_project/focus_project/_milestones.py
Normal file
235
focus_project/focus_project/_milestones.py
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Interview milestones: generated from the project, judged from evidence.
|
||||||
|
|
||||||
|
Nothing here is stored or editable. ``due`` and the state are recomputed from
|
||||||
|
the project (kind, test_at, trips, weekend answers) and the append-only
|
||||||
|
evidence rows, so a milestone cannot be moved or ticked off by editing a row.
|
||||||
|
|
||||||
|
Failure modes (R8): an unanswered trip question reads as a trip (fail
|
||||||
|
closed); a check that could not run is ``unknown`` and counts as not done
|
||||||
|
(fail closed); an unreadable registry or evidence file arms no lock (fail
|
||||||
|
open, logged), matching focus-guard's "unreadable registry" rule.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date, datetime, time, timedelta
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING, Any, Literal
|
||||||
|
|
||||||
|
from focus_project import _api, _interview, evidence
|
||||||
|
from focus_project._errors import RegistryError
|
||||||
|
from focus_project._kind import INTERVIEW
|
||||||
|
from focus_project.sources import activity
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
from focus_project._model import Project
|
||||||
|
|
||||||
|
_LOG = logging.getLogger(__name__)
|
||||||
|
State = Literal["pending", "done", "missed", "late_done", "unknown"]
|
||||||
|
PHONE_LEAD = timedelta(hours=12)
|
||||||
|
MOCK_SESSION_S = 90 * 60
|
||||||
|
_RED = frozenset({"missed", "unknown"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Milestone:
|
||||||
|
"""One generated obligation of an interview project."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
due: datetime
|
||||||
|
state: State
|
||||||
|
completed: bool
|
||||||
|
|
||||||
|
@property
|
||||||
|
def red(self) -> bool:
|
||||||
|
"""Past due and not done (also: the check could not run)."""
|
||||||
|
return self.state in _RED
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
"""The status() row: id, due ISO, state."""
|
||||||
|
return {"id": self.id, "due": self.due.isoformat(), "state": self.state}
|
||||||
|
|
||||||
|
|
||||||
|
def _at(day: date, hour: int, minute: int = 0) -> datetime:
|
||||||
|
return datetime.combine(day, time(hour, minute))
|
||||||
|
|
||||||
|
|
||||||
|
def _trip_due(trip_start: date, entered_ms: int) -> datetime:
|
||||||
|
"""Start - 12 h, or the moment it was entered if that left < 12 h."""
|
||||||
|
nominal = _at(trip_start, 0) - PHONE_LEAD
|
||||||
|
if entered_ms > 0:
|
||||||
|
entered = evidence.from_ms(entered_ms)
|
||||||
|
if nominal - entered < PHONE_LEAD:
|
||||||
|
return entered
|
||||||
|
return nominal
|
||||||
|
|
||||||
|
|
||||||
|
def _is_trip(
|
||||||
|
*,
|
||||||
|
answered: bool | None,
|
||||||
|
answer_ms: int | None,
|
||||||
|
ask_due: datetime,
|
||||||
|
phone_due: datetime,
|
||||||
|
now: datetime,
|
||||||
|
) -> bool:
|
||||||
|
"""Whether the weekend counts as a trip (fail closed).
|
||||||
|
|
||||||
|
Unanswered once the question is due = a trip. A no-trip answer given
|
||||||
|
only after the phone prep was already due cannot erase that overdue
|
||||||
|
milestone: the answer row's own timestamp decides, not a stored flag.
|
||||||
|
"""
|
||||||
|
if answered is None:
|
||||||
|
return now >= ask_due
|
||||||
|
if answered:
|
||||||
|
return True
|
||||||
|
return answer_ms is not None and answer_ms >= evidence.to_ms(phone_due)
|
||||||
|
|
||||||
|
|
||||||
|
def plan(
|
||||||
|
project: Project, test_day: date, now: datetime, rows: list[evidence.Evidence]
|
||||||
|
) -> dict[str, datetime]:
|
||||||
|
"""Milestone id -> due; ``project`` must be a validated interview."""
|
||||||
|
dues = {
|
||||||
|
"rules": _at(project.start, 23, 59),
|
||||||
|
"mock": _at(test_day - timedelta(days=2), 23, 59),
|
||||||
|
"slot": _at(test_day - timedelta(days=1), 12),
|
||||||
|
}
|
||||||
|
answers = dict(project.weekend_answers)
|
||||||
|
for sat in _interview.saturdays(project):
|
||||||
|
ask_due = _at(sat - timedelta(days=2), 23, 59)
|
||||||
|
phone_due = _at(sat, 0) - PHONE_LEAD
|
||||||
|
dues[f"trip_ask:{sat}"] = ask_due
|
||||||
|
covered = any(t.covers(sat) for t in project.trips)
|
||||||
|
row = evidence.first(rows, f"trip_ask:{sat}", "answer")
|
||||||
|
answer_ms = None if row is None else row.ms
|
||||||
|
if not covered and _is_trip(
|
||||||
|
answered=answers.get(sat),
|
||||||
|
answer_ms=answer_ms,
|
||||||
|
ask_due=ask_due,
|
||||||
|
phone_due=phone_due,
|
||||||
|
now=now,
|
||||||
|
):
|
||||||
|
dues[f"phone:{sat}"] = phone_due
|
||||||
|
for trip_row in project.trips:
|
||||||
|
dues[f"phone:{trip_row.start}"] = _trip_due(trip_row.start, trip_row.entered_ms)
|
||||||
|
return dues
|
||||||
|
|
||||||
|
|
||||||
|
def _when(row: evidence.Evidence | None) -> datetime | None:
|
||||||
|
return None if row is None else row.at
|
||||||
|
|
||||||
|
|
||||||
|
Rows = list[evidence.Evidence]
|
||||||
|
Completion = tuple[datetime | None, bool]
|
||||||
|
|
||||||
|
|
||||||
|
def _by_evidence(kind: str) -> Callable[[str, Rows, Project], Completion]:
|
||||||
|
"""Done at the first evidence row of ``kind`` (rules file, mock score)."""
|
||||||
|
|
||||||
|
def judge(mid: str, rows: Rows, _project: Project) -> Completion:
|
||||||
|
return _when(evidence.first(rows, mid, kind)), False
|
||||||
|
|
||||||
|
return judge
|
||||||
|
|
||||||
|
|
||||||
|
def _mock(mid: str, rows: Rows, project: Project) -> Completion:
|
||||||
|
"""Done at the first score row that follows a >= 90 min focus session.
|
||||||
|
|
||||||
|
The session (contiguous beats, :func:`activity.sessions`) must END at or
|
||||||
|
before the score row; a score with no such session completes nothing.
|
||||||
|
"""
|
||||||
|
ends = [hi for lo, hi in activity.sessions(project.id) if hi - lo >= MOCK_SESSION_S]
|
||||||
|
for row in rows:
|
||||||
|
if (row.milestone, row.kind) == (mid, "score") and any(
|
||||||
|
end <= row.ms / 1000 for end in ends
|
||||||
|
):
|
||||||
|
return row.at, False
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
|
||||||
|
def _trip_ask(mid: str, rows: Rows, project: Project) -> Completion:
|
||||||
|
"""Done at the answer row; an answer with no row was given at creation."""
|
||||||
|
if date.fromisoformat(mid.partition(":")[2]) not in dict(project.weekend_answers):
|
||||||
|
return None, False
|
||||||
|
return _when(evidence.first(rows, mid, "answer")) or evidence.from_ms(0), False
|
||||||
|
|
||||||
|
|
||||||
|
def _slot(mid: str, rows: Rows, _project: Project) -> Completion:
|
||||||
|
"""Done once the latest slot entry has a confirm tap at or after it."""
|
||||||
|
slot = evidence.last(rows, mid, "slot")
|
||||||
|
if slot is None:
|
||||||
|
return None, False
|
||||||
|
confirm = evidence.first(rows, mid, "slot_confirm", since_ms=slot.ms)
|
||||||
|
return (None if confirm is None else max(slot.at, confirm.at)), False
|
||||||
|
|
||||||
|
|
||||||
|
def _phone(mid: str, rows: Rows, _project: Project) -> Completion:
|
||||||
|
"""Done when the offline tap and a passing auto check both exist.
|
||||||
|
|
||||||
|
A tap with no verdict at all (or a dark source) is ``unknown``.
|
||||||
|
"""
|
||||||
|
tap = evidence.first(rows, mid, "tap")
|
||||||
|
passed, latest, _ = evidence.check_summary(rows, mid)
|
||||||
|
if tap is not None and passed is not None:
|
||||||
|
return max(tap.at, evidence.from_ms(passed)), False
|
||||||
|
return None, tap is not None and latest is None
|
||||||
|
|
||||||
|
|
||||||
|
_JUDGES = {
|
||||||
|
"rules": _by_evidence("file"),
|
||||||
|
"mock": _mock,
|
||||||
|
"trip_ask": _trip_ask,
|
||||||
|
"slot": _slot,
|
||||||
|
"phone": _phone,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _judge(
|
||||||
|
mid: str, due: datetime, rows: Rows, project: Project, now: datetime
|
||||||
|
) -> Milestone:
|
||||||
|
done_at, unknown = _JUDGES[mid.partition(":")[0]](mid, rows, project)
|
||||||
|
if done_at is not None:
|
||||||
|
state: State = "done" if done_at <= due else "late_done"
|
||||||
|
return Milestone(mid, due, state, completed=True)
|
||||||
|
if now < due:
|
||||||
|
return Milestone(mid, due, "pending", completed=False)
|
||||||
|
return Milestone(mid, due, "unknown" if unknown else "missed", completed=False)
|
||||||
|
|
||||||
|
|
||||||
|
def milestones(project: Project, now: datetime) -> list[Milestone]:
|
||||||
|
"""The project's milestones at ``now`` (naive local), earliest due first.
|
||||||
|
|
||||||
|
Empty for a focus project. May raise OSError reading the evidence file.
|
||||||
|
"""
|
||||||
|
if project.kind != INTERVIEW or project.test_at is None:
|
||||||
|
return []
|
||||||
|
rows = evidence.read(project.id)
|
||||||
|
dues = plan(project, project.test_at.date(), now, rows)
|
||||||
|
found = [_judge(mid, due, rows, project, now) for mid, due in dues.items()]
|
||||||
|
return sorted(found, key=lambda m: (m.due, m.id))
|
||||||
|
|
||||||
|
|
||||||
|
def owed_lock(now: datetime) -> Milestone | None:
|
||||||
|
"""The earliest overdue, uncompleted milestone that locks, or None.
|
||||||
|
|
||||||
|
Locks (missed or unknown) from the moment it is due, every day of the
|
||||||
|
project except the test day itself, where nothing locks. It does not
|
||||||
|
consult sickdays/freedays: focus-guard's ``_off(today)`` precondition
|
||||||
|
already stands the lock down on those days, so wire this after it.
|
||||||
|
Unreadable state arms nothing (fail open, logged).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
project = _api.active(now.date())
|
||||||
|
if project is None or project.test_at is None:
|
||||||
|
return None
|
||||||
|
if now.date() >= project.test_at.date():
|
||||||
|
return None
|
||||||
|
owed = [m for m in milestones(project, now) if not m.completed and now >= m.due]
|
||||||
|
except (RegistryError, OSError) as exc:
|
||||||
|
_LOG.warning("interview state unreadable, no milestone lock: %s", exc)
|
||||||
|
return None
|
||||||
|
return owed[0] if owed else None
|
||||||
@ -4,10 +4,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date
|
from datetime import date, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from focus_project._errors import RegistryError
|
from focus_project._errors import RegistryError
|
||||||
|
from focus_project._kind import FOCUS, INTERVIEW, interview_minutes
|
||||||
|
from focus_project._trips import Trip
|
||||||
|
|
||||||
GATES = frozenset({"leetcode", "reading", "tutor"})
|
GATES = frozenset({"leetcode", "reading", "tutor"})
|
||||||
_ALIASES = {"book": "reading"}
|
_ALIASES = {"book": "reading"}
|
||||||
@ -18,6 +20,17 @@ def normalize_gate(gate: str) -> str:
|
|||||||
return _ALIASES.get(gate, gate)
|
return _ALIASES.get(gate, gate)
|
||||||
|
|
||||||
|
|
||||||
|
def _answers(raw: dict[str, Any] | None) -> tuple[tuple[date, bool], ...]:
|
||||||
|
"""Parse the saturday -> bool map; a non-bool answer is a bad row."""
|
||||||
|
pairs = []
|
||||||
|
for key, value in (raw or {}).items():
|
||||||
|
if not isinstance(value, bool):
|
||||||
|
msg = f"weekend answer for {key} is not true/false"
|
||||||
|
raise TypeError(msg)
|
||||||
|
pairs.append((date.fromisoformat(key), value))
|
||||||
|
return tuple(sorted(pairs))
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Limits:
|
class Limits:
|
||||||
"""The minute targets that held up to and including ``until``."""
|
"""The minute targets that held up to and including ``until``."""
|
||||||
@ -70,19 +83,30 @@ class Project:
|
|||||||
work_url: str = ""
|
work_url: str = ""
|
||||||
# Earlier targets, oldest first: an edit applies from its day, never back.
|
# Earlier targets, oldest first: an edit applies from its day, never back.
|
||||||
history: tuple[Limits, ...] = ()
|
history: tuple[Limits, ...] = ()
|
||||||
|
# "focus" (every pre-interview row) or "interview"; the rest is interview-only.
|
||||||
|
kind: str = FOCUS
|
||||||
|
# Local naive datetime of the test; its date is the project's last day.
|
||||||
|
test_at: datetime | None = None
|
||||||
|
invite_url: str = ""
|
||||||
|
trips: tuple[Trip, ...] = ()
|
||||||
|
# Saturday -> True (trip that weekend) / False (no trip); sorted pairs.
|
||||||
|
weekend_answers: tuple[tuple[date, bool], ...] = ()
|
||||||
|
|
||||||
def _limits_on(self, day: date) -> Limits | None:
|
def _limits_on(self, day: date) -> Limits | None:
|
||||||
return next((h for h in self.history if day <= h.until), None)
|
return next((h for h in self.history if day <= h.until), None)
|
||||||
|
|
||||||
def daily_on(self, day: date) -> int:
|
def daily_on(self, day: date) -> int:
|
||||||
"""The daily target that held on ``day``."""
|
"""The daily target that held on ``day``."""
|
||||||
|
if self.kind == INTERVIEW:
|
||||||
|
return interview_minutes(day)
|
||||||
past = self._limits_on(day)
|
past = self._limits_on(day)
|
||||||
return self.daily_minutes if past is None else past.daily_minutes
|
return self.daily_minutes if past is None else past.daily_minutes
|
||||||
|
|
||||||
def unlock_on(self, day: date) -> int:
|
def unlock_on(self, day: date) -> int:
|
||||||
"""The unlock target that held on ``day``."""
|
"""The unlock target that held on ``day`` (clamped for an interview)."""
|
||||||
past = self._limits_on(day)
|
past = self._limits_on(day)
|
||||||
return self.unlock_minutes if past is None else past.unlock_minutes
|
held = self.unlock_minutes if past is None else past.unlock_minutes
|
||||||
|
return min(held, interview_minutes(day)) if self.kind == INTERVIEW else held
|
||||||
|
|
||||||
def replaces_on(self, day: date) -> tuple[str, ...]:
|
def replaces_on(self, day: date) -> tuple[str, ...]:
|
||||||
"""The gates this project switched off on ``day``."""
|
"""The gates this project switched off on ``day``."""
|
||||||
@ -104,7 +128,7 @@ class Project:
|
|||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
"""The projects.json row."""
|
"""The projects.json row."""
|
||||||
return {
|
row: dict[str, Any] = {
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"name": self.name,
|
"name": self.name,
|
||||||
"start": self.start.isoformat(),
|
"start": self.start.isoformat(),
|
||||||
@ -118,13 +142,23 @@ class Project:
|
|||||||
"created_ms": self.created_ms,
|
"created_ms": self.created_ms,
|
||||||
"work_url": self.work_url,
|
"work_url": self.work_url,
|
||||||
"history": [h.to_dict() for h in self.history],
|
"history": [h.to_dict() for h in self.history],
|
||||||
|
"kind": self.kind,
|
||||||
}
|
}
|
||||||
|
if self.kind == INTERVIEW:
|
||||||
|
row["test_at"] = self.test_at.isoformat() if self.test_at else None
|
||||||
|
row["invite_url"] = self.invite_url
|
||||||
|
row["trips"] = [t.to_dict() for t in self.trips]
|
||||||
|
row["weekend_answers"] = {
|
||||||
|
day.isoformat(): trip for day, trip in self.weekend_answers
|
||||||
|
}
|
||||||
|
return row
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, row: Any) -> Project: # noqa: ANN401
|
def from_dict(cls, row: Any) -> Project: # noqa: ANN401
|
||||||
"""Parse a registry row, or raise :class:`RegistryError`."""
|
"""Parse a registry row, or raise :class:`RegistryError`."""
|
||||||
try:
|
try:
|
||||||
ended = row.get("ended_on")
|
ended = row.get("ended_on")
|
||||||
|
test_at = row.get("test_at")
|
||||||
return cls(
|
return cls(
|
||||||
id=str(row["id"]),
|
id=str(row["id"]),
|
||||||
name=str(row["name"]),
|
name=str(row["name"]),
|
||||||
@ -139,6 +173,11 @@ class Project:
|
|||||||
created_ms=int(row.get("created_ms", 0)),
|
created_ms=int(row.get("created_ms", 0)),
|
||||||
work_url=str(row.get("work_url") or ""),
|
work_url=str(row.get("work_url") or ""),
|
||||||
history=tuple(Limits.from_dict(h) for h in row.get("history", ())),
|
history=tuple(Limits.from_dict(h) for h in row.get("history", ())),
|
||||||
|
kind=str(row.get("kind") or FOCUS),
|
||||||
|
test_at=datetime.fromisoformat(test_at) if test_at else None,
|
||||||
|
invite_url=str(row.get("invite_url") or ""),
|
||||||
|
trips=tuple(Trip.from_dict(t) for t in row.get("trips", ())),
|
||||||
|
weekend_answers=_answers(row.get("weekend_answers")),
|
||||||
)
|
)
|
||||||
except (KeyError, TypeError, ValueError, AttributeError) as exc:
|
except (KeyError, TypeError, ValueError, AttributeError) as exc:
|
||||||
msg = f"bad project row {row!r}: {exc}"
|
msg = f"bad project row {row!r}: {exc}"
|
||||||
|
|||||||
178
focus_project/focus_project/_mutate.py
Normal file
178
focus_project/focus_project/_mutate.py
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Interview mutations: trips, weekend answers, evidence. Stricter only."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
|
from datetime import date, datetime
|
||||||
|
import math
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from focus_project import _interview, _milestones, _store, evidence
|
||||||
|
from focus_project._kind import INTERVIEW
|
||||||
|
from focus_project._trips import Trip, add
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from focus_project._model import Project
|
||||||
|
|
||||||
|
# Which evidence kinds each milestone family accepts from a caller.
|
||||||
|
# ``check`` and ``answer`` are internal: written by run_checks/answer_weekend.
|
||||||
|
_ACCEPTS = {
|
||||||
|
"rules": {"file"},
|
||||||
|
"mock": {"score"},
|
||||||
|
"slot": {"slot", "slot_confirm"},
|
||||||
|
"phone": {"tap"},
|
||||||
|
}
|
||||||
|
# tutor's worker reports phone readiness under the bare id "phone".
|
||||||
|
_PHONE_REPORT = "phone"
|
||||||
|
|
||||||
|
|
||||||
|
def local_now(now: datetime | None) -> datetime:
|
||||||
|
"""``now`` or the clock's local time."""
|
||||||
|
return evidence.clock_now() if now is None else now
|
||||||
|
|
||||||
|
|
||||||
|
def find(existing: list[Project], project_id: str) -> tuple[int, Project]:
|
||||||
|
"""Index and interview project; KeyError unknown, ValueError focus kind."""
|
||||||
|
for i, project in enumerate(existing):
|
||||||
|
if project.id == project_id:
|
||||||
|
if project.kind != INTERVIEW:
|
||||||
|
msg = f"{project_id!r} is not an interview project (no milestones)"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return i, project
|
||||||
|
raise KeyError(project_id)
|
||||||
|
|
||||||
|
|
||||||
|
def add_trip(
|
||||||
|
project_id: str, start: date, end: date, *, now: datetime | None = None
|
||||||
|
) -> Project:
|
||||||
|
"""Announce a trip; it may only grow (see :func:`focus_project._trips.add`)."""
|
||||||
|
stamp = local_now(now)
|
||||||
|
with _store.locked():
|
||||||
|
existing = _store.read_all()
|
||||||
|
i, project = find(existing, project_id)
|
||||||
|
trips = add(project.trips, Trip(start, end, evidence.to_ms(stamp)))
|
||||||
|
updated = replace(project, trips=trips)
|
||||||
|
_interview.validate(updated)
|
||||||
|
existing[i] = updated
|
||||||
|
_store.write_all(existing)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def answer_weekend(
|
||||||
|
project_id: str, saturday: date, *, trip: bool, now: datetime | None = None
|
||||||
|
) -> Project:
|
||||||
|
"""Answer the trip question of a weekend; True never flips back to False."""
|
||||||
|
stamp = local_now(now)
|
||||||
|
with _store.locked():
|
||||||
|
existing = _store.read_all()
|
||||||
|
i, project = find(existing, project_id)
|
||||||
|
if saturday not in _interview.saturdays(project):
|
||||||
|
msg = f"{saturday} is not a weekend of {project_id!r} before its test day"
|
||||||
|
raise ValueError(msg)
|
||||||
|
answers = dict(project.weekend_answers)
|
||||||
|
current = answers.get(saturday)
|
||||||
|
if current is trip:
|
||||||
|
return project
|
||||||
|
if current is True:
|
||||||
|
msg = "a trip answer may only get stricter: it stays True"
|
||||||
|
raise ValueError(msg)
|
||||||
|
if not trip and any(t.covers(saturday) for t in project.trips):
|
||||||
|
msg = f"an announced trip covers {saturday}: it cannot be a no-trip weekend"
|
||||||
|
raise ValueError(msg)
|
||||||
|
updated = replace(
|
||||||
|
project, weekend_answers=tuple(sorted({**answers, saturday: trip}.items()))
|
||||||
|
)
|
||||||
|
# Evidence first: a crash between the two leaves the question open
|
||||||
|
# (fail closed), never an answer with no time.
|
||||||
|
evidence.append(project_id, f"trip_ask:{saturday}", "answer", trip, stamp)
|
||||||
|
existing[i] = updated
|
||||||
|
_store.write_all(existing)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def _number(value: object) -> float:
|
||||||
|
if (
|
||||||
|
isinstance(value, bool)
|
||||||
|
or not isinstance(value, int | float)
|
||||||
|
or not math.isfinite(value)
|
||||||
|
or value < 0
|
||||||
|
):
|
||||||
|
msg = "a score must be a finite, non-negative number"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _count(value: object, label: str) -> int:
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||||
|
msg = f"{label} must be a non-negative integer"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(
|
||||||
|
project_id: str, kind: str, value: object, file: Path | None, stamp: datetime
|
||||||
|
) -> object:
|
||||||
|
"""The value to store for ``kind``; ValueError if it is not acceptable."""
|
||||||
|
if kind == "file":
|
||||||
|
if file is None or not file.is_file() or not file.stat().st_size:
|
||||||
|
msg = "a non-empty file is required"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return evidence.store_file(project_id, file, stamp)
|
||||||
|
if kind == "score":
|
||||||
|
return _number(value)
|
||||||
|
if kind == "slot":
|
||||||
|
moment = datetime.fromisoformat(str(value)) if isinstance(value, str) else None
|
||||||
|
if moment is None or moment.tzinfo is not None:
|
||||||
|
msg = "slot must be a naive local ISO datetime"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return moment.isoformat()
|
||||||
|
if kind == "phone_check":
|
||||||
|
if not isinstance(value, dict) or not isinstance(value.get("plan"), bool):
|
||||||
|
msg = "phone_check needs {plan: bool, due: int, seen_ms: int}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return {
|
||||||
|
"plan": value["plan"],
|
||||||
|
"due": _count(value.get("due"), "due"),
|
||||||
|
"seen_ms": _count(value.get("seen_ms"), "seen_ms"),
|
||||||
|
}
|
||||||
|
if kind == "slot_confirm" and value not in {True, None}:
|
||||||
|
msg = "slot_confirm is a tap: value True"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return True # tap, slot_confirm
|
||||||
|
|
||||||
|
|
||||||
|
def record_evidence( # noqa: PLR0913
|
||||||
|
project_id: str,
|
||||||
|
milestone: str,
|
||||||
|
kind: str,
|
||||||
|
value: object = None,
|
||||||
|
*,
|
||||||
|
file: Path | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> evidence.Evidence:
|
||||||
|
"""Append evidence for a milestone; ValueError if it does not fit.
|
||||||
|
|
||||||
|
Kinds: ``file`` (rules, copied from ``file``), ``tap`` (phone:*),
|
||||||
|
``score`` (mock), ``slot`` (ISO datetime) and ``slot_confirm`` (slot),
|
||||||
|
``phone_check`` ({plan, due, seen_ms}, milestone "phone", from tutor).
|
||||||
|
There is no way to delete a row.
|
||||||
|
"""
|
||||||
|
stamp = local_now(now)
|
||||||
|
_, project = find(_store.read_all(), project_id)
|
||||||
|
if milestone == _PHONE_REPORT:
|
||||||
|
accepted = {"phone_check"}
|
||||||
|
else:
|
||||||
|
known = {m.id for m in _milestones.milestones(project, stamp)}
|
||||||
|
accepted = _ACCEPTS.get(milestone.partition(":")[0], set())
|
||||||
|
if milestone not in known:
|
||||||
|
msg = f"{milestone!r} is not a milestone of {project_id!r}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
if kind not in accepted:
|
||||||
|
msg = f"{milestone!r} takes evidence kinds {sorted(accepted)}, not {kind!r}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return evidence.append(
|
||||||
|
project_id, milestone, kind, _clean(project_id, kind, value, file, stamp), stamp
|
||||||
|
)
|
||||||
@ -30,3 +30,18 @@ def activity_path() -> Path:
|
|||||||
def lock_path() -> Path:
|
def lock_path() -> Path:
|
||||||
"""The registry's writer lock."""
|
"""The registry's writer lock."""
|
||||||
return data_dir() / "projects.lock"
|
return data_dir() / "projects.lock"
|
||||||
|
|
||||||
|
|
||||||
|
def evidence_dir() -> Path:
|
||||||
|
"""Interview-prep evidence: one jsonl per project plus copied files."""
|
||||||
|
return data_dir() / "evidence"
|
||||||
|
|
||||||
|
|
||||||
|
def evidence_path(project_id: str) -> Path:
|
||||||
|
"""The append-only evidence rows of one project."""
|
||||||
|
return evidence_dir() / f"{project_id}.jsonl"
|
||||||
|
|
||||||
|
|
||||||
|
def evidence_files_dir(project_id: str) -> Path:
|
||||||
|
"""Where evidence files (screenshots) of one project are copied."""
|
||||||
|
return evidence_dir() / project_id
|
||||||
|
|||||||
61
focus_project/focus_project/_trips.py
Normal file
61
focus_project/focus_project/_trips.py
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Trips: days away from the PC. They can only grow (ratchet)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from datetime import date
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Trip:
|
||||||
|
"""An inclusive away range; ``entered_ms`` is when it was announced."""
|
||||||
|
|
||||||
|
start: date
|
||||||
|
end: date
|
||||||
|
entered_ms: int = 0
|
||||||
|
|
||||||
|
def covers(self, day: date) -> bool:
|
||||||
|
"""Whether ``day`` is inside the trip."""
|
||||||
|
return self.start <= day <= self.end
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
"""The projects.json row."""
|
||||||
|
return {
|
||||||
|
"start": self.start.isoformat(),
|
||||||
|
"end": self.end.isoformat(),
|
||||||
|
"entered_ms": self.entered_ms,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, row: dict[str, Any]) -> Trip:
|
||||||
|
"""Parse a trip row (errors surface as RegistryError upstream)."""
|
||||||
|
return cls(
|
||||||
|
start=date.fromisoformat(row["start"]),
|
||||||
|
end=date.fromisoformat(row["end"]),
|
||||||
|
entered_ms=int(row.get("entered_ms", 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def add(trips: tuple[Trip, ...], new: Trip) -> tuple[Trip, ...]:
|
||||||
|
"""``trips`` plus ``new``; ValueError if that would loosen an old trip.
|
||||||
|
|
||||||
|
A new range touching an old trip must cover it: it replaces the old one
|
||||||
|
(keeping its entry time when the start did not move earlier). Anything
|
||||||
|
that starts later or ends earlier than what is on file is refused.
|
||||||
|
"""
|
||||||
|
kept: list[Trip] = []
|
||||||
|
for old in trips:
|
||||||
|
if old.start > new.end or new.start > old.end:
|
||||||
|
kept.append(old)
|
||||||
|
elif new.start <= old.start and new.end >= old.end:
|
||||||
|
if new.start == old.start:
|
||||||
|
new = replace(new, entered_ms=old.entered_ms)
|
||||||
|
else:
|
||||||
|
msg = (
|
||||||
|
f"a trip may only get stricter: pass a range that covers "
|
||||||
|
f"{old.start}..{old.end}"
|
||||||
|
)
|
||||||
|
raise ValueError(msg)
|
||||||
|
return tuple(sorted([*kept, new], key=lambda t: t.start))
|
||||||
106
focus_project/focus_project/checks.py
Normal file
106
focus_project/focus_project/checks.py
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Pluggable check sources for the auto-checked milestones, and latching.
|
||||||
|
|
||||||
|
A source is ``fn(project, now) -> True | False | None``; ``None`` means "could
|
||||||
|
not check" (logged) and counts as not done. Only ``phone`` is auto-checked
|
||||||
|
(the calendar slot check was dropped, 2026-11-10: ``slot`` is two taps).
|
||||||
|
|
||||||
|
:func:`run_checks` is the only place sources run: status()/milestones() stay
|
||||||
|
cheap and read the latched ``check`` rows it appends (only on a change).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from focus_project import _milestones, _mutate, _store, evidence
|
||||||
|
from focus_project._model import Project
|
||||||
|
|
||||||
|
_LOG = logging.getLogger(__name__)
|
||||||
|
CheckSource = Callable[[Project, datetime], bool | None]
|
||||||
|
DAY_MS = 24 * 3600 * 1000
|
||||||
|
# A phone check starts 24 h before the milestone is due.
|
||||||
|
WINDOW = timedelta(hours=24)
|
||||||
|
|
||||||
|
|
||||||
|
def _report_ok(value: object, report_ms: int) -> bool:
|
||||||
|
"""A plan exists, cards are due, the phone was seen <= 24 h before the report."""
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return bool(
|
||||||
|
value["plan"] is True
|
||||||
|
and int(value["due"]) > 0
|
||||||
|
and report_ms - int(value["seen_ms"]) <= DAY_MS
|
||||||
|
)
|
||||||
|
except KeyError, TypeError, ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def phone_source(project: Project, now: datetime) -> bool | None:
|
||||||
|
"""Read tutor's latest ``phone_check`` report (<= 24 h old).
|
||||||
|
|
||||||
|
No report, a stale one or one stamped ahead of the clock = ``None``
|
||||||
|
(the worker is not reporting). A fresh one passes only if the plan
|
||||||
|
exists, cards are due and the phone was seen within 24 h of the report.
|
||||||
|
"""
|
||||||
|
report = evidence.last(evidence.read(project.id), "phone", "phone_check")
|
||||||
|
if report is None:
|
||||||
|
_LOG.warning("phone check: no tutor report for %s", project.id)
|
||||||
|
return None
|
||||||
|
if not 0 <= evidence.to_ms(now) - report.ms <= DAY_MS:
|
||||||
|
_LOG.warning("phone check: tutor report for %s is stale or ahead", project.id)
|
||||||
|
return None
|
||||||
|
return _report_ok(report.value, report.ms)
|
||||||
|
|
||||||
|
|
||||||
|
SOURCES: dict[str, CheckSource] = {"phone": phone_source}
|
||||||
|
|
||||||
|
|
||||||
|
def register_source(name: str, source: CheckSource) -> None:
|
||||||
|
"""Replace the callable behind an auto check (``phone``)."""
|
||||||
|
if name not in SOURCES:
|
||||||
|
msg = f"unknown check source {name!r}; known: {sorted(SOURCES)}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
SOURCES[name] = source
|
||||||
|
|
||||||
|
|
||||||
|
def _run(project: Project, now: datetime) -> bool | None:
|
||||||
|
try:
|
||||||
|
return SOURCES["phone"](project, now)
|
||||||
|
except Exception as exc: # noqa: BLE001 (pluggable code: any failure = unknown)
|
||||||
|
_LOG.warning("phone check source failed: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def run_checks(
|
||||||
|
project_id: str, *, now: datetime | None = None
|
||||||
|
) -> dict[str, bool | None]:
|
||||||
|
"""Run the auto checks of open phone milestones; latch what changed.
|
||||||
|
|
||||||
|
Returns milestone id -> verdict for the ones checked (inside their
|
||||||
|
24 h window, not yet done). KeyError for an unknown project; a focus
|
||||||
|
project has nothing to check.
|
||||||
|
"""
|
||||||
|
stamp = _mutate.local_now(now)
|
||||||
|
project = next((p for p in _store.read_all() if p.id == project_id), None)
|
||||||
|
if project is None:
|
||||||
|
raise KeyError(project_id)
|
||||||
|
open_ids = [
|
||||||
|
m.id
|
||||||
|
for m in _milestones.milestones(project, stamp)
|
||||||
|
if m.id.startswith("phone:")
|
||||||
|
and m.state not in {"done", "late_done"}
|
||||||
|
and stamp >= m.due - WINDOW
|
||||||
|
]
|
||||||
|
if not open_ids:
|
||||||
|
return {}
|
||||||
|
verdict = _run(project, stamp)
|
||||||
|
rows = evidence.read(project_id)
|
||||||
|
for mid in open_ids:
|
||||||
|
_, latest, seen = evidence.check_summary(rows, mid)
|
||||||
|
if verdict != latest and not (verdict is None and not seen):
|
||||||
|
evidence.append(project_id, mid, "check", verdict, stamp)
|
||||||
|
return dict.fromkeys(open_ids, verdict)
|
||||||
159
focus_project/focus_project/evidence.py
Normal file
159
focus_project/focus_project/evidence.py
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""The interview evidence store: append-only rows plus copied files.
|
||||||
|
|
||||||
|
One jsonl per project (``{ms, milestone, kind, value}``) under the
|
||||||
|
:mod:`focus_project._paths` seam. There is deliberately no delete or rewrite
|
||||||
|
function: a milestone once evidenced stays evidenced.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import itertools
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from focus_project import _clock, _paths
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Evidence:
|
||||||
|
"""One evidence row."""
|
||||||
|
|
||||||
|
ms: int
|
||||||
|
milestone: str
|
||||||
|
kind: str
|
||||||
|
value: object
|
||||||
|
|
||||||
|
@property
|
||||||
|
def at(self) -> datetime:
|
||||||
|
"""When it was recorded, as a naive local datetime."""
|
||||||
|
return from_ms(self.ms)
|
||||||
|
|
||||||
|
|
||||||
|
def to_ms(moment: datetime) -> int:
|
||||||
|
"""Unix milliseconds of a naive local datetime."""
|
||||||
|
return int(moment.timestamp() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def from_ms(ms: int) -> datetime:
|
||||||
|
"""Naive local datetime of unix milliseconds."""
|
||||||
|
return datetime.fromtimestamp(ms / 1000, tz=UTC).astimezone().replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def clock_now() -> datetime:
|
||||||
|
"""The clock seam (:mod:`focus_project._clock`) as a naive local datetime."""
|
||||||
|
return from_ms(int(_clock.now() * 1000))
|
||||||
|
|
||||||
|
|
||||||
|
def append(
|
||||||
|
project_id: str, milestone: str, kind: str, value: object, now: datetime
|
||||||
|
) -> Evidence:
|
||||||
|
"""Append one row (fsynced) and return it."""
|
||||||
|
row = Evidence(to_ms(now), milestone, kind, value)
|
||||||
|
path = _paths.evidence_path(project_id)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
line = json.dumps(
|
||||||
|
{"ms": row.ms, "milestone": milestone, "kind": kind, "value": value}
|
||||||
|
)
|
||||||
|
with path.open("a", encoding="utf-8") as handle:
|
||||||
|
handle.write(line + "\n")
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def store_file(project_id: str, source: Path, now: datetime) -> str:
|
||||||
|
"""Copy ``source`` beside the rows under a name that never collides."""
|
||||||
|
target = _paths.evidence_files_dir(project_id)
|
||||||
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
|
safe = re.sub(r"[^A-Za-z0-9._-]", "_", source.name)[:80]
|
||||||
|
stamp = to_ms(now)
|
||||||
|
for attempt in itertools.count():
|
||||||
|
name = f"{stamp}-{safe}" if not attempt else f"{stamp}-{attempt}-{safe}"
|
||||||
|
try:
|
||||||
|
with (target / name).open("xb") as out, source.open("rb") as src:
|
||||||
|
shutil.copyfileobj(src, out)
|
||||||
|
out.flush()
|
||||||
|
os.fsync(out.fileno())
|
||||||
|
except FileExistsError:
|
||||||
|
continue
|
||||||
|
return name
|
||||||
|
raise AssertionError # pragma: no cover (itertools.count never ends)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse(line: str) -> Evidence | None:
|
||||||
|
try:
|
||||||
|
row = json.loads(line)
|
||||||
|
if not isinstance(row, dict) or isinstance(row["ms"], bool):
|
||||||
|
return None
|
||||||
|
return Evidence(
|
||||||
|
int(row["ms"]), str(row["milestone"]), str(row["kind"]), row["value"]
|
||||||
|
)
|
||||||
|
except ValueError, KeyError, TypeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read(project_id: str) -> list[Evidence]:
|
||||||
|
"""Every row in file order; a missing file is empty, bad lines are skipped.
|
||||||
|
|
||||||
|
A torn last line (crash mid-append) must not wipe out the whole history,
|
||||||
|
so malformed lines are logged and ignored; an unreadable file raises.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
path = _paths.evidence_path(project_id)
|
||||||
|
text = path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
except FileNotFoundError:
|
||||||
|
return []
|
||||||
|
rows: list[Evidence] = []
|
||||||
|
for number, line in enumerate(text.splitlines(), start=1):
|
||||||
|
parsed = _parse(line)
|
||||||
|
if parsed is None:
|
||||||
|
_LOG.warning(
|
||||||
|
"evidence %s line %d: skipping malformed row", project_id, number
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
rows.append(parsed)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def first(
|
||||||
|
rows: list[Evidence], milestone: str, kind: str, since_ms: int = 0
|
||||||
|
) -> Evidence | None:
|
||||||
|
"""The earliest matching row at or after ``since_ms``."""
|
||||||
|
return next(
|
||||||
|
(
|
||||||
|
r
|
||||||
|
for r in rows
|
||||||
|
if (r.milestone, r.kind) == (milestone, kind) and r.ms >= since_ms
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def last(rows: list[Evidence], milestone: str, kind: str) -> Evidence | None:
|
||||||
|
"""The latest matching row."""
|
||||||
|
return next(
|
||||||
|
(r for r in reversed(rows) if (r.milestone, r.kind) == (milestone, kind)),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_summary(
|
||||||
|
rows: list[Evidence], milestone: str
|
||||||
|
) -> tuple[int | None, bool | None, bool]:
|
||||||
|
"""``(first passing ms, latest verdict, any check row)`` for a milestone."""
|
||||||
|
checks = [r for r in rows if (r.milestone, r.kind) == (milestone, "check")]
|
||||||
|
passed = next((r.ms for r in checks if r.value is True), None)
|
||||||
|
latest = checks[-1].value if checks else None
|
||||||
|
return passed, (latest if isinstance(latest, bool) else None), bool(checks)
|
||||||
@ -8,6 +8,10 @@ minutes (floor). Time not yet lived is never credited: every interval is
|
|||||||
clipped at "now", so an event stamped ahead of the clock (a fast phone clock)
|
clipped at "now", so an event stamped ahead of the clock (a fast phone clock)
|
||||||
earns only once the real clock reaches it. An unreadable registry reads as 0
|
earns only once the real clock reaches it. An unreadable registry reads as 0
|
||||||
(logged): fail closed.
|
(logged): fail closed.
|
||||||
|
|
||||||
|
Unbacked minutes (0.10.0): timer time the anti-cheat gate refused
|
||||||
|
(:mod:`focus_project.sources.activity`) and no other source covers, on the
|
||||||
|
local day up to "now"; carry and the cap play no part. Shown, never paid.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@ -15,9 +19,10 @@ from __future__ import annotations
|
|||||||
from datetime import date # noqa: TC003
|
from datetime import date # noqa: TC003
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from focus_project import _api, _clock, carry
|
from focus_project import _api, _clock, carry, sources
|
||||||
from focus_project._errors import RegistryError
|
from focus_project._errors import RegistryError
|
||||||
from focus_project._own import union_seconds
|
from focus_project._own import union_seconds
|
||||||
|
from focus_project.sources import activity
|
||||||
|
|
||||||
_LOG = logging.getLogger(__name__)
|
_LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -38,4 +43,26 @@ def minutes_for(day: date, now: float | None = None) -> int:
|
|||||||
return carry.balance(day, moment).counted(project.daily_on(day))
|
return carry.balance(day, moment).counted(project.daily_on(day))
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["minutes_for", "union_seconds"]
|
def unbacked_minutes_for(day: date, now: float | None = None) -> int:
|
||||||
|
"""Whole minutes of timer time on ``day`` that the gate did not count.
|
||||||
|
|
||||||
|
0 with no project, before :data:`~focus_project.sources.activity.BACKING_FROM`,
|
||||||
|
or with an unreadable registry (logged).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
project = _api.active(day)
|
||||||
|
if project is None:
|
||||||
|
return 0
|
||||||
|
lo, hi = sources.day_bounds(day)
|
||||||
|
bounds = (lo, min(hi, _clock.now() if now is None else now))
|
||||||
|
unbacked = sources.clip(activity.split_timer(day, project)[1], bounds)
|
||||||
|
counted = sources.clip(sources.collect(day, project), bounds)
|
||||||
|
except (RegistryError, OSError) as exc:
|
||||||
|
_LOG.warning("focus_project registry unreadable, 0 unbacked: %s", exc)
|
||||||
|
return 0
|
||||||
|
# |unbacked minus counted| = |unbacked union counted| - |counted|.
|
||||||
|
gap = union_seconds(unbacked + counted) - union_seconds(counted)
|
||||||
|
return int(gap // 60)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["minutes_for", "unbacked_minutes_for", "union_seconds"]
|
||||||
|
|||||||
@ -13,23 +13,36 @@ heartbeat covering the five minutes before it. Only events recorded for
|
|||||||
the open segment, like a ``checkin``: within :data:`MAX_GAP_S` of the last
|
the open segment, like a ``checkin``: within :data:`MAX_GAP_S` of the last
|
||||||
beat it extends the segment; later it ends the segment at that last beat. It
|
beat it extends the segment; later it ends the segment at that last beat. It
|
||||||
never opens a segment. Other heartbeat sources never chain the timer.
|
never opens a segment. Other heartbeat sources never chain the timer.
|
||||||
|
|
||||||
|
0.10.0 anti-cheat (days from :data:`BACKING_FROM`): a timer span earns only
|
||||||
|
if real activity of the same project backs it, whole span or nothing. A
|
||||||
|
backing event is a ``tutor``/``sql_drills``/``leetcode`` heartbeat or an
|
||||||
|
accepted LeetCode submission (leetcode projects only); ``browser`` never
|
||||||
|
backs. A span ``(a, b)`` is backed if a backing event lies in
|
||||||
|
``[a - 300, b]``, or between two consecutive backing events at most
|
||||||
|
:data:`BRIDGE_S` apart. Check-ins alone earn 0. Earlier days keep the old
|
||||||
|
rule (every span counts), so paid days never change.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
import importlib
|
||||||
|
from itertools import pairwise
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any, Final
|
||||||
|
|
||||||
from focus_project._paths import activity_path
|
from focus_project._paths import activity_path
|
||||||
from focus_project.sources import Interval, heartbeat_interval
|
from focus_project.sources import HEARTBEAT_WINDOW_S, Interval, heartbeat_interval
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
from focus_project._model import Project
|
from focus_project._model import Project
|
||||||
|
|
||||||
MAX_GAP_S = 600.0
|
MAX_GAP_S = 600.0
|
||||||
|
BACKING_FROM: Final = date(2026, 11, 8)
|
||||||
|
BACKING_SOURCES: Final = frozenset({"tutor", "sql_drills", "leetcode"})
|
||||||
|
BRIDGE_S: Final = 1200.0
|
||||||
_LOG = logging.getLogger(__name__)
|
_LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@ -84,12 +97,68 @@ def _is_beat(event: dict[str, Any]) -> bool:
|
|||||||
return event.get("source") == "tutor" and event.get("kind") == "heartbeat"
|
return event.get("source") == "tutor" and event.get("kind") == "heartbeat"
|
||||||
|
|
||||||
|
|
||||||
def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001
|
def sessions(project_id: str) -> list[Interval]:
|
||||||
"""Timer segments plus heartbeats of ``project`` (the registry clips)."""
|
"""Contiguous focus sessions of ``project_id``, time-ordered, as (start, end).
|
||||||
events = read_events(project.id)
|
|
||||||
|
Built from the same timer/tutor-beat segments (and so the same
|
||||||
|
:data:`MAX_GAP_S` rule) that credit minutes; spans that touch are one
|
||||||
|
session, which ends at its last beat.
|
||||||
|
"""
|
||||||
|
events = read_events(project_id)
|
||||||
timer = [e for e in events if e.get("source") == "timer" or _is_beat(e)]
|
timer = [e for e in events if e.get("source") == "timer" or _is_beat(e)]
|
||||||
|
merged: list[Interval] = []
|
||||||
|
for lo, hi in timer_intervals(timer):
|
||||||
|
if merged and lo <= merged[-1][1]:
|
||||||
|
merged[-1] = (merged[-1][0], max(hi, merged[-1][1]))
|
||||||
|
else:
|
||||||
|
merged.append((lo, hi))
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def backing_times(events: list[dict[str, Any]], project: Project) -> list[float]:
|
||||||
|
"""Sorted times of the events that may back a timer span.
|
||||||
|
|
||||||
|
The LeetCode ledger is read in its own guard: if it fails, only the
|
||||||
|
submissions are lost, never the heartbeats (fail closed, narrow).
|
||||||
|
"""
|
||||||
|
times = [float(e["ts"]) for e in events if e.get("source") in BACKING_SOURCES]
|
||||||
|
try:
|
||||||
|
# Imported lazily: it pulls in earned_time, and the timer must keep
|
||||||
|
# working (heartbeat backing only) where that is not installed.
|
||||||
|
leetcode = importlib.import_module("focus_project.sources.leetcode")
|
||||||
|
times += leetcode.submission_times(project)
|
||||||
|
except Exception: # noqa: BLE001 (lose the submissions, keep the beats)
|
||||||
|
_LOG.exception("leetcode backing unavailable, heartbeats only")
|
||||||
|
return sorted(times)
|
||||||
|
|
||||||
|
|
||||||
|
def is_backed(span: Interval, times: list[float]) -> bool:
|
||||||
|
"""Whether a backing event lies in or just before ``span``, or bridges it."""
|
||||||
|
lo, hi = span
|
||||||
|
if any(lo - HEARTBEAT_WINDOW_S <= t <= hi for t in times):
|
||||||
|
return True
|
||||||
|
return any(a <= lo and hi <= b and b - a <= BRIDGE_S for a, b in pairwise(times))
|
||||||
|
|
||||||
|
|
||||||
|
def split_timer(
|
||||||
|
day: date, project: Project, events: list[dict[str, Any]] | None = None
|
||||||
|
) -> tuple[list[Interval], list[Interval]]:
|
||||||
|
"""``(backed, unbacked)`` timer spans of ``project``, judged for ``day``."""
|
||||||
|
found = read_events(project.id) if events is None else events
|
||||||
|
chain = [e for e in found if e.get("source") == "timer" or _is_beat(e)]
|
||||||
|
spans = timer_intervals(chain)
|
||||||
|
if day < BACKING_FROM:
|
||||||
|
return spans, []
|
||||||
|
times = backing_times(found, project)
|
||||||
|
backed = [s for s in spans if is_backed(s, times)]
|
||||||
|
return backed, [s for s in spans if s not in backed]
|
||||||
|
|
||||||
|
|
||||||
|
def intervals(day: date, project: Project) -> list[Interval]:
|
||||||
|
"""Backed timer segments plus heartbeats of ``project`` (the registry clips)."""
|
||||||
|
events = read_events(project.id)
|
||||||
beats = [e for e in events if e.get("source") != "timer"]
|
beats = [e for e in events if e.get("source") != "timer"]
|
||||||
return [
|
return [
|
||||||
*timer_intervals(timer),
|
*split_timer(day, project, events)[0],
|
||||||
*(heartbeat_interval(float(e["ts"])) for e in beats),
|
*(heartbeat_interval(float(e["ts"])) for e in beats),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -55,8 +55,8 @@ def _submitted_at(row: object, key: bytes) -> float | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001
|
def submission_times(project: Project) -> list[float]:
|
||||||
"""One heartbeat per accepted submission (the registry clips to the day)."""
|
"""Times of verified accepted submissions; [] for a non-leetcode project."""
|
||||||
if not any("leetcode" in pattern.lower() for pattern in project.site_patterns):
|
if not any("leetcode" in pattern.lower() for pattern in project.site_patterns):
|
||||||
return []
|
return []
|
||||||
key = ledger.read_key()
|
key = ledger.read_key()
|
||||||
@ -64,4 +64,9 @@ def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001
|
|||||||
_LOG.warning("leetcode source: no signing key, contributes 0")
|
_LOG.warning("leetcode source: no signing key, contributes 0")
|
||||||
return []
|
return []
|
||||||
stamps = (_submitted_at(row, key) for row in ledger.read_rows(ledger_file()))
|
stamps = (_submitted_at(row, key) for row in ledger.read_rows(ledger_file()))
|
||||||
return [heartbeat_interval(ts) for ts in stamps if ts is not None]
|
return [ts for ts in stamps if ts is not None]
|
||||||
|
|
||||||
|
|
||||||
|
def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001
|
||||||
|
"""One heartbeat per accepted submission (the registry clips to the day)."""
|
||||||
|
return [heartbeat_interval(ts) for ts in submission_times(project)]
|
||||||
|
|||||||
@ -3,10 +3,32 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date # noqa: TC003
|
import logging
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from focus_project import _api, carry
|
from focus_project import _api, _milestones, carry, evidence, minutes
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from focus_project._model import Project
|
||||||
|
|
||||||
|
_LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _milestone_block(project: Project) -> tuple[list[dict[str, Any]], bool]:
|
||||||
|
"""Milestone rows and whether any is red; unreadable evidence shows none.
|
||||||
|
|
||||||
|
Display path, so it fails open (logged): the lock path, ``owed_lock``,
|
||||||
|
has its own handling.
|
||||||
|
"""
|
||||||
|
now = evidence.clock_now()
|
||||||
|
try:
|
||||||
|
items = _milestones.milestones(project, now)
|
||||||
|
except OSError as exc:
|
||||||
|
_LOG.warning("interview evidence unreadable, no milestones shown: %s", exc)
|
||||||
|
return [], False
|
||||||
|
return [m.to_dict() for m in items], any(m.red for m in items)
|
||||||
|
|
||||||
|
|
||||||
def status(day: date) -> dict[str, Any]:
|
def status(day: date) -> dict[str, Any]:
|
||||||
@ -14,7 +36,8 @@ def status(day: date) -> dict[str, Any]:
|
|||||||
|
|
||||||
``minutes`` is what was really worked plus what the day took from the
|
``minutes`` is what was really worked plus what the day took from the
|
||||||
bank, so it may exceed ``daily_minutes`` (121/120); ``counted_minutes``
|
bank, so it may exceed ``daily_minutes`` (121/120); ``counted_minutes``
|
||||||
is the capped figure that pays.
|
is the capped figure that pays. ``unbacked_minutes`` is timer time the
|
||||||
|
anti-cheat gate refused (shown as "not counted", never paid).
|
||||||
"""
|
"""
|
||||||
project = _api.active(day)
|
project = _api.active(day)
|
||||||
if project is None:
|
if project is None:
|
||||||
@ -30,9 +53,13 @@ def status(day: date) -> dict[str, Any]:
|
|||||||
"unlock_minutes": None,
|
"unlock_minutes": None,
|
||||||
"ends": None,
|
"ends": None,
|
||||||
"replaces": [],
|
"replaces": [],
|
||||||
|
"unbacked_minutes": 0,
|
||||||
|
"milestones": [],
|
||||||
|
"missed": False,
|
||||||
}
|
}
|
||||||
daily = project.daily_on(day)
|
daily = project.daily_on(day)
|
||||||
bank = carry.balance(day)
|
bank = carry.balance(day)
|
||||||
|
rows, missed = _milestone_block(project)
|
||||||
return {
|
return {
|
||||||
"project": project.id,
|
"project": project.id,
|
||||||
"name": project.name,
|
"name": project.name,
|
||||||
@ -47,4 +74,8 @@ def status(day: date) -> dict[str, Any]:
|
|||||||
"unlock_minutes": project.unlock_on(day),
|
"unlock_minutes": project.unlock_on(day),
|
||||||
"ends": project.last_day.isoformat(),
|
"ends": project.last_day.isoformat(),
|
||||||
"replaces": list(project.replaces_on(day)),
|
"replaces": list(project.replaces_on(day)),
|
||||||
|
"unbacked_minutes": minutes.unbacked_minutes_for(day),
|
||||||
|
# Interview projects: [{id, due ISO, state}] and whether any is red.
|
||||||
|
"milestones": rows,
|
||||||
|
"missed": missed,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,7 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from focus_project import _clock
|
from focus_project import _clock, _paths, checks
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -40,3 +40,16 @@ def _far_future_clock(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
def data(tmp_path: Path) -> Path:
|
def data(tmp_path: Path) -> Path:
|
||||||
"""The redirected data directory (may not exist yet)."""
|
"""The redirected data directory (may not exist yet)."""
|
||||||
return tmp_path / "data"
|
return tmp_path / "data"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _interview_isolation(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, _redirect_state: None
|
||||||
|
) -> None:
|
||||||
|
"""Evidence lives under the redirected data dir; sources are a private copy.
|
||||||
|
|
||||||
|
Fails closed (R3): if the path seam ever resolves outside ``tmp_path`` the
|
||||||
|
test errors before it can write a production evidence row.
|
||||||
|
"""
|
||||||
|
assert _paths.evidence_dir().is_relative_to(tmp_path)
|
||||||
|
monkeypatch.setattr(checks, "SOURCES", dict(checks.SOURCES))
|
||||||
|
|||||||
65
focus_project/focus_project/tests/helpers.py
Normal file
65
focus_project/focus_project/tests/helpers.py
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Shared fixtures-as-functions for the interview-prep tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime, time, timedelta
|
||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import focus_project as fp
|
||||||
|
from focus_project._paths import activity_path
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
START = date(2026, 11, 10) # Tuesday: D1
|
||||||
|
TEST_DAY = date(2026, 11, 27) # Friday: T
|
||||||
|
TEST_AT = datetime.combine(TEST_DAY, time(10, 0))
|
||||||
|
SAT1 = date(2026, 11, 14)
|
||||||
|
SAT2 = date(2026, 11, 21)
|
||||||
|
|
||||||
|
|
||||||
|
def at(day: int, hour: int = 0, minute: int = 0) -> datetime:
|
||||||
|
"""A November 2026 local datetime."""
|
||||||
|
return datetime.combine(date(2026, 11, day), time(hour, minute))
|
||||||
|
|
||||||
|
|
||||||
|
def make(pid: str = "iv", /, **kw: object) -> fp.Project:
|
||||||
|
"""Create an interview project D1 = Nov 10, T = Nov 27."""
|
||||||
|
args: dict[str, object] = {
|
||||||
|
"name": "Interview",
|
||||||
|
"start": START,
|
||||||
|
"end": TEST_DAY,
|
||||||
|
"replaces": ["leetcode", "reading", "tutor"],
|
||||||
|
"daily_minutes": 120,
|
||||||
|
"unlock_minutes": 30,
|
||||||
|
"kind": "interview",
|
||||||
|
"test_at": TEST_AT,
|
||||||
|
"invite_url": "https://app.codility.com/c/run/abc",
|
||||||
|
}
|
||||||
|
args.update(kw)
|
||||||
|
return fp.create_project(pid, **args) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
def shot(tmp_path: Path, name: str = "rules.png") -> Path:
|
||||||
|
"""A non-empty screenshot stand-in."""
|
||||||
|
path = tmp_path / name
|
||||||
|
path.write_bytes(b"\x89PNG rules")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def states(project: fp.Project, now: datetime) -> dict[str, str]:
|
||||||
|
"""Milestone id -> state."""
|
||||||
|
return {m.id: m.state for m in fp.milestones(project, now)}
|
||||||
|
|
||||||
|
|
||||||
|
def session(start: datetime, minutes: int, *, step: int = 5, pid: str = "iv") -> None:
|
||||||
|
"""A timer session start -> check-in every ``step`` min -> stop."""
|
||||||
|
activity_path().parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with activity_path().open("a", encoding="utf-8") as handle:
|
||||||
|
for offset in range(0, minutes + 1, step):
|
||||||
|
kind = "start" if offset == 0 else "checkin"
|
||||||
|
when = start + timedelta(minutes=offset)
|
||||||
|
row = {"ts": when.timestamp(), "source": "timer", "kind": kind}
|
||||||
|
handle.write(json.dumps({**row, "project": pid, "dev": "pc"}) + "\n")
|
||||||
@ -137,6 +137,9 @@ def test_status_active_reports_the_worked_minutes() -> None:
|
|||||||
"unlock_minutes": 30,
|
"unlock_minutes": 30,
|
||||||
"ends": "2026-11-05",
|
"ends": "2026-11-05",
|
||||||
"replaces": ["leetcode"],
|
"replaces": ["leetcode"],
|
||||||
|
"unbacked_minutes": 0,
|
||||||
|
"milestones": [],
|
||||||
|
"missed": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -158,6 +161,9 @@ def test_status_inactive() -> None:
|
|||||||
"unlock_minutes",
|
"unlock_minutes",
|
||||||
"ends",
|
"ends",
|
||||||
"replaces",
|
"replaces",
|
||||||
|
"unbacked_minutes",
|
||||||
|
"milestones",
|
||||||
|
"missed",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
181
focus_project/focus_project/tests/test_backing.py
Normal file
181
focus_project/focus_project/tests/test_backing.py
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""0.10.0: a timer span earns only if a same-project beat backs it."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import focus_project as fp
|
||||||
|
from focus_project import _api, minutes
|
||||||
|
from focus_project.sources import activity
|
||||||
|
from focus_project.tests import test_minutes as tm
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
DAY = date(2026, 11, 8) # BACKING_FROM
|
||||||
|
|
||||||
|
|
||||||
|
def at(hour: int, minute: int = 0) -> float:
|
||||||
|
return tm.at(hour, minute, day=DAY)
|
||||||
|
|
||||||
|
|
||||||
|
def proj() -> None:
|
||||||
|
fp.create_project(
|
||||||
|
"hr",
|
||||||
|
name="HR prep",
|
||||||
|
start=DAY,
|
||||||
|
end=date(2026, 11, 12),
|
||||||
|
replaces=["leetcode"],
|
||||||
|
daily_minutes=120,
|
||||||
|
unlock_minutes=30,
|
||||||
|
site_patterns=["leetcode.com"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def rows(*items: tuple[str, str, float]) -> None:
|
||||||
|
tm.events(*items)
|
||||||
|
|
||||||
|
|
||||||
|
def timer_only() -> None:
|
||||||
|
rows(
|
||||||
|
("timer", "start", at(9)),
|
||||||
|
("timer", "checkin", at(9, 5)),
|
||||||
|
("timer", "stop", at(9, 10)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_timer_only_stretch_pays_zero_and_shows_unbacked() -> None:
|
||||||
|
proj()
|
||||||
|
timer_only()
|
||||||
|
assert minutes.minutes_for(DAY) == 0
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 10
|
||||||
|
assert fp.status(DAY)["unbacked_minutes"] == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_beat_in_the_span_backs_all_of_it() -> None:
|
||||||
|
proj()
|
||||||
|
timer_only()
|
||||||
|
rows(("sql_drills", "heartbeat", at(9, 5)))
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||||
|
assert minutes.minutes_for(DAY) == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_beat_backs_only_the_spans_it_touches() -> None:
|
||||||
|
proj()
|
||||||
|
timer_only()
|
||||||
|
rows(("sql_drills", "heartbeat", at(9, 7)))
|
||||||
|
# 9:00-9:05 stays unbacked, but the beat itself pays 9:02-9:07.
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_beat_at_the_end_of_the_span_backs_it() -> None:
|
||||||
|
proj()
|
||||||
|
rows(
|
||||||
|
("timer", "start", at(9)),
|
||||||
|
("timer", "stop", at(9, 10)),
|
||||||
|
("sql_drills", "heartbeat", at(9, 10)),
|
||||||
|
)
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||||
|
assert minutes.minutes_for(DAY) == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_beat_just_before_the_span_backs_it() -> None:
|
||||||
|
proj()
|
||||||
|
rows(
|
||||||
|
("sql_drills", "heartbeat", at(8, 56)), # within 300 s before 9:00
|
||||||
|
("timer", "start", at(9)),
|
||||||
|
("timer", "stop", at(9, 5)),
|
||||||
|
)
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_late_beat_does_not_back_it() -> None:
|
||||||
|
proj()
|
||||||
|
timer_only()
|
||||||
|
rows(("sql_drills", "heartbeat", at(9, 40)))
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_beats_up_to_twenty_minutes_apart_bridge_the_span() -> None:
|
||||||
|
proj()
|
||||||
|
rows(
|
||||||
|
("tutor", "heartbeat", at(8, 50)),
|
||||||
|
("timer", "start", at(9)),
|
||||||
|
("timer", "checkin", at(9, 5)),
|
||||||
|
("timer", "stop", at(9, 8)),
|
||||||
|
("tutor", "heartbeat", at(9, 10)),
|
||||||
|
)
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_beats_over_twenty_minutes_apart_do_not_bridge() -> None:
|
||||||
|
proj()
|
||||||
|
rows(
|
||||||
|
("tutor", "heartbeat", at(8, 30)),
|
||||||
|
("timer", "start", at(9)),
|
||||||
|
("timer", "stop", at(9, 8)),
|
||||||
|
("tutor", "heartbeat", at(9, 40)),
|
||||||
|
)
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 8
|
||||||
|
|
||||||
|
|
||||||
|
def test_browser_never_backs() -> None:
|
||||||
|
proj()
|
||||||
|
timer_only()
|
||||||
|
rows(("browser", "heartbeat", at(9, 7)))
|
||||||
|
# The beat pays its own 9:02-9:07 but backs no timer span.
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 5
|
||||||
|
assert minutes.minutes_for(DAY) == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_days_before_backing_from_keep_the_old_rule() -> None:
|
||||||
|
fp.create_project(
|
||||||
|
"hr",
|
||||||
|
name="Old",
|
||||||
|
start=date(2026, 11, 2),
|
||||||
|
end=date(2026, 11, 5),
|
||||||
|
replaces=["leetcode"],
|
||||||
|
daily_minutes=120,
|
||||||
|
unlock_minutes=30,
|
||||||
|
site_patterns=["leetcode.com"],
|
||||||
|
)
|
||||||
|
old = date(2026, 11, 2)
|
||||||
|
rows(("timer", "start", tm.at(9)), ("timer", "stop", tm.at(9, 10)))
|
||||||
|
assert minutes.minutes_for(old) == 10
|
||||||
|
assert minutes.unbacked_minutes_for(old) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_project_is_zero_unbacked() -> None:
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||||
|
assert fp.status(DAY)["unbacked_minutes"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbacked_is_clipped_at_now() -> None:
|
||||||
|
proj()
|
||||||
|
timer_only()
|
||||||
|
assert minutes.unbacked_minutes_for(DAY, now=at(9, 4)) == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_timer_returns_backed_and_unbacked() -> None:
|
||||||
|
proj()
|
||||||
|
timer_only()
|
||||||
|
project = _api.active(DAY)
|
||||||
|
assert project is not None
|
||||||
|
backed, unbacked = activity.split_timer(DAY, project)
|
||||||
|
assert backed == []
|
||||||
|
assert unbacked == [(at(9), at(9, 5)), (at(9, 5), at(9, 10))]
|
||||||
|
|
||||||
|
|
||||||
|
def test_backing_from_is_exported() -> None:
|
||||||
|
assert date(2026, 11, 8) == fp.BACKING_FROM
|
||||||
|
assert fp.unbacked_minutes_for is minutes.unbacked_minutes_for
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_error_reads_zero(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
def boom(_day: date) -> None:
|
||||||
|
raise OSError
|
||||||
|
|
||||||
|
monkeypatch.setattr("focus_project._api.active", boom)
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||||
131
focus_project/focus_project/tests/test_backing_edges.py
Normal file
131
focus_project/focus_project/tests/test_backing_edges.py
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""More 0.10.0 backing cases: sources, projects, the cutover midnight, chains.
|
||||||
|
|
||||||
|
The core rule lives in ``test_backing.py``; these pin the edges the spec
|
||||||
|
named (C5-C7) with leetcode-ledger and other-project fixtures.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import focus_project as fp
|
||||||
|
from focus_project import minutes
|
||||||
|
from focus_project._paths import activity_path
|
||||||
|
from focus_project.tests.test_leetcode_source import ac_row, write
|
||||||
|
from focus_project.tests.test_minutes import at
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DAY = date(2026, 11, 9) # after the cutover
|
||||||
|
PRE = date(2026, 11, 7) # before it
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
"""Nothing may fall back to the real home directory."""
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
||||||
|
|
||||||
|
|
||||||
|
def make(pid: str = "hr", patterns: tuple[str, ...] = ("leetcode.com",)) -> None:
|
||||||
|
fp.create_project(
|
||||||
|
pid,
|
||||||
|
name=pid,
|
||||||
|
start=date(2026, 11, 6),
|
||||||
|
end=date(2026, 11, 12),
|
||||||
|
replaces=["leetcode"],
|
||||||
|
daily_minutes=240,
|
||||||
|
unlock_minutes=30,
|
||||||
|
site_patterns=list(patterns),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def rows(*items: tuple[str, str, float] | tuple[str, str, float, str]) -> None:
|
||||||
|
"""Append ``(source, kind, ts[, project])`` events (project default hr)."""
|
||||||
|
activity_path().parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with activity_path().open("a", encoding="utf-8") as handle:
|
||||||
|
for item in items:
|
||||||
|
source, kind, ts = item[:3]
|
||||||
|
pid = item[3] if len(item) == 4 else "hr"
|
||||||
|
row = {"ts": ts, "source": source, "kind": kind, "project": pid}
|
||||||
|
handle.write(json.dumps(row) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def timer(day: date, *marks: tuple[int, int]) -> None:
|
||||||
|
"""A segment: start at the first mark, check-ins, stop at the last."""
|
||||||
|
kinds = ["start", *["checkin"] * (len(marks) - 2), "stop"]
|
||||||
|
rows(
|
||||||
|
*(
|
||||||
|
("timer", k, at(h, m, day=day))
|
||||||
|
for k, (h, m) in zip(kinds, marks, strict=True)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def beat(source: str, h: int, m: int, day: date = DAY, pid: str = "hr") -> None:
|
||||||
|
rows((source, "heartbeat", at(h, m, day=day), pid))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("source", ["sql_drills", "leetcode"])
|
||||||
|
def test_sql_drills_and_leetcode_heartbeats_back_too(source: str) -> None:
|
||||||
|
make()
|
||||||
|
timer(DAY, (9, 0), (9, 5), (9, 10))
|
||||||
|
beat(source, 9, 5)
|
||||||
|
assert minutes.minutes_for(DAY) == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_browser_and_other_project_beats_do_not_back() -> None:
|
||||||
|
make() # "other" needs no registry entry: rows are filtered by id
|
||||||
|
timer(DAY, (9, 0), (9, 5), (9, 10))
|
||||||
|
beat("browser", 9, 4)
|
||||||
|
beat("tutor", 9, 4, pid="other")
|
||||||
|
assert minutes.minutes_for(DAY) == 5 # the browser beat's own window only
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 6 # 9:04-9:10
|
||||||
|
|
||||||
|
|
||||||
|
def test_accepted_submission_backs_a_leetcode_project(tmp_path: Path) -> None:
|
||||||
|
make()
|
||||||
|
timer(DAY, (9, 0), (9, 5), (9, 10))
|
||||||
|
write(tmp_path, ac_row(at(9, 4, day=DAY)))
|
||||||
|
assert minutes.minutes_for(DAY) == 11 # 8:59-9:10
|
||||||
|
|
||||||
|
|
||||||
|
def test_submission_does_not_back_a_non_leetcode_project(tmp_path: Path) -> None:
|
||||||
|
make("hr", ("sqlzoo.net",))
|
||||||
|
timer(DAY, (9, 0), (9, 5), (9, 10))
|
||||||
|
write(tmp_path, ac_row(at(9, 4, day=DAY)))
|
||||||
|
assert minutes.minutes_for(DAY) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_span_across_the_cutover_midnight_follows_the_day() -> None:
|
||||||
|
make()
|
||||||
|
rows(
|
||||||
|
("timer", "start", at(23, 58, day=date(2026, 11, 7))),
|
||||||
|
("timer", "checkin", at(0, 3, day=date(2026, 11, 8))),
|
||||||
|
("timer", "stop", at(0, 5, day=date(2026, 11, 8))),
|
||||||
|
)
|
||||||
|
assert minutes.minutes_for(date(2026, 11, 7)) == 2
|
||||||
|
assert minutes.minutes_for(date(2026, 11, 8)) == 0
|
||||||
|
assert minutes.unbacked_minutes_for(date(2026, 11, 8)) == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_tutor_chained_span_is_backed_but_earlier_reading_is_not() -> None:
|
||||||
|
"""A tutor beat chains the segment (0.8.0) and backs the span it ends.
|
||||||
|
|
||||||
|
start 9:00, checkin 9:08, tutor beat 9:16: the span 9:08-9:16 ends at a
|
||||||
|
backing beat and earns; 9:00-9:08 has no beat in or 5 min before it and
|
||||||
|
nothing bridges it, so it shows as unbacked.
|
||||||
|
"""
|
||||||
|
make()
|
||||||
|
rows(
|
||||||
|
("timer", "start", at(9, 0, day=DAY)),
|
||||||
|
("timer", "checkin", at(9, 8, day=DAY)),
|
||||||
|
)
|
||||||
|
beat("tutor", 9, 16)
|
||||||
|
assert minutes.minutes_for(DAY) == 8
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 8
|
||||||
193
focus_project/focus_project/tests/test_checks.py
Normal file
193
focus_project/focus_project/tests/test_checks.py
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Pluggable check sources, the phone source, and latching."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import focus_project as fp
|
||||||
|
from focus_project import _paths, checks, evidence
|
||||||
|
from focus_project.tests.helpers import SAT1, at, make
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
MID = "phone:2026-11-14"
|
||||||
|
|
||||||
|
|
||||||
|
def _ms(moment: datetime) -> int:
|
||||||
|
return int(moment.timestamp() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def _report(
|
||||||
|
moment: datetime,
|
||||||
|
*,
|
||||||
|
plan: bool = True,
|
||||||
|
due: int = 5,
|
||||||
|
seen: datetime | None = None,
|
||||||
|
) -> None:
|
||||||
|
value = {"plan": plan, "due": due, "seen_ms": _ms(seen or moment)}
|
||||||
|
fp.record_evidence("iv", "phone", "phone_check", value, now=moment)
|
||||||
|
|
||||||
|
|
||||||
|
def _phone(now: datetime) -> bool | None:
|
||||||
|
return checks.phone_source(fp.list_projects()[0], now)
|
||||||
|
|
||||||
|
|
||||||
|
def test_phone_source_without_a_report_is_unknown(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
make()
|
||||||
|
with caplog.at_level("WARNING"):
|
||||||
|
assert _phone(at(13, 9)) is None
|
||||||
|
assert "phone" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_phone_source_passes_on_a_fresh_good_report() -> None:
|
||||||
|
make()
|
||||||
|
_report(at(13, 8), seen=at(12, 20))
|
||||||
|
assert _phone(at(13, 9)) is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"bad",
|
||||||
|
[{"plan": False}, {"due": 0}, {"seen": at(11, 7)}],
|
||||||
|
)
|
||||||
|
def test_phone_source_fails_on_any_bad_part(bad: dict[str, object]) -> None:
|
||||||
|
make()
|
||||||
|
_report(at(13, 8), **bad) # type: ignore[arg-type]
|
||||||
|
assert _phone(at(13, 9)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_phone_source_stale_or_future_report_is_unknown() -> None:
|
||||||
|
make()
|
||||||
|
_report(at(13, 8))
|
||||||
|
assert _phone(at(14, 8, 1)) is None # the worker stopped reporting
|
||||||
|
assert _phone(at(13, 7)) is None # stamped ahead of the clock
|
||||||
|
|
||||||
|
|
||||||
|
def test_phone_source_uses_the_latest_report() -> None:
|
||||||
|
make()
|
||||||
|
_report(at(13, 8), plan=False)
|
||||||
|
_report(at(13, 9))
|
||||||
|
assert _phone(at(13, 10)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_phone_source_hand_edited_row_is_a_failure() -> None:
|
||||||
|
make()
|
||||||
|
_report(at(13, 8))
|
||||||
|
path = _paths.evidence_path("iv")
|
||||||
|
row = {"ms": _ms(at(13, 9)), "milestone": "phone", "kind": "phone_check"}
|
||||||
|
path.write_text(path.read_text() + json.dumps({**row, "value": "ok"}) + "\n")
|
||||||
|
assert _phone(at(13, 10)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def _patch(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, fn: Callable[[fp.Project, datetime], bool | None]
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setitem(checks.SOURCES, "phone", fn)
|
||||||
|
|
||||||
|
|
||||||
|
def _trip_project() -> None:
|
||||||
|
make()
|
||||||
|
fp.answer_weekend("iv", SAT1, trip=True, now=at(11, 9))
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_checks_latches_a_pass(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
_trip_project()
|
||||||
|
_patch(monkeypatch, lambda p, n: True)
|
||||||
|
assert fp.run_checks("iv", now=at(13, 9)) == {MID: True}
|
||||||
|
assert fp.run_checks("iv", now=at(13, 10)) == {MID: True}
|
||||||
|
rows = [r for r in evidence.read("iv") if r.kind == "check"]
|
||||||
|
assert [(r.milestone, r.value) for r in rows] == [(MID, True)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_checks_only_inside_the_window(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
_trip_project()
|
||||||
|
_patch(monkeypatch, lambda p, n: True)
|
||||||
|
assert fp.run_checks("iv", now=at(12, 11, 59)) == {} # >24 h before due
|
||||||
|
assert fp.run_checks("iv", now=at(12, 12)) == {MID: True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_checks_records_changes_not_every_tick(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
_trip_project()
|
||||||
|
answers: list[bool | None] = [None, False, False, None, None]
|
||||||
|
_patch(monkeypatch, lambda p, n: answers.pop(0))
|
||||||
|
for minute in range(5):
|
||||||
|
fp.run_checks("iv", now=at(13, 9, minute))
|
||||||
|
rows = [r.value for r in evidence.read("iv") if r.kind == "check"]
|
||||||
|
assert rows == [False, None] # leading None writes nothing
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_checks_source_crash_is_unknown_and_loud(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
_trip_project()
|
||||||
|
|
||||||
|
def boom(p: fp.Project, n: datetime) -> bool:
|
||||||
|
msg = "tutor down"
|
||||||
|
raise OSError(msg)
|
||||||
|
|
||||||
|
_patch(monkeypatch, boom)
|
||||||
|
with caplog.at_level("WARNING"):
|
||||||
|
assert fp.run_checks("iv", now=at(13, 9)) == {MID: None}
|
||||||
|
assert "tutor down" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_checks_skips_finished_milestones(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
_trip_project()
|
||||||
|
fp.record_evidence("iv", MID, "tap", value=True, now=at(12, 9))
|
||||||
|
evidence.append("iv", MID, "check", True, at(12, 10))
|
||||||
|
_patch(monkeypatch, lambda p, n: pytest.fail("already done"))
|
||||||
|
assert fp.run_checks("iv", now=at(13, 9)) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_checks_ignores_focus_projects() -> None:
|
||||||
|
fp.create_project(
|
||||||
|
"f",
|
||||||
|
name="F",
|
||||||
|
start=at(10).date(),
|
||||||
|
end=at(12).date(),
|
||||||
|
replaces=["leetcode"],
|
||||||
|
daily_minutes=30,
|
||||||
|
unlock_minutes=10,
|
||||||
|
)
|
||||||
|
assert fp.run_checks("f", now=at(11, 9)) == {}
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
fp.run_checks("ghost", now=at(11, 9))
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_source_swaps_the_callable(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(checks, "SOURCES", dict(checks.SOURCES))
|
||||||
|
|
||||||
|
def fn(p: fp.Project, n: datetime) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
fp.register_source("phone", fn)
|
||||||
|
assert checks.SOURCES["phone"] is fn
|
||||||
|
with pytest.raises(ValueError, match="source"):
|
||||||
|
fp.register_source("calendar", fn)
|
||||||
|
|
||||||
|
|
||||||
|
def test_end_to_end_phone_milestone_goes_done() -> None:
|
||||||
|
_trip_project()
|
||||||
|
fp.record_evidence("iv", MID, "tap", value=True, now=at(12, 9))
|
||||||
|
_report(at(13, 9))
|
||||||
|
assert fp.run_checks("iv", now=at(13, 9, 30)) == {MID: True}
|
||||||
|
state = {m.id: m.state for m in fp.milestones(fp.list_projects()[0], at(13, 10))}
|
||||||
|
assert state[MID] == "done"
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_missing_a_field_fails() -> None:
|
||||||
|
make()
|
||||||
|
path = _paths.evidence_path("iv")
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
row = {"ms": _ms(at(13, 8)), "milestone": "phone", "kind": "phone_check"}
|
||||||
|
path.write_text(json.dumps({**row, "value": {"plan": True}}) + "\n")
|
||||||
|
assert _phone(at(13, 9)) is False
|
||||||
126
focus_project/focus_project/tests/test_evidence.py
Normal file
126
focus_project/focus_project/tests/test_evidence.py
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""The append-only evidence store and record_evidence validation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import focus_project as fp
|
||||||
|
from focus_project import _paths, evidence
|
||||||
|
from focus_project.tests.helpers import at, make, shot
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def test_paths_are_isolated(tmp_path: Path) -> None:
|
||||||
|
assert _paths.evidence_dir().is_relative_to(tmp_path)
|
||||||
|
assert _paths.evidence_path("iv").name == "iv.jsonl"
|
||||||
|
assert _paths.evidence_files_dir("iv").name == "iv"
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_is_copied_and_row_appended(tmp_path: Path) -> None:
|
||||||
|
make()
|
||||||
|
src = shot(tmp_path)
|
||||||
|
row = fp.record_evidence("iv", "rules", "file", file=src, now=at(10, 9))
|
||||||
|
assert row.kind == "file"
|
||||||
|
stored = _paths.evidence_files_dir("iv") / str(row.value)
|
||||||
|
assert stored.read_bytes() == src.read_bytes()
|
||||||
|
line = json.loads(_paths.evidence_path("iv").read_text().splitlines()[0])
|
||||||
|
assert set(line) == {"ms", "milestone", "kind", "value"}
|
||||||
|
assert line["milestone"] == "rules"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_append_never_rewrite() -> None:
|
||||||
|
make()
|
||||||
|
fp.record_evidence("iv", "mock", "score", 71, now=at(24, 9))
|
||||||
|
fp.record_evidence("iv", "mock", "score", 80.5, now=at(24, 10))
|
||||||
|
assert [r.value for r in evidence.read("iv")] == [71, 80.5]
|
||||||
|
assert not hasattr(fp, "delete_evidence")
|
||||||
|
assert not hasattr(evidence, "delete")
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_name_files_do_not_overwrite(tmp_path: Path) -> None:
|
||||||
|
make()
|
||||||
|
one = fp.record_evidence("iv", "rules", "file", file=shot(tmp_path), now=at(10, 9))
|
||||||
|
two = fp.record_evidence("iv", "rules", "file", file=shot(tmp_path), now=at(10, 9))
|
||||||
|
assert one.value != two.value
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("milestone", "kind", "value"),
|
||||||
|
[
|
||||||
|
("rules", "tap", True), # wrong kind for the milestone
|
||||||
|
("rules", "check", True), # internal kind
|
||||||
|
("trip_ask:2026-11-14", "answer", True), # use answer_weekend
|
||||||
|
("nope", "tap", True), # not a milestone of the project
|
||||||
|
("mock", "score", "high"),
|
||||||
|
("mock", "score", True),
|
||||||
|
("mock", "score", float("nan")),
|
||||||
|
("mock", "score", -1),
|
||||||
|
("slot", "slot", "tomorrow"),
|
||||||
|
("slot", "slot", "2026-11-26T10:00:00+02:00"), # aware
|
||||||
|
("slot", "slot_confirm", False),
|
||||||
|
("phone", "phone_check", {"plan": True}),
|
||||||
|
("phone", "phone_check", {"plan": "yes", "due": 1, "seen_ms": 1}),
|
||||||
|
("phone", "phone_check", "ok"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_bad_evidence_refused(milestone: str, kind: str, value: object) -> None:
|
||||||
|
make()
|
||||||
|
with pytest.raises(ValueError): # noqa: PT011
|
||||||
|
fp.record_evidence("iv", milestone, kind, value, now=at(12, 9))
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_kind_needs_a_real_nonempty_file(tmp_path: Path) -> None:
|
||||||
|
make()
|
||||||
|
empty = tmp_path / "e.png"
|
||||||
|
empty.write_bytes(b"")
|
||||||
|
for bad in (None, tmp_path / "missing.png", empty, tmp_path):
|
||||||
|
with pytest.raises(ValueError, match="file"):
|
||||||
|
fp.record_evidence("iv", "rules", "file", file=bad, now=at(10, 9))
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_project_is_keyerror() -> None:
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
fp.record_evidence("ghost", "mock", "score", 1, now=at(24, 9))
|
||||||
|
|
||||||
|
|
||||||
|
def test_focus_project_has_no_milestones() -> None:
|
||||||
|
fp.create_project(
|
||||||
|
"f",
|
||||||
|
name="F",
|
||||||
|
start=at(10).date(),
|
||||||
|
end=at(12).date(),
|
||||||
|
replaces=["leetcode"],
|
||||||
|
daily_minutes=30,
|
||||||
|
unlock_minutes=10,
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="milestone"):
|
||||||
|
fp.record_evidence("f", "mock", "score", 1, now=at(10, 9))
|
||||||
|
|
||||||
|
|
||||||
|
def test_torn_and_foreign_lines_are_skipped(caplog: pytest.LogCaptureFixture) -> None:
|
||||||
|
make()
|
||||||
|
fp.record_evidence("iv", "mock", "score", 70, now=at(24, 9))
|
||||||
|
path = _paths.evidence_path("iv")
|
||||||
|
path.write_text(path.read_text() + '{"ms": 1, "milest\n[1]\n{"ms":"x"}\n')
|
||||||
|
with caplog.at_level("WARNING"):
|
||||||
|
rows = evidence.read("iv")
|
||||||
|
assert [r.value for r in rows] == [70]
|
||||||
|
assert "skipping" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_file_reads_empty() -> None:
|
||||||
|
assert evidence.read("nobody") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_garbage_bytes_are_skipped_not_fatal() -> None:
|
||||||
|
make()
|
||||||
|
fp.record_evidence("iv", "mock", "score", 70, now=at(24, 9))
|
||||||
|
path = _paths.evidence_path("iv")
|
||||||
|
path.write_bytes(path.read_bytes() + b"\xff\xfe\x80 not utf-8\n")
|
||||||
|
assert [r.value for r in evidence.read("iv")] == [70]
|
||||||
175
focus_project/focus_project/tests/test_interview_model.py
Normal file
175
focus_project/focus_project/tests/test_interview_model.py
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Project.kind, interview fields, validation, budget."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import focus_project as fp
|
||||||
|
from focus_project import _interview, _milestones
|
||||||
|
from focus_project._errors import RegistryError
|
||||||
|
from focus_project.tests.helpers import SAT1, START, TEST_AT, TEST_DAY, at, make
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
LIVE_ROW = {
|
||||||
|
"id": "hackerrank-prep",
|
||||||
|
"name": "HackerRank prep",
|
||||||
|
"start": "2026-11-02",
|
||||||
|
"end": "2026-11-05",
|
||||||
|
"replaces": ["leetcode", "reading", "tutor"],
|
||||||
|
"daily_minutes": 120,
|
||||||
|
"unlock_minutes": 30,
|
||||||
|
"subjects": ["a"],
|
||||||
|
"site_patterns": ["leetcode.com"],
|
||||||
|
"ended_on": None,
|
||||||
|
"created_ms": 1793603609345,
|
||||||
|
"work_url": "https://www.hackerrank.com/dashboard",
|
||||||
|
"history": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_projects_json_shape_loads(data: Path) -> None:
|
||||||
|
data.mkdir()
|
||||||
|
(data / "projects.json").write_text(json.dumps({"projects": [LIVE_ROW]}))
|
||||||
|
[p] = fp.list_projects()
|
||||||
|
assert p.kind == "focus"
|
||||||
|
assert p.test_at is None
|
||||||
|
assert p.trips == ()
|
||||||
|
assert p.weekend_answers == ()
|
||||||
|
assert p.daily_on(date(2026, 11, 3)) == 120
|
||||||
|
snap = fp.status(date(2026, 11, 3))
|
||||||
|
assert snap["milestones"] == []
|
||||||
|
assert snap["missed"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_focus_row_stays_free_of_interview_keys(data: Path) -> None:
|
||||||
|
fp.create_project(
|
||||||
|
"f",
|
||||||
|
name="F",
|
||||||
|
start=START,
|
||||||
|
end=TEST_DAY,
|
||||||
|
replaces=["leetcode"],
|
||||||
|
daily_minutes=30,
|
||||||
|
unlock_minutes=10,
|
||||||
|
)
|
||||||
|
row = json.loads((data / "projects.json").read_text())["projects"][0]
|
||||||
|
assert row["kind"] == "focus"
|
||||||
|
assert not {"test_at", "invite_url", "trips", "weekend_answers"} & set(row)
|
||||||
|
|
||||||
|
|
||||||
|
def test_interview_roundtrip_and_file_shape(data: Path) -> None:
|
||||||
|
p = make(
|
||||||
|
trips=[fp.Trip(date(2026, 11, 20), date(2026, 11, 22))],
|
||||||
|
weekend_answers={SAT1: False},
|
||||||
|
)
|
||||||
|
[back] = fp.list_projects()
|
||||||
|
assert back == p
|
||||||
|
assert back.kind == "interview"
|
||||||
|
assert back.test_at == TEST_AT
|
||||||
|
assert back.trips[0].entered_ms > 0
|
||||||
|
row = json.loads((data / "projects.json").read_text())["projects"][0]
|
||||||
|
assert row["test_at"] == "2026-11-27T10:00:00"
|
||||||
|
assert row["trips"][0]["start"] == "2026-11-20"
|
||||||
|
assert row["weekend_answers"] == {"2026-11-14": False}
|
||||||
|
assert row["invite_url"].startswith("https://")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"bad",
|
||||||
|
[
|
||||||
|
{"test_at": None},
|
||||||
|
{"test_at": at(26, 10)}, # end != test day
|
||||||
|
{"test_at": at(9, 10), "end": date(2026, 11, 9)},
|
||||||
|
{"invite_url": "ftp://x.example/t"},
|
||||||
|
{"invite_url": "javascript:alert(1)"},
|
||||||
|
{"invite_url": "https://a b.example"},
|
||||||
|
{"kind": "bogus"},
|
||||||
|
{"trips": [fp.Trip(date(2026, 11, 28), date(2026, 11, 29))]},
|
||||||
|
{"trips": [fp.Trip(date(2026, 11, 20), date(2026, 11, 19))]},
|
||||||
|
{"weekend_answers": {date(2026, 11, 15): True}}, # a Sunday
|
||||||
|
{"weekend_answers": {date(2026, 11, 28): True}}, # after T
|
||||||
|
{"daily_minutes": 29},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_interview_validation(bad: dict[str, object]) -> None:
|
||||||
|
with pytest.raises(ValueError): # noqa: PT011
|
||||||
|
make(**bad)
|
||||||
|
|
||||||
|
|
||||||
|
def test_focus_kind_rejects_interview_fields() -> None:
|
||||||
|
with pytest.raises(ValueError, match="interview"):
|
||||||
|
make(kind="focus")
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_invite_url_is_allowed() -> None:
|
||||||
|
assert make(invite_url="").invite_url == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_budget_30_weekdays_120_weekend() -> None:
|
||||||
|
p = make(unlock_minutes=60)
|
||||||
|
assert p.daily_on(date(2026, 11, 10)) == 30 # Tue
|
||||||
|
assert p.daily_on(date(2026, 11, 13)) == 30 # Fri
|
||||||
|
assert p.daily_on(SAT1) == 120
|
||||||
|
assert p.daily_on(date(2026, 11, 15)) == 120 # Sun
|
||||||
|
assert p.unlock_on(date(2026, 11, 10)) == 30 # clamped to the day's target
|
||||||
|
assert p.unlock_on(SAT1) == 60
|
||||||
|
|
||||||
|
|
||||||
|
def test_budget_flows_through_status() -> None:
|
||||||
|
make()
|
||||||
|
assert fp.status(date(2026, 11, 10))["daily_minutes"] == 30
|
||||||
|
assert fp.status(SAT1)["daily_minutes"] == 120
|
||||||
|
|
||||||
|
|
||||||
|
def test_interview_exempt_from_floor_but_focus_is_not() -> None:
|
||||||
|
make(daily_minutes=30, unlock_minutes=30) # 3 gates, floor 90
|
||||||
|
with pytest.raises(ValueError, match="at least"):
|
||||||
|
fp.create_project(
|
||||||
|
"f",
|
||||||
|
name="F",
|
||||||
|
start=date(2026, 12, 1),
|
||||||
|
end=date(2026, 12, 2),
|
||||||
|
replaces=["leetcode", "reading"],
|
||||||
|
daily_minutes=30,
|
||||||
|
unlock_minutes=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_interview_has_no_weekends() -> None:
|
||||||
|
focus = fp.create_project(
|
||||||
|
"f",
|
||||||
|
name="F",
|
||||||
|
start=START,
|
||||||
|
end=TEST_DAY,
|
||||||
|
replaces=["leetcode"],
|
||||||
|
daily_minutes=30,
|
||||||
|
unlock_minutes=10,
|
||||||
|
)
|
||||||
|
assert _interview.saturdays(focus) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_trip_without_entry_time_uses_the_nominal_due() -> None:
|
||||||
|
assert _milestones._trip_due(date(2026, 11, 13), 0) == at(12, 12)
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_boolean_weekend_answer_is_a_bad_row(data: Path) -> None:
|
||||||
|
make()
|
||||||
|
path = data / "projects.json"
|
||||||
|
rows = json.loads(path.read_text())
|
||||||
|
rows["projects"][0]["weekend_answers"] = {"2026-11-14": "false"}
|
||||||
|
path.write_text(json.dumps(rows))
|
||||||
|
with pytest.raises(RegistryError):
|
||||||
|
fp.list_projects()
|
||||||
|
|
||||||
|
|
||||||
|
def test_interview_unlock_edit_keeps_the_ratchet() -> None:
|
||||||
|
make(unlock_minutes=60)
|
||||||
|
fp.update_project("iv", today=SAT1, unlock_minutes=10)
|
||||||
|
assert fp.list_projects()[0].unlock_on(SAT1) == 60 # lower holds from tomorrow
|
||||||
|
assert fp.list_projects()[0].unlock_on(date(2026, 11, 15)) == 10
|
||||||
@ -14,6 +14,8 @@ import pytest
|
|||||||
import focus_project as fp
|
import focus_project as fp
|
||||||
from focus_project import minutes, sources
|
from focus_project import minutes, sources
|
||||||
from focus_project.sources import leetcode
|
from focus_project.sources import leetcode
|
||||||
|
from focus_project.tests.test_backing import DAY, proj, rows
|
||||||
|
from focus_project.tests.test_backing import at as day_at
|
||||||
from focus_project.tests.test_minutes import D1, at, project
|
from focus_project.tests.test_minutes import D1, at, project
|
||||||
|
|
||||||
KEY = b"k" * 32
|
KEY = b"k" * 32
|
||||||
@ -127,3 +129,29 @@ def test_read_only_replay_of_real_ledger(monkeypatch: pytest.MonkeyPatch) -> Non
|
|||||||
assert got
|
assert got
|
||||||
assert all(b - a == sources.HEARTBEAT_WINDOW_S for a, b in got)
|
assert all(b - a == sources.HEARTBEAT_WINDOW_S for a, b in got)
|
||||||
assert REAL.stat().st_mtime_ns == before
|
assert REAL.stat().st_mtime_ns == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_accepted_submission_backs_a_timer_span(tmp_path: Path) -> None:
|
||||||
|
"""0.10.0: an accepted submission backs timer time on a backed day."""
|
||||||
|
proj()
|
||||||
|
rows(("timer", "start", day_at(9)), ("timer", "stop", day_at(9, 10)))
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 10
|
||||||
|
write(tmp_path, ac_row(day_at(9, 10)))
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||||
|
assert minutes.minutes_for(DAY) == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_unreadable_ledger_leaves_heartbeat_backing(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
def boom(_project: object) -> list[float]:
|
||||||
|
raise OSError
|
||||||
|
|
||||||
|
monkeypatch.setattr(leetcode, "submission_times", boom)
|
||||||
|
proj()
|
||||||
|
rows(
|
||||||
|
("timer", "start", day_at(9)),
|
||||||
|
("timer", "stop", day_at(9, 10)),
|
||||||
|
("tutor", "heartbeat", day_at(9, 10)),
|
||||||
|
)
|
||||||
|
assert minutes.unbacked_minutes_for(DAY) == 0
|
||||||
|
|||||||
228
focus_project/focus_project/tests/test_milestones.py
Normal file
228
focus_project/focus_project/tests/test_milestones.py
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Milestone generation, states and the lock that falls out of them."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import focus_project as fp
|
||||||
|
from focus_project import evidence
|
||||||
|
from focus_project.tests.helpers import SAT1, SAT2, at, make, session, shot, states
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def _due(p: fp.Project, now_day: int = 10) -> dict[str, str]:
|
||||||
|
return {m.id: m.due.isoformat() for m in fp.milestones(p, at(now_day, 8))}
|
||||||
|
|
||||||
|
|
||||||
|
def test_table_of_dues() -> None:
|
||||||
|
assert _due(make()) == {
|
||||||
|
"rules": "2026-11-10T23:59:00",
|
||||||
|
"trip_ask:2026-11-14": "2026-11-12T23:59:00",
|
||||||
|
"trip_ask:2026-11-21": "2026-11-19T23:59:00",
|
||||||
|
"mock": "2026-11-25T23:59:00",
|
||||||
|
"slot": "2026-11-26T12:00:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_sorted_by_due_and_all_pending_at_start() -> None:
|
||||||
|
project = make()
|
||||||
|
items = fp.milestones(project, at(10, 8))
|
||||||
|
assert [m.due for m in items] == sorted(m.due for m in items)
|
||||||
|
assert {m.state for m in items} == {"pending"}
|
||||||
|
assert items[0].to_dict() == {
|
||||||
|
"id": "rules",
|
||||||
|
"due": "2026-11-10T23:59:00",
|
||||||
|
"state": "pending",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_focus_project_has_none() -> None:
|
||||||
|
p = fp.create_project(
|
||||||
|
"f",
|
||||||
|
name="F",
|
||||||
|
start=date(2026, 11, 10),
|
||||||
|
end=date(2026, 11, 12),
|
||||||
|
replaces=["leetcode"],
|
||||||
|
daily_minutes=30,
|
||||||
|
unlock_minutes=10,
|
||||||
|
)
|
||||||
|
assert fp.milestones(p, at(11, 8)) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_unanswered_weekend_is_a_trip_once_ask_is_due() -> None:
|
||||||
|
p = make()
|
||||||
|
assert "phone:2026-11-14" not in states(p, at(12, 23, 58))
|
||||||
|
got = states(p, at(13, 8))
|
||||||
|
assert got["trip_ask:2026-11-14"] == "missed"
|
||||||
|
assert got["phone:2026-11-14"] == "pending" # due Fri 12:00
|
||||||
|
assert states(p, at(13, 12))["phone:2026-11-14"] == "missed"
|
||||||
|
assert _due(p, 13)["phone:2026-11-14"] == "2026-11-13T12:00:00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_answers_decide_the_phone_milestone() -> None:
|
||||||
|
make()
|
||||||
|
no = fp.answer_weekend("iv", SAT1, trip=False, now=at(11, 9))
|
||||||
|
assert "phone:2026-11-14" not in states(no, at(13, 13))
|
||||||
|
assert states(no, at(13, 13))["trip_ask:2026-11-14"] == "done"
|
||||||
|
yes = fp.answer_weekend("iv", SAT1, trip=True, now=at(11, 10))
|
||||||
|
assert states(yes, at(11, 11))["phone:2026-11-14"] == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_trip_covers_its_saturday() -> None:
|
||||||
|
make()
|
||||||
|
p = fp.add_trip("iv", date(2026, 11, 13), date(2026, 11, 15), now=at(10, 9))
|
||||||
|
got = states(p, at(13, 8))
|
||||||
|
assert "phone:2026-11-13" in got
|
||||||
|
assert "phone:2026-11-14" not in got
|
||||||
|
assert _due(p)["phone:2026-11-13"] == "2026-11-12T12:00:00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_late_trip_is_due_the_moment_it_is_entered() -> None:
|
||||||
|
make()
|
||||||
|
p = fp.add_trip("iv", date(2026, 11, 13), date(2026, 11, 14), now=at(12, 8))
|
||||||
|
assert _due(p, 12)["phone:2026-11-13"] == "2026-11-12T08:00:00"
|
||||||
|
assert states(p, at(12, 8))["phone:2026-11-13"] == "missed"
|
||||||
|
far = fp.add_trip("iv", date(2026, 11, 20), date(2026, 11, 22), now=at(12, 9))
|
||||||
|
assert _due(far, 12)["phone:2026-11-20"] == "2026-11-19T12:00:00"
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(tmp_path: Path) -> None:
|
||||||
|
"""Everything due before the slot is done on time; weekend 2 is no trip."""
|
||||||
|
fp.record_evidence("iv", "rules", "file", file=shot(tmp_path), now=at(10, 9))
|
||||||
|
fp.answer_weekend("iv", SAT1, trip=False, now=at(11, 9))
|
||||||
|
fp.answer_weekend("iv", SAT2, trip=False, now=at(11, 9))
|
||||||
|
session(at(24, 7), 90)
|
||||||
|
fp.record_evidence("iv", "mock", "score", 70, now=at(24, 9))
|
||||||
|
|
||||||
|
|
||||||
|
def test_slot_is_due_t_minus_1_at_noon_and_locks_that_evening(tmp_path: Path) -> None:
|
||||||
|
make()
|
||||||
|
_clean(tmp_path)
|
||||||
|
assert fp.owed_lock(at(26, 11, 59)) is None
|
||||||
|
owed = fp.owed_lock(at(26, 12))
|
||||||
|
assert owed is not None
|
||||||
|
assert owed.id == "slot"
|
||||||
|
assert owed.state == "missed"
|
||||||
|
assert fp.owed_lock(at(26, 20)) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_test_day_never_locks_but_shows_red(tmp_path: Path) -> None:
|
||||||
|
make()
|
||||||
|
assert fp.owed_lock(at(27, 9)) is None
|
||||||
|
assert fp.status(date(2026, 11, 27))["milestones"] is not None
|
||||||
|
assert {m.state for m in fp.milestones(fp.list_projects()[0], at(27, 9))} >= {
|
||||||
|
"missed"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_owed_lock_is_the_earliest_missed() -> None:
|
||||||
|
make()
|
||||||
|
owed = fp.owed_lock(at(13, 8))
|
||||||
|
assert owed is not None
|
||||||
|
assert owed.id == "rules"
|
||||||
|
|
||||||
|
|
||||||
|
def test_late_completion_unlocks(tmp_path: Path) -> None:
|
||||||
|
make()
|
||||||
|
fp.answer_weekend("iv", SAT1, trip=False, now=at(11, 9))
|
||||||
|
owed = fp.owed_lock(at(11, 10))
|
||||||
|
assert owed is not None
|
||||||
|
assert owed.id == "rules"
|
||||||
|
fp.record_evidence("iv", "rules", "file", file=shot(tmp_path), now=at(11, 10))
|
||||||
|
p = fp.list_projects()[0]
|
||||||
|
assert states(p, at(11, 10))["rules"] == "late_done"
|
||||||
|
assert fp.owed_lock(at(11, 10, 1)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_on_time_is_done_at_the_boundary(tmp_path: Path) -> None:
|
||||||
|
make()
|
||||||
|
fp.record_evidence("iv", "rules", "file", file=shot(tmp_path), now=at(10, 23, 58))
|
||||||
|
p = fp.list_projects()[0]
|
||||||
|
assert states(p, at(11, 8))["rules"] == "done"
|
||||||
|
assert states(p, at(10, 23, 59))["rules"] == "done"
|
||||||
|
|
||||||
|
|
||||||
|
def test_slot_needs_entry_and_confirm_tap() -> None:
|
||||||
|
p = make()
|
||||||
|
fp.record_evidence("iv", "slot", "slot", "2026-11-26T20:00:00", now=at(25, 9))
|
||||||
|
assert states(p, at(25, 10))["slot"] == "pending"
|
||||||
|
assert states(p, at(26, 12))["slot"] == "missed" # entered, never confirmed
|
||||||
|
fp.record_evidence("iv", "slot", "slot_confirm", value=True, now=at(25, 11))
|
||||||
|
assert states(p, at(25, 12))["slot"] == "done"
|
||||||
|
fp.record_evidence("iv", "slot", "slot", "2026-11-26T21:00:00", now=at(25, 13))
|
||||||
|
assert states(p, at(25, 14))["slot"] == "pending" # moved: confirm again
|
||||||
|
fp.record_evidence("iv", "slot", "slot_confirm", value=True, now=at(26, 13))
|
||||||
|
assert states(p, at(26, 14))["slot"] == "late_done"
|
||||||
|
assert not [s for s in states(p, at(26, 14)).values() if s == "unknown"]
|
||||||
|
|
||||||
|
|
||||||
|
def _phone_setup() -> None:
|
||||||
|
make()
|
||||||
|
fp.answer_weekend("iv", SAT1, trip=True, now=at(11, 9))
|
||||||
|
|
||||||
|
|
||||||
|
def test_phone_milestone_states() -> None:
|
||||||
|
_phone_setup()
|
||||||
|
p = fp.list_projects()[0]
|
||||||
|
mid = "phone:2026-11-14"
|
||||||
|
assert states(p, at(13, 13))[mid] == "missed" # no tap, no check
|
||||||
|
fp.record_evidence("iv", mid, "tap", value=True, now=at(12, 9))
|
||||||
|
assert states(p, at(13, 13))[mid] == "unknown" # tap but nothing checked
|
||||||
|
evidence.append("iv", mid, "check", value=False, now=at(13, 11))
|
||||||
|
assert states(p, at(13, 13))[mid] == "missed" # checked and failing
|
||||||
|
evidence.append("iv", mid, "check", value=None, now=at(13, 11, 30))
|
||||||
|
assert states(p, at(13, 13))[mid] == "unknown" # source went dark
|
||||||
|
evidence.append("iv", mid, "check", value=True, now=at(13, 11, 40))
|
||||||
|
assert states(p, at(13, 13))[mid] == "done"
|
||||||
|
|
||||||
|
|
||||||
|
def test_phone_passed_after_due_is_late_done() -> None:
|
||||||
|
_phone_setup()
|
||||||
|
mid = "phone:2026-11-14"
|
||||||
|
fp.record_evidence("iv", mid, "tap", value=True, now=at(12, 9))
|
||||||
|
evidence.append("iv", mid, "check", value=True, now=at(13, 14))
|
||||||
|
assert states(fp.list_projects()[0], at(13, 15))[mid] == "late_done"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_locks_like_missed(tmp_path: Path) -> None:
|
||||||
|
_phone_setup()
|
||||||
|
mid = "phone:2026-11-14"
|
||||||
|
fp.record_evidence("iv", "rules", "file", file=shot(tmp_path), now=at(10, 9))
|
||||||
|
fp.answer_weekend("iv", SAT2, trip=False, now=at(11, 9))
|
||||||
|
fp.record_evidence("iv", mid, "tap", value=True, now=at(12, 9))
|
||||||
|
owed = fp.owed_lock(at(13, 13))
|
||||||
|
assert owed is not None
|
||||||
|
assert (owed.id, owed.state) == (mid, "unknown")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unreadable_registry_fails_open_and_loud(
|
||||||
|
data: Path, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
data.mkdir()
|
||||||
|
(data / "projects.json").write_text("{nope")
|
||||||
|
with caplog.at_level("WARNING"):
|
||||||
|
assert fp.owed_lock(at(13, 8)) is None
|
||||||
|
assert "unreadable" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_project_no_lock() -> None:
|
||||||
|
assert fp.owed_lock(at(13, 8)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_focus_project_never_locks_on_milestones() -> None:
|
||||||
|
fp.create_project(
|
||||||
|
"f",
|
||||||
|
name="F",
|
||||||
|
start=date(2026, 11, 10),
|
||||||
|
end=date(2026, 11, 20),
|
||||||
|
replaces=["leetcode"],
|
||||||
|
daily_minutes=30,
|
||||||
|
unlock_minutes=10,
|
||||||
|
)
|
||||||
|
assert fp.owed_lock(at(13, 8)) is None
|
||||||
61
focus_project/focus_project/tests/test_milestones_mock.py
Normal file
61
focus_project/focus_project/tests/test_milestones_mock.py
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""The mock milestone: a score counts only after a >= 90 min focus session."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import focus_project as fp
|
||||||
|
from focus_project.tests.helpers import at, make, session, states
|
||||||
|
|
||||||
|
|
||||||
|
def test_mock_needs_a_score_after_a_long_session() -> None:
|
||||||
|
p = make()
|
||||||
|
assert states(p, at(26, 8))["mock"] == "missed"
|
||||||
|
session(at(25, 20), 90)
|
||||||
|
fp.record_evidence("iv", "mock", "score", 64, now=at(26, 8))
|
||||||
|
assert states(fp.list_projects()[0], at(26, 9))["mock"] == "late_done"
|
||||||
|
|
||||||
|
|
||||||
|
def test_score_without_a_session_does_not_complete_mock() -> None:
|
||||||
|
p = make()
|
||||||
|
fp.record_evidence("iv", "mock", "score", 64, now=at(24, 9))
|
||||||
|
assert states(p, at(24, 10))["mock"] == "pending"
|
||||||
|
assert states(p, at(26, 8))["mock"] == "missed"
|
||||||
|
assert fp.owed_lock(at(26, 8)) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_session_does_not_complete_mock() -> None:
|
||||||
|
p = make()
|
||||||
|
session(at(24, 7), 89)
|
||||||
|
fp.record_evidence("iv", "mock", "score", 64, now=at(24, 9))
|
||||||
|
assert states(p, at(24, 10))["mock"] == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_broken_by_a_gap_does_not_complete_mock() -> None:
|
||||||
|
p = make()
|
||||||
|
session(at(24, 6), 60) # 06:00-07:00, then an 11 min hole (> 600 s)
|
||||||
|
session(at(24, 7, 11), 60)
|
||||||
|
fp.record_evidence("iv", "mock", "score", 64, now=at(24, 9))
|
||||||
|
assert states(p, at(24, 10))["mock"] == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_must_end_before_the_score() -> None:
|
||||||
|
p = make()
|
||||||
|
session(at(24, 8), 90) # ends 09:30
|
||||||
|
fp.record_evidence("iv", "mock", "score", 64, now=at(24, 9))
|
||||||
|
assert states(p, at(24, 10))["mock"] == "pending"
|
||||||
|
fp.record_evidence("iv", "mock", "score", 66, now=at(24, 9, 30))
|
||||||
|
assert states(p, at(24, 10))["mock"] == "done" # ends exactly at the score
|
||||||
|
|
||||||
|
|
||||||
|
def test_other_projects_sessions_do_not_count() -> None:
|
||||||
|
p = make()
|
||||||
|
session(at(24, 7), 90, pid="other")
|
||||||
|
fp.record_evidence("iv", "mock", "score", 64, now=at(24, 9))
|
||||||
|
assert states(p, at(24, 10))["mock"] == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_on_time_but_score_late_is_late_done() -> None:
|
||||||
|
p = make()
|
||||||
|
session(at(25, 20), 100)
|
||||||
|
fp.record_evidence("iv", "mock", "score", 64, now=at(26, 0, 5))
|
||||||
|
assert states(p, at(26, 1))["mock"] == "late_done"
|
||||||
172
focus_project/focus_project/tests/test_ratchet.py
Normal file
172
focus_project/focus_project/tests/test_ratchet.py
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""add_trip / answer_weekend only ever get stricter."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import focus_project as fp
|
||||||
|
from focus_project import evidence
|
||||||
|
from focus_project.tests.helpers import SAT1, at, make, states
|
||||||
|
|
||||||
|
|
||||||
|
def _ms(day: int, hour: int) -> int:
|
||||||
|
return int(at(day, hour).timestamp() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_trip_records_when_it_was_entered() -> None:
|
||||||
|
make()
|
||||||
|
p = fp.add_trip("iv", date(2026, 11, 20), date(2026, 11, 22), now=at(10, 9))
|
||||||
|
assert p.trips == (fp.Trip(date(2026, 11, 20), date(2026, 11, 22), _ms(10, 9)),)
|
||||||
|
assert fp.list_projects() == [p]
|
||||||
|
|
||||||
|
|
||||||
|
def test_extending_keeps_entry_time_moving_earlier_resets_it() -> None:
|
||||||
|
make()
|
||||||
|
fp.add_trip("iv", date(2026, 11, 20), date(2026, 11, 21), now=at(10, 9))
|
||||||
|
later = fp.add_trip("iv", date(2026, 11, 20), date(2026, 11, 23), now=at(11, 9))
|
||||||
|
assert later.trips == (fp.Trip(date(2026, 11, 20), date(2026, 11, 23), _ms(10, 9)),)
|
||||||
|
earlier = fp.add_trip("iv", date(2026, 11, 19), date(2026, 11, 23), now=at(12, 9))
|
||||||
|
assert earlier.trips == (
|
||||||
|
fp.Trip(date(2026, 11, 19), date(2026, 11, 23), _ms(12, 9)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_second_disjoint_trip_and_idempotent_repeat() -> None:
|
||||||
|
make()
|
||||||
|
fp.add_trip("iv", date(2026, 11, 13), date(2026, 11, 14), now=at(10, 9))
|
||||||
|
fp.add_trip("iv", date(2026, 11, 20), date(2026, 11, 21), now=at(10, 10))
|
||||||
|
again = fp.add_trip("iv", date(2026, 11, 20), date(2026, 11, 21), now=at(11, 9))
|
||||||
|
assert len(again.trips) == 2
|
||||||
|
assert again.trips[1].entered_ms == _ms(10, 10)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("start", "end"),
|
||||||
|
[
|
||||||
|
(date(2026, 11, 21), date(2026, 11, 22)), # starts later: loosens
|
||||||
|
(date(2026, 11, 20), date(2026, 11, 21)), # ends earlier: shortens
|
||||||
|
(date(2026, 11, 21), date(2026, 11, 21)), # strictly inside
|
||||||
|
(date(2026, 11, 22), date(2026, 11, 25)), # partial overlap, later start
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_trip_may_not_shrink_or_start_later(start: date, end: date) -> None:
|
||||||
|
make()
|
||||||
|
before = fp.add_trip("iv", date(2026, 11, 20), date(2026, 11, 22), now=at(10, 9))
|
||||||
|
with pytest.raises(ValueError, match=r"stricter|cover"):
|
||||||
|
fp.add_trip("iv", start, end, now=at(11, 9))
|
||||||
|
assert fp.list_projects() == [before]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("start", "end"),
|
||||||
|
[
|
||||||
|
(date(2026, 11, 20), date(2026, 11, 19)),
|
||||||
|
(date(2026, 11, 28), date(2026, 11, 29)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_bad_trip_ranges(start: date, end: date) -> None:
|
||||||
|
make()
|
||||||
|
with pytest.raises(ValueError): # noqa: PT011
|
||||||
|
fp.add_trip("iv", start, end, now=at(10, 9))
|
||||||
|
|
||||||
|
|
||||||
|
def test_trip_on_wrong_project() -> None:
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
fp.add_trip("ghost", date(2026, 11, 20), date(2026, 11, 21), now=at(10, 9))
|
||||||
|
fp.create_project(
|
||||||
|
"f",
|
||||||
|
name="F",
|
||||||
|
start=date(2026, 12, 1),
|
||||||
|
end=date(2026, 12, 3),
|
||||||
|
replaces=["leetcode"],
|
||||||
|
daily_minutes=30,
|
||||||
|
unlock_minutes=10,
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="interview"):
|
||||||
|
fp.add_trip("f", date(2026, 12, 2), date(2026, 12, 3), now=at(10, 9))
|
||||||
|
|
||||||
|
|
||||||
|
def test_answer_false_to_true_ok_true_to_false_refused() -> None:
|
||||||
|
make()
|
||||||
|
fp.answer_weekend("iv", SAT1, trip=False, now=at(11, 9))
|
||||||
|
yes = fp.answer_weekend("iv", SAT1, trip=True, now=at(11, 10))
|
||||||
|
assert dict(yes.weekend_answers) == {SAT1: True}
|
||||||
|
with pytest.raises(ValueError, match="stricter"):
|
||||||
|
fp.answer_weekend("iv", SAT1, trip=False, now=at(11, 11))
|
||||||
|
assert dict(fp.list_projects()[0].weekend_answers) == {SAT1: True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_repeating_an_answer_writes_no_second_row() -> None:
|
||||||
|
make()
|
||||||
|
fp.answer_weekend("iv", SAT1, trip=True, now=at(11, 9))
|
||||||
|
fp.answer_weekend("iv", SAT1, trip=True, now=at(11, 10))
|
||||||
|
rows = [r for r in evidence.read("iv") if r.milestone == "trip_ask:2026-11-14"]
|
||||||
|
assert [(r.kind, r.value) for r in rows] == [("answer", True)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"day", [date(2026, 11, 15), date(2026, 11, 7), date(2026, 11, 28)]
|
||||||
|
)
|
||||||
|
def test_answer_only_for_a_weekend_of_the_project(day: date) -> None:
|
||||||
|
make()
|
||||||
|
with pytest.raises(ValueError, match="weekend"):
|
||||||
|
fp.answer_weekend("iv", day, trip=True, now=at(11, 9))
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_trip_answer_contradicting_an_explicit_trip_is_refused() -> None:
|
||||||
|
make()
|
||||||
|
fp.add_trip("iv", date(2026, 11, 13), date(2026, 11, 15), now=at(10, 9))
|
||||||
|
with pytest.raises(ValueError, match="trip covers"):
|
||||||
|
fp.answer_weekend("iv", SAT1, trip=False, now=at(11, 9))
|
||||||
|
|
||||||
|
|
||||||
|
def test_late_answer_unlocks_but_is_late_done() -> None:
|
||||||
|
p = make()
|
||||||
|
assert states(p, at(13, 8))["trip_ask:2026-11-14"] == "missed"
|
||||||
|
p = fp.answer_weekend("iv", SAT1, trip=False, now=at(13, 8))
|
||||||
|
assert states(p, at(13, 9))["trip_ask:2026-11-14"] == "late_done"
|
||||||
|
|
||||||
|
|
||||||
|
def test_answer_on_wrong_project() -> None:
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
fp.answer_weekend("ghost", SAT1, trip=True, now=at(11, 9))
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_skips_other_projects() -> None:
|
||||||
|
fp.create_project(
|
||||||
|
"f",
|
||||||
|
name="F",
|
||||||
|
start=date(2026, 10, 1),
|
||||||
|
end=date(2026, 10, 3),
|
||||||
|
replaces=["leetcode"],
|
||||||
|
daily_minutes=30,
|
||||||
|
unlock_minutes=10,
|
||||||
|
)
|
||||||
|
make()
|
||||||
|
p = fp.answer_weekend("iv", SAT1, trip=True, now=at(11, 9))
|
||||||
|
assert dict(p.weekend_answers) == {SAT1: True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_late_no_trip_answer_cannot_erase_an_overdue_phone_milestone() -> None:
|
||||||
|
make()
|
||||||
|
assert states(fp.list_projects()[0], at(13, 13))["phone:2026-11-14"] == "missed"
|
||||||
|
p = fp.answer_weekend("iv", SAT1, trip=False, now=at(13, 13))
|
||||||
|
got = states(p, at(13, 14))
|
||||||
|
assert got["trip_ask:2026-11-14"] == "late_done"
|
||||||
|
assert got["phone:2026-11-14"] == "missed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_trip_answer_before_the_phone_is_due_drops_it() -> None:
|
||||||
|
make()
|
||||||
|
p = fp.answer_weekend("iv", SAT1, trip=False, now=at(13, 11, 59))
|
||||||
|
assert "phone:2026-11-14" not in states(p, at(13, 14))
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_trip_answer_given_at_creation_has_no_phone() -> None:
|
||||||
|
p = make(weekend_answers={SAT1: False})
|
||||||
|
got = states(p, at(13, 14))
|
||||||
|
assert got["trip_ask:2026-11-14"] == "done"
|
||||||
|
assert "phone:2026-11-14" not in got
|
||||||
72
focus_project/focus_project/tests/test_status_interview.py
Normal file
72
focus_project/focus_project/tests/test_status_interview.py
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""status(day) carries the milestones for i3bar / daily-limits / tutor."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import focus_project as fp
|
||||||
|
from focus_project import _clock, evidence
|
||||||
|
from focus_project.tests.helpers import at, make
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def _now(monkeypatch: pytest.MonkeyPatch, day: int, hour: int) -> None:
|
||||||
|
stamp = at(day, hour).timestamp()
|
||||||
|
monkeypatch.setattr(_clock, "now", lambda: stamp)
|
||||||
|
|
||||||
|
|
||||||
|
def test_idle_status_has_the_keys() -> None:
|
||||||
|
snap = fp.status(date(2026, 11, 10))
|
||||||
|
assert snap["milestones"] == []
|
||||||
|
assert snap["missed"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_lists_milestones_and_goes_red(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
make()
|
||||||
|
_now(monkeypatch, 10, 9)
|
||||||
|
snap = fp.status(date(2026, 11, 10))
|
||||||
|
assert snap["missed"] is False
|
||||||
|
assert snap["milestones"][0]["id"] == "rules"
|
||||||
|
assert set(snap["milestones"][0]) == {"id", "due", "state"}
|
||||||
|
_now(monkeypatch, 11, 9)
|
||||||
|
snap = fp.status(date(2026, 11, 11))
|
||||||
|
assert snap["missed"] is True
|
||||||
|
assert snap["milestones"][0]["state"] == "missed"
|
||||||
|
assert snap["milestones"][0]["due"] == "2026-11-10T23:59:00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_stays_red_on_test_day(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
make()
|
||||||
|
_now(monkeypatch, 27, 9)
|
||||||
|
assert fp.status(date(2026, 11, 27))["missed"] is True
|
||||||
|
assert fp.owed_lock(at(27, 9)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_survives_unreadable_evidence(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
make()
|
||||||
|
_now(monkeypatch, 11, 9)
|
||||||
|
|
||||||
|
def boom(project_id: str) -> list[evidence.Evidence]:
|
||||||
|
msg = "disk"
|
||||||
|
raise OSError(msg)
|
||||||
|
|
||||||
|
monkeypatch.setattr(evidence, "read", boom)
|
||||||
|
with caplog.at_level("WARNING"):
|
||||||
|
snap = fp.status(date(2026, 11, 11))
|
||||||
|
assert snap["milestones"] == []
|
||||||
|
assert snap["missed"] is False
|
||||||
|
assert "evidence unreadable" in caplog.text
|
||||||
|
assert fp.owed_lock(at(11, 9)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_isolation_canary(tmp_path: Path) -> None:
|
||||||
|
make()
|
||||||
|
assert not (tmp_path / "data" / "evidence").exists()
|
||||||
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "focus_project"
|
name = "focus_project"
|
||||||
version = "0.8.0"
|
version = "0.10.0"
|
||||||
description = "Time-boxed focus projects: replace chosen gates, pay a daily-minutes gate"
|
description = "Time-boxed focus projects: replace chosen gates, pay a daily-minutes gate"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
# Stdlib only on purpose: guards import it from the system python.
|
# Stdlib only on purpose: guards import it from the system python.
|
||||||
|
|||||||
@ -112,8 +112,10 @@ repos:
|
|||||||
- id: pylint
|
- id: pylint
|
||||||
args:
|
args:
|
||||||
- --rcfile=leetcode_coach/pyproject.toml
|
- --rcfile=leetcode_coach/pyproject.toml
|
||||||
- --fail-under=8.0
|
- --fail-under=10
|
||||||
- --jobs=4
|
# --jobs=1: pylint's parallel mode pickles its state with dill,
|
||||||
|
# which crashes on Python 3.15 (no code.co_lnotab).
|
||||||
|
- --jobs=1
|
||||||
- leetcode_coach/leetcode_coach
|
- leetcode_coach/leetcode_coach
|
||||||
# Whole package, never the staged subset: a score over an arbitrary
|
# Whole package, never the staged subset: a score over an arbitrary
|
||||||
# handful of files is an arbitrary number. Kept in step with
|
# handful of files is an arbitrary number. Kept in step with
|
||||||
|
|||||||
@ -15,6 +15,7 @@ write is logged and retried in order; the relay never exits over it.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
import functools
|
import functools
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@ -54,6 +55,14 @@ def _pairs(event: sse.Event) -> list[tuple[list[str], object]]:
|
|||||||
return [(base, event.data)]
|
return [(base, event.data)]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class _ChatMirror:
|
||||||
|
"""Which session's chat RTDB holds, and the line keys already sent."""
|
||||||
|
|
||||||
|
sid: str | None = None
|
||||||
|
sent: set[str] = dataclasses.field(default_factory=set)
|
||||||
|
|
||||||
|
|
||||||
class PhoneLink:
|
class PhoneLink:
|
||||||
"""Mirrors the relay into RTDB and feeds RTDB requests to the lane."""
|
"""Mirrors the relay into RTDB and feeds RTDB requests to the lane."""
|
||||||
|
|
||||||
@ -78,15 +87,14 @@ class PhoneLink:
|
|||||||
self._rescan_every = rescan_s
|
self._rescan_every = rescan_s
|
||||||
self.outbox = Outbox()
|
self.outbox = Outbox()
|
||||||
self._mirror_lock = threading.Lock()
|
self._mirror_lock = threading.Lock()
|
||||||
self._chat_sid: str | None = None
|
self._chat = _ChatMirror()
|
||||||
self._sent: set[str] = set()
|
|
||||||
|
|
||||||
def attach(self) -> None:
|
def attach(self) -> None:
|
||||||
"""Listen to the lane and the backend; then mirror everything once."""
|
"""Listen to the lane and the backend; then mirror everything once."""
|
||||||
self.lane.on_result.append(self.on_result)
|
self.lane.on_result.append(self.on_result)
|
||||||
self.lane.on_busy.append(lambda _busy: self.beat())
|
self.lane.on_busy.append(lambda _busy: self.beat())
|
||||||
self.coach.listeners.append(self.on_state)
|
self.coach.listeners.append(self.on_state)
|
||||||
self._chat_sid = None
|
self._chat.sid = None
|
||||||
self.on_state(self.coach.state(), self.coach.session)
|
self.on_state(self.coach.state(), self.coach.session)
|
||||||
|
|
||||||
def beat(self) -> None:
|
def beat(self) -> None:
|
||||||
@ -111,18 +119,18 @@ class PhoneLink:
|
|||||||
text = json.dumps(state, ensure_ascii=False)
|
text = json.dumps(state, ensure_ascii=False)
|
||||||
with self._mirror_lock:
|
with self._mirror_lock:
|
||||||
sid = None if session is None else session.sid
|
sid = None if session is None else session.sid
|
||||||
if sid != self._chat_sid:
|
if sid != self._chat.sid:
|
||||||
self.outbox.put(
|
self.outbox.put(
|
||||||
"chat reset", functools.partial(self.rtdb.delete, "chat")
|
"chat reset", functools.partial(self.rtdb.delete, "chat")
|
||||||
)
|
)
|
||||||
self._chat_sid, self._sent = sid, set()
|
self._chat = _ChatMirror(sid)
|
||||||
lines = {} if session is None else dict(session.chat)
|
lines = {} if session is None else dict(session.chat)
|
||||||
new: dict[str, object] = {
|
new: dict[str, object] = {
|
||||||
k: v for k, v in lines.items() if k not in self._sent
|
k: v for k, v in lines.items() if k not in self._chat.sent
|
||||||
}
|
}
|
||||||
if new:
|
if new:
|
||||||
self.outbox.put("chat", functools.partial(self.rtdb.patch, "chat", new))
|
self.outbox.put("chat", functools.partial(self.rtdb.patch, "chat", new))
|
||||||
self._sent |= set(new)
|
self._chat.sent |= set(new)
|
||||||
put = functools.partial(self.rtdb.put, "state", text)
|
put = functools.partial(self.rtdb.put, "state", text)
|
||||||
self.outbox.put("state", put, latest="state")
|
self.outbox.put("state", put, latest="state")
|
||||||
self.beat()
|
self.beat()
|
||||||
|
|||||||
@ -8,10 +8,18 @@ mechanically (no model, nothing to fail open), and the verdict rides the same
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from typing import Final
|
from typing import Final, NamedTuple
|
||||||
|
|
||||||
from leetcode_coach import req_check
|
from leetcode_coach import req_check
|
||||||
|
|
||||||
|
|
||||||
|
class Facet(NamedTuple):
|
||||||
|
"""One part of an output shape: how to spot it, and what to ask for."""
|
||||||
|
|
||||||
|
pattern: re.Pattern[str]
|
||||||
|
hint: str
|
||||||
|
|
||||||
|
|
||||||
VIA: Final = "shape"
|
VIA: Final = "shape"
|
||||||
SHAPE_RE: Final = re.compile(r"^\s*shape\s*:", re.IGNORECASE)
|
SHAPE_RE: Final = re.compile(r"^\s*shape\s*:", re.IGNORECASE)
|
||||||
SQL_LANGS: Final = frozenset(
|
SQL_LANGS: Final = frozenset(
|
||||||
@ -20,15 +28,15 @@ SQL_LANGS: Final = frozenset(
|
|||||||
LANGUAGES_RE: Final = re.compile(r"^Languages available:(.*)$", re.MULTILINE)
|
LANGUAGES_RE: Final = re.compile(r"^Languages available:(.*)$", re.MULTILINE)
|
||||||
TAGS_RE: Final = re.compile(r"^Tags:(.*)$", re.MULTILINE)
|
TAGS_RE: Final = re.compile(r"^Tags:(.*)$", re.MULTILINE)
|
||||||
FACETS: Final = {
|
FACETS: Final = {
|
||||||
"columns": (
|
"columns": Facet(
|
||||||
re.compile(r"\b(?:columns?|fields?|headers?|named)\b", re.IGNORECASE),
|
re.compile(r"\b(?:columns?|fields?|headers?|named)\b", re.IGNORECASE),
|
||||||
"which columns come back, named how",
|
"which columns come back, named how",
|
||||||
),
|
),
|
||||||
"rows": (
|
"rows": Facet(
|
||||||
re.compile(r"\b(?:rows?|records?|per)\b", re.IGNORECASE),
|
re.compile(r"\b(?:rows?|records?|per)\b", re.IGNORECASE),
|
||||||
"what one row stands for (one row per ...)",
|
"what one row stands for (one row per ...)",
|
||||||
),
|
),
|
||||||
"grouping": (
|
"grouping": Facet(
|
||||||
re.compile(
|
re.compile(
|
||||||
r"\b(?:group\w*|aggregat\w*|distinct|unique|per|sum|count|ungrouped)\b",
|
r"\b(?:group\w*|aggregat\w*|distinct|unique|per|sum|count|ungrouped)\b",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
@ -49,7 +57,9 @@ def is_sql(statement: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def missing_facets(text: str) -> tuple[str, ...]:
|
def missing_facets(text: str) -> tuple[str, ...]:
|
||||||
return tuple(name for name, (found, _) in FACETS.items() if not found.search(text))
|
return tuple(
|
||||||
|
name for name, facet in FACETS.items() if not facet.pattern.search(text)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def check(text: str) -> req_check.Check:
|
def check(text: str) -> req_check.Check:
|
||||||
@ -58,4 +68,4 @@ def check(text: str) -> req_check.Check:
|
|||||||
|
|
||||||
|
|
||||||
def hints(missing: tuple[str, ...] | list[str]) -> list[str]:
|
def hints(missing: tuple[str, ...] | list[str]) -> list[str]:
|
||||||
return [f"{name}: {FACETS[name][1]}" for name in missing if name in FACETS]
|
return [f"{name}: {FACETS[name].hint}" for name in missing if name in FACETS]
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
# none of them at import time.
|
# none of them at import time.
|
||||||
bandit[toml]==1.9.4
|
bandit[toml]==1.9.4
|
||||||
codespell==2.4.3
|
codespell==2.4.3
|
||||||
coverage==7.17.0
|
coverage==7.17.2
|
||||||
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
|
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
|
||||||
genanki==0.13.1
|
genanki==0.13.1
|
||||||
mypy==2.4.1
|
mypy==2.4.1
|
||||||
@ -13,8 +13,8 @@ pre-commit==4.7.0
|
|||||||
pylint==4.1.3
|
pylint==4.1.3
|
||||||
pytest==9.2.0
|
pytest==9.2.0
|
||||||
pytest-cov==7.1.0
|
pytest-cov==7.1.0
|
||||||
pytest-randomly==5.0.0
|
pytest-randomly==5.1.0
|
||||||
pytest-xdist==3.8.0
|
pytest-xdist==3.8.0
|
||||||
requests==2.34.2
|
requests==2.34.2
|
||||||
ruff==0.17.3
|
ruff==0.17.4
|
||||||
types-requests==2.33.0.20261030
|
types-requests==2.33.0.20261030
|
||||||
|
|||||||
@ -132,10 +132,7 @@ def rewrite(text: str, old: Path, new: Path) -> str:
|
|||||||
|
|
||||||
|
|
||||||
#: The remaining files are plain reference docs, renamed mechanically to
|
#: The remaining files are plain reference docs, renamed mechanically to
|
||||||
#: DOCS-<existing-slug>.md. The one exception is the dopamine-ux programme:
|
#: DOCS-<existing-slug>.md.
|
||||||
#: 00-INDEX records 8 of its 9 parts as "not started", so those parts are
|
|
||||||
#: outstanding work and become TODO, not DOCS. The index itself is a record.
|
|
||||||
AUTO_TASK_PREFIXES = ("utils/dopamine-ux/0",)
|
|
||||||
|
|
||||||
|
|
||||||
def slugify(stem: str) -> str:
|
def slugify(stem: str) -> str:
|
||||||
@ -150,8 +147,6 @@ def auto_name(rel: str) -> tuple[str, str]:
|
|||||||
# A misspelled README is a README, not a doc needing a new namespace.
|
# A misspelled README is a README, not a doc needing a new namespace.
|
||||||
if stem.lower() == "readme":
|
if stem.lower() == "readme":
|
||||||
return "README.md", "record"
|
return "README.md", "record"
|
||||||
if rel.startswith(AUTO_TASK_PREFIXES) and "INDEX" not in stem:
|
|
||||||
return f"TODO-{slugify(stem)}.md", "task"
|
|
||||||
return f"DOCS-{slugify(stem)}.md", "record"
|
return f"DOCS-{slugify(stem)}.md", "record"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -42,7 +42,6 @@
|
|||||||
"testsAndMisc/docs/shell-split-verification.md": "DOCS-shell-split-verification.md",
|
"testsAndMisc/docs/shell-split-verification.md": "DOCS-shell-split-verification.md",
|
||||||
"testsAndMisc/linux_configuration/docs/SUMMARY.md": "DOCS-pacman-security-layers.md",
|
"testsAndMisc/linux_configuration/docs/SUMMARY.md": "DOCS-pacman-security-layers.md",
|
||||||
"utils/gatelock/docs/scrollable-viewport.md": "DOCS-scrollable-viewport.md",
|
"utils/gatelock/docs/scrollable-viewport.md": "DOCS-scrollable-viewport.md",
|
||||||
"utils/dopamine-ux/00-INDEX.md": "DOCS-dopamine-ux-index.md",
|
|
||||||
"digital-wellbeing/llm-notes/units-and-tasks.md": "DOCS-shutdown-units-reference.md",
|
"digital-wellbeing/llm-notes/units-and-tasks.md": "DOCS-shutdown-units-reference.md",
|
||||||
"digital-wellbeing/README_COMPULSIVE_BLOCK_LLM.md": "DOCS-compulsive-block-reference.md",
|
"digital-wellbeing/README_COMPULSIVE_BLOCK_LLM.md": "DOCS-compulsive-block-reference.md",
|
||||||
"digital-wellbeing/README_MIDNIGHT_SHUTDOWN_LLM.md": "DOCS-midnight-shutdown-reference.md",
|
"digital-wellbeing/README_MIDNIGHT_SHUTDOWN_LLM.md": "DOCS-midnight-shutdown-reference.md",
|
||||||
|
|||||||
@ -90,12 +90,6 @@ def test_auto_name_fixes_readme_casing() -> None:
|
|||||||
assert auto_name("x/Readme.md") == ("README.md", "record")
|
assert auto_name("x/Readme.md") == ("README.md", "record")
|
||||||
|
|
||||||
|
|
||||||
def test_dopamine_ux_parts_are_tasks_but_index_is_not() -> None:
|
|
||||||
"""00-INDEX records the programme; 01-09 are its unstarted parts."""
|
|
||||||
assert auto_name("utils/dopamine-ux/03-diet-guard.md")[1] == "task"
|
|
||||||
assert auto_name("utils/dopamine-ux/00-INDEX.md")[1] == "record"
|
|
||||||
|
|
||||||
|
|
||||||
def test_classified_paths_do_not_overlap() -> None:
|
def test_classified_paths_do_not_overlap() -> None:
|
||||||
"""A file cannot be both a task and a record."""
|
"""A file cannot be both a task and a record."""
|
||||||
assert not set(classify.TASKS) & set(classify.RECORDS)
|
assert not set(classify.TASKS) & set(classify.RECORDS)
|
||||||
|
|||||||
@ -93,6 +93,14 @@ web-relevant file is staged (`web.DEFAULT_FILES`, overridable with
|
|||||||
degrades to a pass, and the gate defers to that workflow when run inside
|
degrades to a pass, and the gate defers to that workflow when run inside
|
||||||
another GitHub Actions job.
|
another GitHub Actions job.
|
||||||
|
|
||||||
|
## Flutter apps: no Google non-free sign-in
|
||||||
|
|
||||||
|
A repo with a `pubspec.yaml` beside an `android/` dir (root or one level
|
||||||
|
down) also gets `scripts/check_no_gms_signin.sh` (a shim) and the always-run
|
||||||
|
`no-gms-signin` hook (`install_gms.py`). The shared gate fails on
|
||||||
|
google_sign_in / play-services-auth / googleid in pubspec.lock or gradle, or
|
||||||
|
in a built APK with `--apk`; apps use `google_account_token` instead.
|
||||||
|
|
||||||
## Flutter apps: the launcher rules
|
## Flutter apps: the launcher rules
|
||||||
|
|
||||||
Decided by kuhy 2026-10-23 (`launcher.py`). They apply to every tracked
|
Decided by kuhy 2026-10-23 (`launcher.py`). They apply to every tracked
|
||||||
|
|||||||
@ -25,6 +25,7 @@ from dep_freshness.install import NO_WORKFLOW_MARKER
|
|||||||
from repo_contract import bootstrap
|
from repo_contract import bootstrap
|
||||||
from repo_contract.check import violations
|
from repo_contract.check import violations
|
||||||
from repo_contract.infer import propose
|
from repo_contract.infer import propose
|
||||||
|
from repo_contract.install_gms import install_gms, plan_gms
|
||||||
from repo_contract.install_web import install_web, plan_web
|
from repo_contract.install_web import install_web, plan_web
|
||||||
from repo_contract.precommit import PRECOMMIT, needs_hook, write_hook
|
from repo_contract.precommit import PRECOMMIT, needs_hook, write_hook
|
||||||
from repo_contract.stacks import detect_stacks
|
from repo_contract.stacks import detect_stacks
|
||||||
@ -64,6 +65,7 @@ def plan(repo: Path) -> list[str]:
|
|||||||
|
|
||||||
A website (detected or declared in repo-contract.toml) also gets the
|
A website (detected or declared in repo-contract.toml) also gets the
|
||||||
AgentReady gate: config, shim, workflow and hook (see install_web.py).
|
AgentReady gate: config, shim, workflow and hook (see install_web.py).
|
||||||
|
A Flutter Android app also gets the no-GMS-sign-in gate (install_gms.py).
|
||||||
"""
|
"""
|
||||||
todo = []
|
todo = []
|
||||||
if _needs_shim(repo):
|
if _needs_shim(repo):
|
||||||
@ -72,7 +74,7 @@ def plan(repo: Path) -> list[str]:
|
|||||||
todo.append(str(WORKFLOW))
|
todo.append(str(WORKFLOW))
|
||||||
if needs_hook(repo, HOOK_ID):
|
if needs_hook(repo, HOOK_ID):
|
||||||
todo.append(f"{PRECOMMIT} ({HOOK_ID} hook)")
|
todo.append(f"{PRECOMMIT} ({HOOK_ID} hook)")
|
||||||
return todo + plan_web(repo)
|
return todo + plan_web(repo) + plan_gms(repo)
|
||||||
|
|
||||||
|
|
||||||
def install(repo: Path) -> list[str]:
|
def install(repo: Path) -> list[str]:
|
||||||
@ -92,7 +94,7 @@ def install(repo: Path) -> list[str]:
|
|||||||
if needs_hook(repo, HOOK_ID):
|
if needs_hook(repo, HOOK_ID):
|
||||||
write_hook(repo, _template("pre-commit-snippet.yaml"))
|
write_hook(repo, _template("pre-commit-snippet.yaml"))
|
||||||
done.append(f"{PRECOMMIT} ({HOOK_ID} hook)")
|
done.append(f"{PRECOMMIT} ({HOOK_ID} hook)")
|
||||||
return done + install_web(repo)
|
return done + install_web(repo) + install_gms(repo)
|
||||||
|
|
||||||
|
|
||||||
def bootstrap_commands(repo: Path) -> list[str]:
|
def bootstrap_commands(repo: Path) -> list[str]:
|
||||||
|
|||||||
62
repo_contract/install_gms.py
Normal file
62
repo_contract/install_gms.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
"""The no-GMS-sign-in half of the installer: Flutter Android apps get the gate.
|
||||||
|
|
||||||
|
Why it rides on install_repo_contract_gate.sh: that installer is the one step
|
||||||
|
every new repo gets after `git init`, so a new Flutter app cannot come out of
|
||||||
|
scaffolding able to re-add google_sign_in (13 apps were migrated off it onto
|
||||||
|
utils/google_account_token in November 2026). Pieces: the
|
||||||
|
scripts/check_no_gms_signin.sh delegate and an always-run pre-commit hook.
|
||||||
|
Only repos with a pubspec.yaml next to an android/ dir (at the root or one
|
||||||
|
level down, e.g. tutor/app) get it; utils itself holds the real gate.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from repo_contract.precommit import PRECOMMIT, needs_hook, write_hook
|
||||||
|
|
||||||
|
TEMPLATES = Path(__file__).resolve().parent / "templates"
|
||||||
|
SHIM = Path("scripts/check_no_gms_signin.sh")
|
||||||
|
HOOK_ID = "no-gms-signin"
|
||||||
|
HOOK_PIECE = f"{PRECOMMIT} ({HOOK_ID} hook)"
|
||||||
|
SHIM_TEMPLATE = "check_no_gms_signin.shim.sh"
|
||||||
|
|
||||||
|
|
||||||
|
def _template(name: str) -> str:
|
||||||
|
return (TEMPLATES / name).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def is_flutter_android(repo: Path) -> bool:
|
||||||
|
"""A pubspec.yaml beside an android/ dir, at the root or one level down."""
|
||||||
|
if (repo / "repo_contract" / "check.py").is_file():
|
||||||
|
return False # utils: SHIM is the real gate, never a delegate
|
||||||
|
pubspecs = [repo / "pubspec.yaml", *repo.glob("*/pubspec.yaml")]
|
||||||
|
return any(p.is_file() and (p.parent / "android").is_dir() for p in pubspecs)
|
||||||
|
|
||||||
|
|
||||||
|
def plan_gms(repo: Path) -> list[str]:
|
||||||
|
"""Which no-GMS-sign-in gate pieces are missing or drifted."""
|
||||||
|
if not is_flutter_android(repo):
|
||||||
|
return []
|
||||||
|
todo = []
|
||||||
|
shim = repo / SHIM
|
||||||
|
if not shim.is_file() or shim.read_text(encoding="utf-8") != _template(
|
||||||
|
SHIM_TEMPLATE
|
||||||
|
):
|
||||||
|
todo.append(str(SHIM))
|
||||||
|
if needs_hook(repo, HOOK_ID):
|
||||||
|
todo.append(HOOK_PIECE)
|
||||||
|
return todo
|
||||||
|
|
||||||
|
|
||||||
|
def install_gms(repo: Path) -> list[str]:
|
||||||
|
"""Write every missing or drifted no-GMS-sign-in piece. Returns them."""
|
||||||
|
todo = plan_gms(repo)
|
||||||
|
if str(SHIM) in todo:
|
||||||
|
target = repo / SHIM
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target.write_text(_template(SHIM_TEMPLATE), encoding="utf-8")
|
||||||
|
target.chmod(0o755)
|
||||||
|
if HOOK_PIECE in todo:
|
||||||
|
write_hook(repo, _template("no-gms-signin-hook.yaml"))
|
||||||
|
return todo
|
||||||
27
repo_contract/templates/check_no_gms_signin.shim.sh
Normal file
27
repo_contract/templates/check_no_gms_signin.shim.sh
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Fail if this app links Google's non-free sign-in (google_sign_in,
|
||||||
|
# play-services-auth, googleid) instead of google_account_token.
|
||||||
|
#
|
||||||
|
# Thin delegate to the shared gate in ~/src/utils, which owns the package
|
||||||
|
# list and the fix message. Copy this file to scripts/check_no_gms_signin.sh
|
||||||
|
# in a repo; never copy the shared gate itself.
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
readonly SHARED_GATE="${UTILS_ROOT:-$HOME/src/utils}/scripts/check_no_gms_signin.sh"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
if [[ ! -x "$SHARED_GATE" ]]; then
|
||||||
|
echo "Error: shared no-gms-signin gate not found at $SHARED_GATE" >&2
|
||||||
|
echo " Clone github.com/kuhyx/utils to ~/src/utils, or set" >&2
|
||||||
|
echo " UTILS_ROOT to where it lives." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec bash "$SHARED_GATE" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
12
repo_contract/templates/no-gms-signin-hook.yaml
Normal file
12
repo_contract/templates/no-gms-signin-hook.yaml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# Added under `repos: - repo: local: hooks:` by install_repo_contract_gate.sh
|
||||||
|
# for any repo with a pubspec.yaml beside an android/ dir (a Flutter app).
|
||||||
|
# No Google non-free sign-in: google_sign_in links play-services-auth /
|
||||||
|
# googleid into the APK; apps use utils/google_account_token instead.
|
||||||
|
# always_run rather than `files:`: a poisoned pubspec.lock can arrive by
|
||||||
|
# pull or merge, and a staged-only trigger would never look again.
|
||||||
|
- id: no-gms-signin
|
||||||
|
name: no google_sign_in / GMS auth (use google_account_token)
|
||||||
|
entry: scripts/check_no_gms_signin.sh .
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
always_run: true
|
||||||
77
repo_contract/tests/test_install_gms.py
Normal file
77
repo_contract/tests/test_install_gms.py
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
"""A Flutter repo with an Android target gets the no-GMS-sign-in gate."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from repo_contract import install as inst
|
||||||
|
from repo_contract import install_gms as gms
|
||||||
|
|
||||||
|
from .conftest import write
|
||||||
|
|
||||||
|
|
||||||
|
def flutter_android(root: Path, sub: str = "") -> Path:
|
||||||
|
"""A Flutter project at `root/sub` with an android/ dir."""
|
||||||
|
base = f"{sub}/" if sub else ""
|
||||||
|
write(root, f"{base}pubspec.yaml", "name: x\n")
|
||||||
|
write(root, f"{base}android/app/build.gradle.kts", "plugins {}\n")
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_pubspec_means_no_gate(repo: Path) -> None:
|
||||||
|
assert gms.plan_gms(repo) == []
|
||||||
|
assert gms.install_gms(repo) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_pubspec_without_android_means_no_gate(repo: Path) -> None:
|
||||||
|
write(repo, "pubspec.yaml", "name: x\n")
|
||||||
|
assert gms.plan_gms(repo) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_root_flutter_android_gets_shim_and_hook(repo: Path) -> None:
|
||||||
|
flutter_android(repo)
|
||||||
|
assert gms.plan_gms(repo) == [str(gms.SHIM), gms.HOOK_PIECE]
|
||||||
|
assert inst.install(repo)[-2:] == [str(gms.SHIM), gms.HOOK_PIECE]
|
||||||
|
shim = repo / gms.SHIM
|
||||||
|
assert shim.stat().st_mode & stat.S_IXUSR
|
||||||
|
config = (repo / inst.PRECOMMIT).read_text(encoding="utf-8")
|
||||||
|
assert f"id: {gms.HOOK_ID}" in config
|
||||||
|
assert "always_run: true" in config
|
||||||
|
assert gms.plan_gms(repo) == []
|
||||||
|
assert gms.install_gms(repo) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_nested_app_dir_counts(repo: Path) -> None:
|
||||||
|
flutter_android(repo, "app")
|
||||||
|
assert gms.plan_gms(repo) == [str(gms.SHIM), gms.HOOK_PIECE]
|
||||||
|
|
||||||
|
|
||||||
|
def test_drifted_shim_is_rewritten_hook_kept(repo: Path) -> None:
|
||||||
|
flutter_android(repo)
|
||||||
|
gms.install_gms(repo)
|
||||||
|
write(repo, gms.SHIM.as_posix(), "#!/bin/bash\nexit 0\n")
|
||||||
|
assert gms.install_gms(repo) == [str(gms.SHIM)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_utils_itself_is_never_given_a_shim(repo: Path) -> None:
|
||||||
|
flutter_android(repo, "google_account_token")
|
||||||
|
write(repo, "repo_contract/check.py")
|
||||||
|
assert gms.plan_gms(repo) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_installed_shim_delegates_to_the_shared_gate(repo: Path) -> None:
|
||||||
|
flutter_android(repo)
|
||||||
|
write(repo, "pubspec.lock", "packages:\n google_sign_in_android:\n")
|
||||||
|
gms.install_gms(repo)
|
||||||
|
utils_root = Path(__file__).resolve().parents[2]
|
||||||
|
proc = subprocess.run(
|
||||||
|
["bash", str(repo / gms.SHIM), str(repo)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
env={"UTILS_ROOT": str(utils_root), "PATH": "/usr/bin:/bin"},
|
||||||
|
)
|
||||||
|
assert proc.returncode == 1
|
||||||
|
assert "google_account_token" in proc.stderr
|
||||||
193
scripts/check_no_gms_signin.sh
Executable file
193
scripts/check_no_gms_signin.sh
Executable file
@ -0,0 +1,193 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Fail if a Flutter/Android app links Google's non-free sign-in stack.
|
||||||
|
#
|
||||||
|
# Every app signs in through kuhy's FOSS plugin google_account_token
|
||||||
|
# (utils/google_account_token, Android AccountManager). google_sign_in pulls
|
||||||
|
# google_sign_in_android, which links play-services-auth / googleid into the
|
||||||
|
# APK. Nine apps were migrated off it on 2026-11-02 and four more on
|
||||||
|
# 2026-11-10; nothing else stops a later session from re-adding it.
|
||||||
|
#
|
||||||
|
# Lockfile mode (default) reads pubspec.lock package keys, plus the gradle
|
||||||
|
# files under each lock's sibling android/ dir. Web-only pieces
|
||||||
|
# (google_sign_in_web, google_sign_in_platform_interface and their transitive
|
||||||
|
# google_identity_services_web) are allowed while google_sign_in_android is
|
||||||
|
# absent: kartka's web build uses them for the GIS button and they never
|
||||||
|
# reach an APK. APK mode looks for the GMS auth library marker files in the
|
||||||
|
# built APK, the ground truth when a dependency arrives some other way.
|
||||||
|
#
|
||||||
|
# Usage: check_no_gms_signin.sh [pubspec.lock | dir ...]
|
||||||
|
# check_no_gms_signin.sh --apk app.apk
|
||||||
|
# A dir is searched for pubspec.lock files (build/, .dart_tool/ pruned).
|
||||||
|
# With no arguments, checks ./pubspec.lock (passes if there is none).
|
||||||
|
# Exit: 0 clean | 1 non-free sign-in found | 2 usage error
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_NAME="$(basename "$0")"
|
||||||
|
readonly SCRIPT_NAME
|
||||||
|
readonly BANNED=(google_sign_in google_sign_in_android google_sign_in_ios)
|
||||||
|
readonly WEB_ONLY=(google_sign_in_web google_sign_in_platform_interface
|
||||||
|
google_identity_services_web)
|
||||||
|
# Maven coordinates (and split-form catalog names) of the GMS sign-in stack.
|
||||||
|
# Anchored so com.google.gms:google-services (Firebase) and
|
||||||
|
# play-services-auth-api-phone do not trip it.
|
||||||
|
readonly GRADLE_PATTERN='com\.google\.android\.gms:play-services-(auth|identity-credentials)([^-[:alnum:]]|$)|com\.google\.android\.libraries\.identity\.googleid|androidx\.credentials:credentials-play-services-auth|name[[:space:]]*=[[:space:]]*"(play-services-auth|play-services-identity-credentials|credentials-play-services-auth|googleid)"'
|
||||||
|
# APK mode: library marker files AGP copies to the APK root (or META-INF),
|
||||||
|
# one per linked artifact. Dex strings are not used: play-services-base and
|
||||||
|
# -basement reference GoogleSignInAccount without linking play-services-auth.
|
||||||
|
# play-services-fido (passkeys) is deliberately allowed.
|
||||||
|
readonly APK_MARKERS=(
|
||||||
|
googleid.properties
|
||||||
|
play-services-auth.properties
|
||||||
|
play-services-auth-base.properties
|
||||||
|
play-services-auth-api-phone.properties
|
||||||
|
play-services-auth-blockstore.properties
|
||||||
|
play-services-identity-credentials.properties
|
||||||
|
META-INF/androidx.credentials_credentials-play-services-auth.version
|
||||||
|
)
|
||||||
|
readonly PRUNE=(-name build -o -name .dart_tool -o -name .git -o -name .gradle -o -name .claude
|
||||||
|
-o -name node_modules)
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: $SCRIPT_NAME [pubspec.lock | dir ...]"
|
||||||
|
echo " $SCRIPT_NAME --apk app.apk"
|
||||||
|
echo " Fails when an app links Google's non-free sign-in (GMS auth)."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fix_hint() {
|
||||||
|
echo " Use google_account_token instead (FOSS, AccountManager):" >&2
|
||||||
|
echo " google_account_token:" >&2
|
||||||
|
echo " git:" >&2
|
||||||
|
echo " url: https://github.com/kuhyx/utils" >&2
|
||||||
|
echo " ref: google_account_token-v0.1.0" >&2
|
||||||
|
echo " path: google_account_token" >&2
|
||||||
|
echo " See utils/google_account_token/README.md." >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
# Package keys of a pubspec.lock: the 2-space keys under `packages:`.
|
||||||
|
lock_packages() {
|
||||||
|
awk '
|
||||||
|
/^packages:[[:space:]]*$/ { in_packages = 1; next }
|
||||||
|
/^[^[:space:]]/ { in_packages = 0; next }
|
||||||
|
in_packages && /^ [A-Za-z0-9_]+:[[:space:]]*$/ {
|
||||||
|
sub(/^ /, ""); sub(/:.*/, ""); print
|
||||||
|
}
|
||||||
|
' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prints the offending packages of one lock, one per line.
|
||||||
|
lock_offenders() {
|
||||||
|
local -A present=()
|
||||||
|
local name
|
||||||
|
while IFS= read -r name; do
|
||||||
|
present["$name"]=1
|
||||||
|
done < <(lock_packages "$1")
|
||||||
|
for name in "${BANNED[@]}"; do
|
||||||
|
[[ -n "${present[$name]:-}" ]] && echo "$name"
|
||||||
|
done
|
||||||
|
if [[ -n "${present[google_sign_in_android]:-}" ]]; then
|
||||||
|
for name in "${WEB_ONLY[@]}"; do
|
||||||
|
[[ -n "${present[$name]:-}" ]] && echo "$name"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prints file:line:text for every non-comment GMS sign-in coordinate under
|
||||||
|
# the lock's sibling android/ dir.
|
||||||
|
gradle_offenders() {
|
||||||
|
local android_dir="$1/android" file
|
||||||
|
[[ -d "$android_dir" ]] || return 0
|
||||||
|
while IFS= read -r -d '' file; do
|
||||||
|
grep -nE "$GRADLE_PATTERN" "$file" \
|
||||||
|
| grep -vE '^[0-9]+:[[:space:]]*(//|#|\*|/\*)' \
|
||||||
|
| sed "s|^|$file:|" || true
|
||||||
|
done < <(find "$android_dir" \( "${PRUNE[@]}" \) -prune -o -type f \
|
||||||
|
\( -name 'build.gradle*' -o -name '*.versions.toml' \) -print0)
|
||||||
|
}
|
||||||
|
|
||||||
|
indent() {
|
||||||
|
local line
|
||||||
|
while IFS= read -r line; do
|
||||||
|
echo " $line"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
check_lock() {
|
||||||
|
local lock="$1" offenders gradle
|
||||||
|
offenders="$(lock_offenders "$lock")"
|
||||||
|
gradle="$(gradle_offenders "$(dirname "$lock")")"
|
||||||
|
[[ -z "$offenders" && -z "$gradle" ]] && return 0
|
||||||
|
if [[ -n "$offenders" ]]; then
|
||||||
|
echo "Error: $lock links Google's non-free sign-in:" >&2
|
||||||
|
indent <<<"$offenders" >&2
|
||||||
|
fi
|
||||||
|
if [[ -n "$gradle" ]]; then
|
||||||
|
echo "Error: gradle names the GMS sign-in stack:" >&2
|
||||||
|
indent <<<"$gradle" >&2
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
check_apk() {
|
||||||
|
local apk="$1" entries found=()
|
||||||
|
local marker
|
||||||
|
[[ -f "$apk" ]] || { echo "Error: no such APK: $apk" >&2; exit 2; }
|
||||||
|
entries="$(unzip -Z1 "$apk")"
|
||||||
|
for marker in "${APK_MARKERS[@]}"; do
|
||||||
|
grep -qxF -- "$marker" <<<"$entries" && found+=("$marker")
|
||||||
|
done
|
||||||
|
[[ "${#found[@]}" -eq 0 ]] && return 0
|
||||||
|
echo "Error: $apk bundles Google's non-free sign-in libraries:" >&2
|
||||||
|
printf ' %s\n' "${found[@]}" >&2
|
||||||
|
fix_hint
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Expands the arguments into pubspec.lock paths, NUL-separated.
|
||||||
|
collect_locks() {
|
||||||
|
local arg
|
||||||
|
for arg in "$@"; do
|
||||||
|
if [[ -d "$arg" ]]; then
|
||||||
|
find "$arg" \( "${PRUNE[@]}" \) -prune -o -type f \
|
||||||
|
-name pubspec.lock -print0
|
||||||
|
else
|
||||||
|
printf '%s\0' "$arg"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
case "${1:-}" in
|
||||||
|
-h|--help) usage ;;
|
||||||
|
--apk)
|
||||||
|
[[ $# -eq 2 ]] || { echo "Usage: $SCRIPT_NAME --apk app.apk" >&2; exit 2; }
|
||||||
|
check_apk "$2"
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if [[ $# -eq 0 ]]; then
|
||||||
|
[[ -f pubspec.lock ]] || return 0
|
||||||
|
set -- pubspec.lock
|
||||||
|
fi
|
||||||
|
|
||||||
|
local -a locks=()
|
||||||
|
local arg lock failed=0
|
||||||
|
# Validated up front: collect_locks runs in a process substitution, whose
|
||||||
|
# exit status would be lost, and a typo'd path must not pass silently.
|
||||||
|
for arg in "$@"; do
|
||||||
|
[[ -e "$arg" ]] || { echo "Error: no such file or directory: $arg" >&2; exit 2; }
|
||||||
|
done
|
||||||
|
mapfile -d '' locks < <(collect_locks "$@")
|
||||||
|
for lock in "${locks[@]}"; do
|
||||||
|
check_lock "$lock" || failed=1
|
||||||
|
done
|
||||||
|
[[ "$failed" -eq 0 ]] || fix_hint
|
||||||
|
return "$failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@ -9,6 +9,7 @@
|
|||||||
# when the repo has .dep-freshness-no-workflow), Commands section. A website
|
# when the repo has .dep-freshness-no-workflow), Commands section. A website
|
||||||
# (Vite/Astro/index.html build, or [agent_ready] in repo-contract.toml) also
|
# (Vite/Astro/index.html build, or [agent_ready] in repo-contract.toml) also
|
||||||
# gets the AgentReady gate: config table, shim, hook, agent-ready workflow.
|
# gets the AgentReady gate: config table, shim, hook, agent-ready workflow.
|
||||||
|
# A Flutter app with android/ also gets the no-GMS-sign-in gate (shim + hook).
|
||||||
#
|
#
|
||||||
# Usage: scripts/install_repo_contract_gate.sh <repo> [--check] [--no-bootstrap]
|
# Usage: scripts/install_repo_contract_gate.sh <repo> [--check] [--no-bootstrap]
|
||||||
# Exit: 0 contract satisfied | 1 violations left (e.g. <FILL IN>) | 2 not a git repo
|
# Exit: 0 contract satisfied | 1 violations left (e.g. <FILL IN>) | 2 not a git repo
|
||||||
|
|||||||
195
scripts/tests/test_check_no_gms_signin.py
Normal file
195
scripts/tests/test_check_no_gms_signin.py
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
"""check_no_gms_signin.sh: no app may link Google's non-free sign-in stack.
|
||||||
|
|
||||||
|
Each test writes a throwaway Flutter-shaped project (pubspec.lock, optional
|
||||||
|
android/ gradle files) or a fake APK built with zipfile, runs the real
|
||||||
|
script, and asserts on the exit code and the message.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SCRIPT = Path(__file__).resolve().parent.parent / "check_no_gms_signin.sh"
|
||||||
|
|
||||||
|
# The package keys of kuhy's real kartka lock (web-only GIS button).
|
||||||
|
KARTKA_KEYS = (
|
||||||
|
"google_account_token",
|
||||||
|
"google_identity_services_web",
|
||||||
|
"google_sign_in_platform_interface",
|
||||||
|
"google_sign_in_web",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _lock(root: Path, *packages: str) -> Path:
|
||||||
|
"""Write a pubspec.lock under `root` with the given package keys."""
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
body = ["# Generated by pub", "packages:"]
|
||||||
|
for name in ("collection", *packages):
|
||||||
|
body += [
|
||||||
|
f" {name}:",
|
||||||
|
' dependency: "direct main"',
|
||||||
|
" description:",
|
||||||
|
f" name: {name}",
|
||||||
|
" source: hosted",
|
||||||
|
' version: "1.0.0"',
|
||||||
|
]
|
||||||
|
body += ["sdks:", ' dart: ">=3.0.0 <4.0.0"', ""]
|
||||||
|
lock = root / "pubspec.lock"
|
||||||
|
lock.write_text("\n".join(body), encoding="utf-8")
|
||||||
|
return lock
|
||||||
|
|
||||||
|
|
||||||
|
def _run(*args: str | Path) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(
|
||||||
|
["bash", str(SCRIPT), *map(str, args)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_lock_passes(tmp_path: Path) -> None:
|
||||||
|
lock = _lock(tmp_path, "google_account_token", "http")
|
||||||
|
assert _run(lock).returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_google_sign_in_android_fails_and_names_the_fix(tmp_path: Path) -> None:
|
||||||
|
lock = _lock(tmp_path, "google_sign_in_android")
|
||||||
|
proc = _run(lock)
|
||||||
|
assert proc.returncode == 1
|
||||||
|
assert str(lock) in proc.stderr
|
||||||
|
assert "google_sign_in_android" in proc.stderr
|
||||||
|
assert "google_account_token-v0.1.0" in proc.stderr
|
||||||
|
assert "google_account_token/README.md" in proc.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_facing_google_sign_in_fails(tmp_path: Path) -> None:
|
||||||
|
proc = _run(_lock(tmp_path, "google_sign_in"))
|
||||||
|
assert proc.returncode == 1
|
||||||
|
assert "google_sign_in " in proc.stderr or "google_sign_in\n" in proc.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_kartka_style_web_only_passes(tmp_path: Path) -> None:
|
||||||
|
assert _run(_lock(tmp_path, *KARTKA_KEYS)).returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_web_pieces_fail_once_android_is_linked(tmp_path: Path) -> None:
|
||||||
|
proc = _run(_lock(tmp_path, *KARTKA_KEYS, "google_sign_in_android"))
|
||||||
|
assert proc.returncode == 1
|
||||||
|
assert "google_sign_in_web" in proc.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_gradle_play_services_auth_fails(tmp_path: Path) -> None:
|
||||||
|
lock = _lock(tmp_path)
|
||||||
|
gradle = tmp_path / "android" / "app" / "build.gradle.kts"
|
||||||
|
gradle.parent.mkdir(parents=True)
|
||||||
|
gradle.write_text(
|
||||||
|
'dependencies {\n implementation("com.google.gms:google-services:4")\n'
|
||||||
|
' implementation("com.google.android.gms:play-services-auth:21.3.0")\n}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
proc = _run(lock)
|
||||||
|
assert proc.returncode == 1
|
||||||
|
assert str(gradle) in proc.stderr
|
||||||
|
assert "play-services-auth" in proc.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_versions_toml_googleid_fails(tmp_path: Path) -> None:
|
||||||
|
lock = _lock(tmp_path)
|
||||||
|
toml = tmp_path / "android" / "gradle" / "libs.versions.toml"
|
||||||
|
toml.parent.mkdir(parents=True)
|
||||||
|
toml.write_text(
|
||||||
|
'googleid = "com.google.android.libraries.identity.googleid:googleid:1.1"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
assert _run(lock).returncode == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_firebase_google_services_plugin_passes(tmp_path: Path) -> None:
|
||||||
|
lock = _lock(tmp_path)
|
||||||
|
gradle = tmp_path / "android" / "build.gradle"
|
||||||
|
gradle.parent.mkdir(parents=True)
|
||||||
|
gradle.write_text(
|
||||||
|
"// play-services-auth was removed 2026-11-02\n"
|
||||||
|
"classpath 'com.google.gms:google-services:4.4.2'\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
assert _run(lock).returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_directory_mode_finds_nested_locks_and_prunes_build(tmp_path: Path) -> None:
|
||||||
|
_lock(tmp_path / "app", "google_account_token")
|
||||||
|
_lock(tmp_path / "app" / "build" / "x", "google_sign_in_android")
|
||||||
|
_lock(tmp_path / ".dart_tool" / "y", "google_sign_in_android")
|
||||||
|
# Agent worktrees are other checkouts, judged in their own repo.
|
||||||
|
_lock(tmp_path / ".claude" / "worktrees" / "z", "google_sign_in_android")
|
||||||
|
assert _run(tmp_path).returncode == 0
|
||||||
|
_lock(tmp_path / "other", "google_sign_in_ios")
|
||||||
|
proc = _run(tmp_path)
|
||||||
|
assert proc.returncode == 1
|
||||||
|
assert "google_sign_in_ios" in proc.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_explicit_file_is_a_usage_error(tmp_path: Path) -> None:
|
||||||
|
assert _run(tmp_path / "nope.lock").returncode == 2
|
||||||
|
|
||||||
|
|
||||||
|
CREDENTIALS_MARKER = "META-INF/androidx.credentials_credentials-play-services-auth.version"
|
||||||
|
DEX_REF = b"\x00Lcom/google/android/gms/auth/api/signin/GoogleSignInAccount;\x00" * 2
|
||||||
|
|
||||||
|
|
||||||
|
def _apk(path: Path, dex: bytes = b"", markers: tuple[str, ...] = ()) -> Path:
|
||||||
|
"""Fake APK: dex strings plus library marker files AGP copies in."""
|
||||||
|
with zipfile.ZipFile(path, "w") as apk:
|
||||||
|
apk.writestr("AndroidManifest.xml", b"\x03\x00")
|
||||||
|
apk.writestr("classes.dex", b"dex\n035\x00" + dex)
|
||||||
|
apk.writestr("classes2.dex", b"dex\n035\x00Lkotlin/Unit;\x00")
|
||||||
|
for name in markers:
|
||||||
|
apk.writestr(name, b"version=1")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def test_apk_with_only_dex_references_passes(tmp_path: Path) -> None:
|
||||||
|
"""play-services-base references GoogleSignInAccount without linking auth."""
|
||||||
|
markers = (
|
||||||
|
"play-services-base.properties",
|
||||||
|
"play-services-basement.properties",
|
||||||
|
"play-services-tasks.properties",
|
||||||
|
"play-services-fido.properties",
|
||||||
|
)
|
||||||
|
proc = _run("--apk", _apk(tmp_path / "ok.apk", DEX_REF, markers))
|
||||||
|
assert proc.returncode == 0, proc.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_apk_without_markers_passes(tmp_path: Path) -> None:
|
||||||
|
assert _run("--apk", _apk(tmp_path / "clean.apk")).returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_apk_banned_markers_fail_and_are_named(tmp_path: Path) -> None:
|
||||||
|
banned = (
|
||||||
|
"play-services-auth.properties",
|
||||||
|
"googleid.properties",
|
||||||
|
"play-services-auth-base.properties",
|
||||||
|
"play-services-auth-api-phone.properties",
|
||||||
|
"play-services-auth-blockstore.properties",
|
||||||
|
"play-services-identity-credentials.properties",
|
||||||
|
CREDENTIALS_MARKER,
|
||||||
|
)
|
||||||
|
for marker in banned:
|
||||||
|
apk = _apk(tmp_path / "bad.apk", DEX_REF, ("play-services-base.properties", marker))
|
||||||
|
proc = _run("--apk", apk)
|
||||||
|
assert proc.returncode == 1, marker
|
||||||
|
assert marker in proc.stderr
|
||||||
|
assert "google_account_token" in proc.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_apk_nested_marker_name_is_not_a_root_marker(tmp_path: Path) -> None:
|
||||||
|
"""Only APK-root files count; a same-named file in a subdir is not AGP's."""
|
||||||
|
apk = _apk(tmp_path / "nested.apk", markers=("assets/play-services-auth.properties",))
|
||||||
|
assert _run("--apk", apk).returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_apk_missing_file_is_a_usage_error(tmp_path: Path) -> None:
|
||||||
|
assert _run("--apk", tmp_path / "nope.apk").returncode == 2
|
||||||
@ -70,22 +70,35 @@ refused (past day, already sick, not sick, a free day, sync unavailable),
|
|||||||
## The picker window (0.3.0)
|
## The picker window (0.3.0)
|
||||||
|
|
||||||
`sickdays gui` (or the "Sick days" launcher): the phone's Sick days screen on
|
`sickdays gui` (or the "Sick days" launcher): the phone's Sick days screen on
|
||||||
Tk -- calendar, a five-switch panel per day, the days ahead. Past days are
|
Tk -- calendar, a six-switch panel per day, the days ahead. Past days are
|
||||||
inert; a free day is refused; each write starts `sync_quietly()` on a
|
inert; a free day is refused; each write starts `sync_quietly()` on a
|
||||||
non-daemon thread. `WM_CLASS "sickdays", "Sickdays"`; `Escape` closes. Embed
|
non-daemon thread. `WM_CLASS "sickdays", "Sickdays"`; `Escape` closes. Embed
|
||||||
just the panel (`import sickdays` never imports tkinter):
|
just the panel (`import sickdays` never imports tkinter):
|
||||||
|
|
||||||
|
No words inside the window (textgate): dates are ISO numerals (`2026-11`,
|
||||||
|
`2026-11-27`), each locker is a drawn pictogram (dumbbell = workout, `</>` =
|
||||||
|
LeetCode, open book, apple = diet, alarm clock, target = focus), switches are
|
||||||
|
knob-right-on-accent / knob-left-on-dark, buttons are check (mark / save),
|
||||||
|
red bin (clear) and cross (cancel). A refusal is an icon in the status slot:
|
||||||
|
padlock (past), no-entry (free day), warning (cannot check, or a refused
|
||||||
|
write; the detail goes to the log), hourglass (a sync is still running).
|
||||||
|
Icons are Canvas geometry, not font glyphs: the venv's Tk uses core X fonts,
|
||||||
|
where non-ASCII glyphs render as nothing. The title bar keeps "Sick days"
|
||||||
|
(an app's name, textgate settled decision 4). The guard is
|
||||||
|
`tests/test_gui_no_words.py`: no letter on any widget, in any state.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from sickdays.gui import KeepPanel # a plain tk.Frame: no root, grab or wm_* call
|
from sickdays.gui import KeepPanel # a plain tk.Frame: no root, grab or wm_* call
|
||||||
KeepPanel(parent, day, *, on_saved, on_cancel=None, paths=None).pack()
|
KeepPanel(parent, day, *, on_saved, on_cancel=None, paths=None).pack()
|
||||||
```
|
```
|
||||||
|
|
||||||
Normal day: five switches, all off (off = stands down), "Mark sick"; sick
|
Normal day: six switches, all off (off = stands down), `actions.mark`; sick
|
||||||
day: its switches, "Save", "Clear sick day". Past/free/uncheckable days show
|
day: its switches, `actions.save`, `actions.clear`. Past/free/uncheckable
|
||||||
why and offer no write; a refusal (or a click mid-sync) shows in the panel.
|
days show their icon and offer no write; a refusal (or a click mid-sync)
|
||||||
|
shows in the panel's `status` slot, named by `panel.reason`.
|
||||||
`on_saved(SickDay | None)` fires after the write and sync start: the entry on
|
`on_saved(SickDay | None)` fires after the write and sync start: the entry on
|
||||||
Mark sick / a changed Save (`is_sick=True`) or Clear (`is_sick=False`), `None`
|
mark / a changed save (`is_sick=True`) or clear (`is_sick=False`), `None`
|
||||||
on an unchanged Save (nothing written). The panel never destroys itself.
|
on an unchanged save (nothing written). The panel never destroys itself.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
|
|||||||
@ -120,6 +120,9 @@ disable = []
|
|||||||
min-public-methods = 0
|
min-public-methods = 0
|
||||||
max-module-lines = 1000
|
max-module-lines = 1000
|
||||||
max-attributes = 10
|
max-attributes = 10
|
||||||
|
# A tk.Canvas subclass (gui/_controls.py) has 9 ancestors by construction:
|
||||||
|
# Canvas mixes in XView and YView on top of Widget's chain.
|
||||||
|
max-parents = 9
|
||||||
|
|
||||||
[tool.pylint.typecheck]
|
[tool.pylint.typecheck]
|
||||||
generated-members = [
|
generated-members = [
|
||||||
|
|||||||
@ -1,29 +1,33 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
"""The month grid: Monday-first, one button per day, prev/next month arrows.
|
"""The month grid: Monday-first, one button per day, prev/next month chevrons.
|
||||||
|
|
||||||
The phone's ``DayCalendar``/``DayCell``: a sick day is filled ``cat-2``, a
|
The phone's ``DayCalendar``/``DayCell``: a sick day is filled ``cat-2``, a
|
||||||
sick day that keeps a locker carries a dot, Tue-Thu are shaded, and past
|
sick day that keeps a locker carries a dot, Tue-Thu are shaded, and past
|
||||||
days keep their mark but faded and inert. Today stays clickable -- a sick
|
days keep their mark but faded and inert. Today stays clickable -- a sick
|
||||||
day can be marked or cleared for today. Deciding what a click *means* stays
|
day can be marked or cleared for today. Deciding what a click *means* stays
|
||||||
with the window; this only reports the date.
|
with the window; this only reports the date. No words: the month reads
|
||||||
|
``2026-11`` and the weekday row is dots, with rings on Saturday and Sunday.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Final
|
||||||
|
|
||||||
from sickdays._day import to_iso
|
from sickdays._day import to_iso
|
||||||
from sickdays.gui import _text
|
from sickdays.gui import _dates
|
||||||
from sickdays.gui import _tokens as tok
|
from sickdays.gui import _tokens as tok
|
||||||
from sickdays.gui import _widgets as ui
|
from sickdays.gui import _widgets as ui
|
||||||
|
from sickdays.gui._controls import IconButton, IconSlot
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
_PREV = "\N{SINGLE LEFT-POINTING ANGLE QUOTATION MARK}"
|
#: The weekday marks over the grid, in px.
|
||||||
_NEXT = "\N{SINGLE RIGHT-POINTING ANGLE QUOTATION MARK}"
|
_MARK_PX: Final = 10
|
||||||
|
#: The keep-a-locker dot inside a sick day's cell, in px.
|
||||||
|
_DOT_PX: Final = 8
|
||||||
|
|
||||||
|
|
||||||
def _cell_colours(*, sick: bool, past: bool, workday: bool) -> tuple[str, str]:
|
def _cell_colours(*, sick: bool, past: bool, workday: bool) -> tuple[str, str]:
|
||||||
@ -56,7 +60,7 @@ class MonthCalendar(tk.Frame):
|
|||||||
self._is_sick = is_sick
|
self._is_sick = is_sick
|
||||||
self._has_dot = has_dot
|
self._has_dot = has_dot
|
||||||
self._on_day = on_day
|
self._on_day = on_day
|
||||||
self._first, self._last = _text.month_bounds(today)
|
self._first, self._last = _dates.month_bounds(today)
|
||||||
self.month = today.replace(day=1)
|
self.month = today.replace(day=1)
|
||||||
self._header = tk.Frame(self, name="header", bg=self.cget("bg"))
|
self._header = tk.Frame(self, name="header", bg=self.cget("bg"))
|
||||||
self._header.pack(fill="x")
|
self._header.pack(fill="x")
|
||||||
@ -66,7 +70,7 @@ class MonthCalendar(tk.Frame):
|
|||||||
|
|
||||||
def step(self, months: int) -> None:
|
def step(self, months: int) -> None:
|
||||||
"""Move ``months`` months (within the phone's range) and redraw."""
|
"""Move ``months`` months (within the phone's range) and redraw."""
|
||||||
target = _text.shift_month(self.month, months)
|
target = _dates.shift_month(self.month, months)
|
||||||
if self._first <= target <= self._last:
|
if self._first <= target <= self._last:
|
||||||
self.month = target
|
self.month = target
|
||||||
self.redraw()
|
self.redraw()
|
||||||
@ -77,11 +81,12 @@ class MonthCalendar(tk.Frame):
|
|||||||
for child in frame.winfo_children():
|
for child in frame.winfo_children():
|
||||||
child.destroy()
|
child.destroy()
|
||||||
self._draw_header()
|
self._draw_header()
|
||||||
for column, initial in enumerate(_text.WEEKDAY_INITIALS):
|
for column in range(7):
|
||||||
ui.label(self._grid, initial, "caption", fg=tok.MUTED_ON_DARK).grid(
|
mark = "ring" if _dates.is_weekend_column(column) else "dot"
|
||||||
row=0, column=column
|
IconSlot(self._grid, size=_MARK_PX).show(mark, tok.MUTED_ON_DARK).grid(
|
||||||
|
row=0, column=column, pady=(0, tok.XS)
|
||||||
)
|
)
|
||||||
for row, week in enumerate(_text.month_weeks(self.month), start=1):
|
for row, week in enumerate(_dates.month_weeks(self.month), start=1):
|
||||||
for column, day in enumerate(week):
|
for column, day in enumerate(week):
|
||||||
if day is not None:
|
if day is not None:
|
||||||
self._cell(day).grid(
|
self._cell(day).grid(
|
||||||
@ -89,20 +94,20 @@ class MonthCalendar(tk.Frame):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _draw_header(self) -> None:
|
def _draw_header(self) -> None:
|
||||||
back = ui.button(self._header, _PREV, lambda: self.step(-1), name="prev")
|
back = IconButton(self._header, "prev", lambda: self.step(-1), name="prev")
|
||||||
forward = ui.button(self._header, _NEXT, lambda: self.step(1), name="next")
|
forward = IconButton(self._header, "next", lambda: self.step(1), name="next")
|
||||||
back.configure(state="normal" if self.month > self._first else "disabled")
|
back.set_enabled(self.month > self._first)
|
||||||
forward.configure(state="normal" if self.month < self._last else "disabled")
|
forward.set_enabled(self.month < self._last)
|
||||||
back.pack(side="left")
|
back.pack(side="left")
|
||||||
forward.pack(side="right")
|
forward.pack(side="right")
|
||||||
ui.label(self._header, _text.month_title(self.month), "subtitle").pack(
|
ui.label(self._header, _dates.month_title(self.month), "subtitle").pack(
|
||||||
side="top", pady=tok.XS
|
side="top", pady=tok.XS
|
||||||
)
|
)
|
||||||
|
|
||||||
def _cell(self, day: date) -> tk.Button:
|
def _cell(self, day: date) -> tk.Button:
|
||||||
sick = self._is_sick(day)
|
sick = self._is_sick(day)
|
||||||
past = day < self._today
|
past = day < self._today
|
||||||
fill, text = _cell_colours(sick=sick, past=past, workday=_text.is_workday(day))
|
fill, text = _cell_colours(sick=sick, past=past, workday=_dates.is_workday(day))
|
||||||
cell = tk.Button(
|
cell = tk.Button(
|
||||||
self._grid,
|
self._grid,
|
||||||
name=f"day-{to_iso(day)}",
|
name=f"day-{to_iso(day)}",
|
||||||
@ -124,7 +129,6 @@ class MonthCalendar(tk.Frame):
|
|||||||
cursor="arrow" if past else "hand2",
|
cursor="arrow" if past else "hand2",
|
||||||
)
|
)
|
||||||
if sick and self._has_dot(day):
|
if sick and self._has_dot(day):
|
||||||
dot = tk.Label(cell, name="dot", text="●", fg=text, bg=fill)
|
dot = IconSlot(cell, name="dot", size=_DOT_PX).show("dot", text)
|
||||||
dot.configure(font=tok.font(ui.family(self), "caption"), padx=0, pady=0)
|
dot.place(relx=1.0, x=-2, y=2, anchor="ne")
|
||||||
dot.place(relx=1.0, x=-2, y=0, anchor="ne")
|
|
||||||
return cell
|
return cell
|
||||||
|
|||||||
171
sickdays/sickdays/gui/_controls.py
Normal file
171
sickdays/sickdays/gui/_controls.py
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Word-free controls: an icon button, an on/off switch and an icon slot.
|
||||||
|
|
||||||
|
Canvases, because a Tk Button or Checkbutton can only show its state as a
|
||||||
|
word or a font glyph (see :mod:`sickdays.gui._icons`). Each keeps Tk's own
|
||||||
|
``state`` option and offers ``invoke()``, so callers and tests drive them
|
||||||
|
like the widgets they replace. Every state has two channels: shape (icon,
|
||||||
|
knob position) plus colour.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tkinter as tk
|
||||||
|
from typing import TYPE_CHECKING, Final, Literal
|
||||||
|
|
||||||
|
from sickdays.gui import _tokens as tok
|
||||||
|
from sickdays.gui._icons import TAG, draw
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
ButtonKind = Literal["primary", "danger", "plain"]
|
||||||
|
|
||||||
|
#: (fill, icon colour) per button kind; danger keeps its red on a dark fill.
|
||||||
|
_BUTTON_COLOURS: Final[dict[ButtonKind, tuple[str, str]]] = {
|
||||||
|
"primary": (tok.ACCENT, tok.ON_FILL),
|
||||||
|
"danger": (tok.INK_RAISED_2, tok.DANGER),
|
||||||
|
"plain": (tok.INK_RAISED_2, tok.TEXT_ON_DARK),
|
||||||
|
}
|
||||||
|
ICON_PX: Final = 20
|
||||||
|
_TRACK_W: Final = 44
|
||||||
|
_TRACK_H: Final = 24
|
||||||
|
|
||||||
|
|
||||||
|
def _enabled(widget: tk.Canvas) -> bool:
|
||||||
|
return str(widget.cget("state")) != "disabled"
|
||||||
|
|
||||||
|
|
||||||
|
class IconButton(tk.Canvas):
|
||||||
|
"""A flat button showing ``icon``; ``invoke()`` runs ``command`` unless disabled."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent: tk.Misc,
|
||||||
|
icon: str,
|
||||||
|
command: Callable[[], object],
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
kind: ButtonKind = "plain",
|
||||||
|
) -> None:
|
||||||
|
"""Draw the button; a click anywhere on it invokes it."""
|
||||||
|
fill, self._colour = _BUTTON_COLOURS[kind]
|
||||||
|
super().__init__(
|
||||||
|
parent,
|
||||||
|
name=name,
|
||||||
|
width=ICON_PX + 2 * tok.MD,
|
||||||
|
height=ICON_PX + 2 * tok.SM,
|
||||||
|
bg=fill,
|
||||||
|
highlightthickness=0,
|
||||||
|
borderwidth=0,
|
||||||
|
cursor="hand2",
|
||||||
|
)
|
||||||
|
#: Which pictogram this button shows.
|
||||||
|
self.icon = icon
|
||||||
|
self._command = command
|
||||||
|
self.bind("<Button-1>", lambda _event: self.invoke())
|
||||||
|
self._paint()
|
||||||
|
|
||||||
|
def set_enabled(self, enabled: bool) -> None: # noqa: FBT001
|
||||||
|
"""Grey the icon out and ignore clicks while disabled."""
|
||||||
|
self.configure(
|
||||||
|
state="normal" if enabled else "disabled",
|
||||||
|
cursor="hand2" if enabled else "arrow",
|
||||||
|
)
|
||||||
|
self._paint()
|
||||||
|
|
||||||
|
def invoke(self) -> None:
|
||||||
|
"""Run the command, as a click would."""
|
||||||
|
if _enabled(self):
|
||||||
|
self._command()
|
||||||
|
|
||||||
|
def _paint(self) -> None:
|
||||||
|
self.delete(TAG)
|
||||||
|
colour = self._colour if _enabled(self) else tok.MUTED_ON_DARK
|
||||||
|
draw(self, self.icon, size=ICON_PX, colour=colour, at=(tok.MD, tok.SM))
|
||||||
|
|
||||||
|
|
||||||
|
class Toggle(tk.Canvas):
|
||||||
|
"""An on/off switch for ``variable``: knob right on accent, left on dark."""
|
||||||
|
|
||||||
|
def __init__(self, parent: tk.Misc, variable: tk.BooleanVar, *, name: str) -> None:
|
||||||
|
"""Draw the switch in ``variable``'s current state."""
|
||||||
|
super().__init__(
|
||||||
|
parent,
|
||||||
|
name=name,
|
||||||
|
width=_TRACK_W,
|
||||||
|
height=_TRACK_H,
|
||||||
|
bg=parent.cget("bg"),
|
||||||
|
highlightthickness=0,
|
||||||
|
borderwidth=0,
|
||||||
|
cursor="hand2",
|
||||||
|
)
|
||||||
|
self._variable = variable
|
||||||
|
self.bind("<Button-1>", lambda _event: self.invoke())
|
||||||
|
self.paint()
|
||||||
|
|
||||||
|
def set_enabled(self, enabled: bool) -> None: # noqa: FBT001
|
||||||
|
"""Fade the switch and ignore clicks while disabled."""
|
||||||
|
self.configure(
|
||||||
|
state="normal" if enabled else "disabled",
|
||||||
|
cursor="hand2" if enabled else "arrow",
|
||||||
|
)
|
||||||
|
self.paint()
|
||||||
|
|
||||||
|
def invoke(self) -> None:
|
||||||
|
"""Flip the switch, as a click would."""
|
||||||
|
if _enabled(self):
|
||||||
|
self._variable.set(not self._variable.get())
|
||||||
|
self.paint()
|
||||||
|
|
||||||
|
def is_on(self) -> bool:
|
||||||
|
"""Whether the locker this switch stands for still applies."""
|
||||||
|
return bool(self._variable.get())
|
||||||
|
|
||||||
|
def paint(self) -> None:
|
||||||
|
"""Redraw the track and knob from the variable."""
|
||||||
|
self.delete(TAG)
|
||||||
|
on = self.is_on()
|
||||||
|
track, knob = (
|
||||||
|
(tok.ACCENT, tok.ON_FILL) if on else (tok.INK_RAISED_2, tok.MUTED_ON_DARK)
|
||||||
|
)
|
||||||
|
if not _enabled(self):
|
||||||
|
track = tok.blend(track, tok.INK, tok.PAST_ALPHA)
|
||||||
|
knob = tok.blend(knob, tok.INK, tok.PAST_ALPHA)
|
||||||
|
h, w = _TRACK_H, _TRACK_W
|
||||||
|
self.create_oval(0, 0, h, h, fill=track, outline=track, tags=TAG)
|
||||||
|
self.create_oval(w - h, 0, w, h, fill=track, outline=track, tags=TAG)
|
||||||
|
self.create_rectangle(
|
||||||
|
h // 2, 0, w - h // 2, h, fill=track, outline=track, tags=TAG
|
||||||
|
)
|
||||||
|
left = w - h + 3 if on else 3
|
||||||
|
self.create_oval(
|
||||||
|
left, 3, left + h - 6, h - 3, fill=knob, outline=knob, tags=TAG
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IconSlot(tk.Canvas):
|
||||||
|
"""One icon, or nothing: a status mark, a locker's pictogram, a marker."""
|
||||||
|
|
||||||
|
def __init__(self, parent: tk.Misc, *, name: str = "", size: int = ICON_PX) -> None:
|
||||||
|
"""An empty slot ``size`` px square on ``parent``'s background."""
|
||||||
|
super().__init__(
|
||||||
|
parent,
|
||||||
|
name=name,
|
||||||
|
width=size,
|
||||||
|
height=size,
|
||||||
|
bg=parent.cget("bg"),
|
||||||
|
highlightthickness=0,
|
||||||
|
borderwidth=0,
|
||||||
|
)
|
||||||
|
self._size = size
|
||||||
|
#: The pictogram shown, or ``None`` when the slot is empty.
|
||||||
|
self.icon: str | None = None
|
||||||
|
|
||||||
|
def show(self, icon: str | None, colour: str = tok.TEXT_ON_DARK) -> IconSlot:
|
||||||
|
"""Show ``icon`` in ``colour`` (``None`` clears the slot)."""
|
||||||
|
self.delete(TAG)
|
||||||
|
self.icon = icon
|
||||||
|
if icon is not None:
|
||||||
|
draw(self, icon, size=self._size, colour=colour)
|
||||||
|
return self
|
||||||
86
sickdays/sickdays/gui/_dates.py
Normal file
86
sickdays/sickdays/gui/_dates.py
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Calendar arithmetic and the free-day check for the picker -- no Tk in here.
|
||||||
|
|
||||||
|
The picker shows no words (kuhy's textgate rule): dates are ISO numerals and
|
||||||
|
a refusal is a reason *code* the widgets turn into an icon. The library's
|
||||||
|
own message for an unavailable check goes to the log, not the screen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import calendar
|
||||||
|
from datetime import date
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING, Final, Literal
|
||||||
|
|
||||||
|
from sickdays._conflict import free_day_predicate
|
||||||
|
from sickdays._constants import LOCKERS
|
||||||
|
from sickdays._errors import ConflictCheckUnavailableError
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
_LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: Why a day cannot be marked sick: it is a free day, or that cannot be checked.
|
||||||
|
Block = Literal["free", "unavailable"]
|
||||||
|
|
||||||
|
#: Tue/Wed/Thu, shaded like the phone's grid (wake-alarm's ``isWorkday``).
|
||||||
|
_WORKDAYS: Final = frozenset({1, 2, 3})
|
||||||
|
#: Sat/Sun, marked with a ring instead of a dot over the grid.
|
||||||
|
_WEEKEND: Final = frozenset({5, 6})
|
||||||
|
|
||||||
|
|
||||||
|
def kept_in_order(kept: Iterable[str]) -> tuple[str, ...]:
|
||||||
|
"""The kept lockers in ``sickdays.LOCKERS`` order, not the set's."""
|
||||||
|
chosen = set(kept)
|
||||||
|
return tuple(locker for locker in LOCKERS if locker in chosen)
|
||||||
|
|
||||||
|
|
||||||
|
def free_day_block(day: date) -> Block | None:
|
||||||
|
"""Why ``day`` cannot be marked sick for the free-day pool, or None.
|
||||||
|
|
||||||
|
"Could not check" refuses too: it must never count as "not a free day".
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
is_free = free_day_predicate()
|
||||||
|
except ConflictCheckUnavailableError as exc:
|
||||||
|
_LOG.warning("free-day check unavailable: %s", exc)
|
||||||
|
return "unavailable"
|
||||||
|
return "free" if is_free(day) else None
|
||||||
|
|
||||||
|
|
||||||
|
def is_workday(day: date) -> bool:
|
||||||
|
"""Whether ``day`` is a weekly workday (Tue/Wed/Thu), shaded on the grid."""
|
||||||
|
return day.weekday() in _WORKDAYS
|
||||||
|
|
||||||
|
|
||||||
|
def is_weekend_column(column: int) -> bool:
|
||||||
|
"""Whether grid ``column`` (0 = Monday) is Saturday or Sunday."""
|
||||||
|
return column in _WEEKEND
|
||||||
|
|
||||||
|
|
||||||
|
def month_title(month: date) -> str:
|
||||||
|
"""``"2026-10"``: the month as numerals, never a month name."""
|
||||||
|
return f"{month.year}-{month.month:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def month_weeks(month: date) -> list[list[date | None]]:
|
||||||
|
"""The month as Monday-first weeks, ``None`` padding either end."""
|
||||||
|
return [
|
||||||
|
[None if not cell else date(month.year, month.month, cell) for cell in week]
|
||||||
|
for week in calendar.Calendar(firstweekday=0).monthdayscalendar(
|
||||||
|
month.year, month.month
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def shift_month(month: date, step: int) -> date:
|
||||||
|
"""The first of the month ``step`` months after ``month``."""
|
||||||
|
index = month.year * 12 + month.month - 1 + step
|
||||||
|
return date(index // 12, index % 12 + 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def month_bounds(reference: date) -> tuple[date, date]:
|
||||||
|
"""January this year to December next year, the phone's range."""
|
||||||
|
return date(reference.year, 1, 1), date(reference.year + 1, 12, 1)
|
||||||
235
sickdays/sickdays/gui/_icons.py
Normal file
235
sickdays/sickdays/gui/_icons.py
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
"""Pictograms drawn as Canvas geometry, so the picker needs no words.
|
||||||
|
|
||||||
|
Drawn rather than typed: the venv's Tk falls back to core X fonts, where
|
||||||
|
every non-ASCII glyph (angle quotes, check marks) renders as nothing; the
|
||||||
|
old month arrows were blank buttons. Each icon is plotted on a unit square,
|
||||||
|
scaled to ``size`` pixels, and tagged ``icon`` so a repaint can drop it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Final
|
||||||
|
|
||||||
|
from sickdays._constants import (
|
||||||
|
LOCKER_ALARM,
|
||||||
|
LOCKER_BOOK,
|
||||||
|
LOCKER_DIET,
|
||||||
|
LOCKER_FOCUS,
|
||||||
|
LOCKER_LEETCODE,
|
||||||
|
LOCKER_WORKOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
import tkinter as tk
|
||||||
|
|
||||||
|
TAG: Final = "icon"
|
||||||
|
|
||||||
|
|
||||||
|
class Pen:
|
||||||
|
"""Draws unit-square coordinates onto ``canvas`` at ``(x, y)``, ``size`` wide."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, canvas: tk.Canvas, *, size: int, colour: str, at: tuple[int, int]
|
||||||
|
) -> None:
|
||||||
|
"""Remember where and how large to draw."""
|
||||||
|
self._canvas = canvas
|
||||||
|
self._size = size
|
||||||
|
self._colour = colour
|
||||||
|
self._x, self._y = at
|
||||||
|
self.width = max(2, round(size / 10))
|
||||||
|
|
||||||
|
def _xy(self, points: tuple[float, ...]) -> list[float]:
|
||||||
|
return [
|
||||||
|
(self._y if i % 2 else self._x) + value * self._size
|
||||||
|
for i, value in enumerate(points)
|
||||||
|
]
|
||||||
|
|
||||||
|
def line(self, *points: float) -> None:
|
||||||
|
"""A stroke through ``points`` (x0, y0, x1, y1, ...)."""
|
||||||
|
self._canvas.create_line(
|
||||||
|
*self._xy(points),
|
||||||
|
fill=self._colour,
|
||||||
|
width=self.width,
|
||||||
|
capstyle="round",
|
||||||
|
joinstyle="round",
|
||||||
|
tags=TAG,
|
||||||
|
)
|
||||||
|
|
||||||
|
def oval(self, *box: float, filled: bool = False) -> None:
|
||||||
|
"""An ellipse in ``box`` (x0, y0, x1, y1), outlined or filled."""
|
||||||
|
self._canvas.create_oval(
|
||||||
|
*self._xy(box),
|
||||||
|
outline=self._colour,
|
||||||
|
fill=self._colour if filled else "",
|
||||||
|
width=self.width,
|
||||||
|
tags=TAG,
|
||||||
|
)
|
||||||
|
|
||||||
|
def shape(self, *points: float, filled: bool = False) -> None:
|
||||||
|
"""A closed polygon through ``points``, outlined or filled."""
|
||||||
|
self._canvas.create_polygon(
|
||||||
|
*self._xy(points),
|
||||||
|
outline=self._colour,
|
||||||
|
fill=self._colour if filled else "",
|
||||||
|
width=self.width,
|
||||||
|
joinstyle="round",
|
||||||
|
tags=TAG,
|
||||||
|
)
|
||||||
|
|
||||||
|
def arch(self, *box: float) -> None:
|
||||||
|
"""The top half of the ellipse in ``box`` (a padlock's shackle)."""
|
||||||
|
self._canvas.create_arc(
|
||||||
|
*self._xy(box),
|
||||||
|
start=0,
|
||||||
|
extent=180,
|
||||||
|
style="arc",
|
||||||
|
outline=self._colour,
|
||||||
|
width=self.width,
|
||||||
|
tags=TAG,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _check(pen: Pen) -> None:
|
||||||
|
pen.line(0.2, 0.55, 0.42, 0.76, 0.82, 0.28)
|
||||||
|
|
||||||
|
|
||||||
|
def _cross(pen: Pen) -> None:
|
||||||
|
pen.line(0.25, 0.25, 0.75, 0.75)
|
||||||
|
pen.line(0.75, 0.25, 0.25, 0.75)
|
||||||
|
|
||||||
|
|
||||||
|
def _trash(pen: Pen) -> None:
|
||||||
|
pen.line(0.18, 0.26, 0.82, 0.26)
|
||||||
|
pen.line(0.4, 0.26, 0.42, 0.14, 0.58, 0.14, 0.6, 0.26)
|
||||||
|
pen.shape(0.27, 0.34, 0.73, 0.34, 0.68, 0.88, 0.32, 0.88)
|
||||||
|
|
||||||
|
|
||||||
|
def _prev(pen: Pen) -> None:
|
||||||
|
pen.line(0.62, 0.22, 0.36, 0.5, 0.62, 0.78)
|
||||||
|
|
||||||
|
|
||||||
|
def _next(pen: Pen) -> None:
|
||||||
|
pen.line(0.38, 0.22, 0.64, 0.5, 0.38, 0.78)
|
||||||
|
|
||||||
|
|
||||||
|
def _workout(pen: Pen) -> None:
|
||||||
|
pen.line(0.2, 0.5, 0.8, 0.5)
|
||||||
|
pen.shape(0.17, 0.28, 0.29, 0.28, 0.29, 0.72, 0.17, 0.72, filled=True)
|
||||||
|
pen.shape(0.71, 0.28, 0.83, 0.28, 0.83, 0.72, 0.71, 0.72, filled=True)
|
||||||
|
pen.line(0.08, 0.5, 0.17, 0.5)
|
||||||
|
pen.line(0.83, 0.5, 0.92, 0.5)
|
||||||
|
|
||||||
|
|
||||||
|
def _leetcode(pen: Pen) -> None:
|
||||||
|
pen.line(0.36, 0.28, 0.12, 0.5, 0.36, 0.72)
|
||||||
|
pen.line(0.64, 0.28, 0.88, 0.5, 0.64, 0.72)
|
||||||
|
pen.line(0.57, 0.2, 0.43, 0.8)
|
||||||
|
|
||||||
|
|
||||||
|
def _book(pen: Pen) -> None:
|
||||||
|
pen.shape(0.5, 0.3, 0.1, 0.2, 0.1, 0.76, 0.5, 0.86)
|
||||||
|
pen.shape(0.5, 0.3, 0.9, 0.2, 0.9, 0.76, 0.5, 0.86)
|
||||||
|
|
||||||
|
|
||||||
|
def _diet(pen: Pen) -> None:
|
||||||
|
pen.oval(0.2, 0.34, 0.8, 0.9, filled=True)
|
||||||
|
pen.line(0.5, 0.34, 0.54, 0.14)
|
||||||
|
pen.oval(0.56, 0.12, 0.78, 0.26, filled=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _alarm(pen: Pen) -> None:
|
||||||
|
pen.oval(0.18, 0.24, 0.82, 0.88)
|
||||||
|
pen.line(0.5, 0.38, 0.5, 0.56, 0.63, 0.64)
|
||||||
|
pen.oval(0.08, 0.1, 0.3, 0.3, filled=True)
|
||||||
|
pen.oval(0.7, 0.1, 0.92, 0.3, filled=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _focus(pen: Pen) -> None:
|
||||||
|
pen.oval(0.1, 0.1, 0.9, 0.9)
|
||||||
|
pen.oval(0.3, 0.3, 0.7, 0.7)
|
||||||
|
pen.oval(0.44, 0.44, 0.56, 0.56, filled=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _padlock(pen: Pen) -> None:
|
||||||
|
pen.arch(0.3, 0.12, 0.7, 0.62)
|
||||||
|
pen.shape(0.22, 0.44, 0.78, 0.44, 0.78, 0.88, 0.22, 0.88, filled=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _no_entry(pen: Pen) -> None:
|
||||||
|
pen.oval(0.14, 0.14, 0.86, 0.86)
|
||||||
|
pen.line(0.28, 0.28, 0.72, 0.72)
|
||||||
|
|
||||||
|
|
||||||
|
def _warning(pen: Pen) -> None:
|
||||||
|
pen.shape(0.5, 0.12, 0.9, 0.86, 0.1, 0.86)
|
||||||
|
pen.line(0.5, 0.38, 0.5, 0.6)
|
||||||
|
pen.oval(0.47, 0.71, 0.53, 0.77, filled=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _hourglass(pen: Pen) -> None:
|
||||||
|
pen.line(0.22, 0.12, 0.78, 0.12)
|
||||||
|
pen.line(0.22, 0.88, 0.78, 0.88)
|
||||||
|
pen.shape(0.28, 0.12, 0.72, 0.12, 0.28, 0.88, 0.72, 0.88)
|
||||||
|
|
||||||
|
|
||||||
|
def _sick(pen: Pen) -> None:
|
||||||
|
pen.shape(0.15, 0.15, 0.85, 0.15, 0.85, 0.85, 0.15, 0.85, filled=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _normal(pen: Pen) -> None:
|
||||||
|
pen.shape(0.15, 0.15, 0.85, 0.15, 0.85, 0.85, 0.15, 0.85)
|
||||||
|
|
||||||
|
|
||||||
|
def _dot(pen: Pen) -> None:
|
||||||
|
pen.oval(0.3, 0.3, 0.7, 0.7, filled=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _ring(pen: Pen) -> None:
|
||||||
|
pen.oval(0.25, 0.25, 0.75, 0.75)
|
||||||
|
|
||||||
|
|
||||||
|
ICONS: Final[dict[str, Callable[[Pen], None]]] = {
|
||||||
|
"check": _check,
|
||||||
|
"cross": _cross,
|
||||||
|
"trash": _trash,
|
||||||
|
"prev": _prev,
|
||||||
|
"next": _next,
|
||||||
|
"workout": _workout,
|
||||||
|
"leetcode": _leetcode,
|
||||||
|
"book": _book,
|
||||||
|
"diet": _diet,
|
||||||
|
"alarm": _alarm,
|
||||||
|
"focus": _focus,
|
||||||
|
"padlock": _padlock,
|
||||||
|
"no_entry": _no_entry,
|
||||||
|
"warning": _warning,
|
||||||
|
"hourglass": _hourglass,
|
||||||
|
"sick": _sick,
|
||||||
|
"normal": _normal,
|
||||||
|
"dot": _dot,
|
||||||
|
"ring": _ring,
|
||||||
|
}
|
||||||
|
|
||||||
|
#: The pictogram each locker is shown as, in place of its name.
|
||||||
|
LOCKER_ICONS: Final = {
|
||||||
|
LOCKER_WORKOUT: "workout",
|
||||||
|
LOCKER_LEETCODE: "leetcode",
|
||||||
|
LOCKER_BOOK: "book",
|
||||||
|
LOCKER_DIET: "diet",
|
||||||
|
LOCKER_ALARM: "alarm",
|
||||||
|
LOCKER_FOCUS: "focus",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def draw(
|
||||||
|
canvas: tk.Canvas,
|
||||||
|
icon: str,
|
||||||
|
*,
|
||||||
|
size: int,
|
||||||
|
colour: str,
|
||||||
|
at: tuple[int, int] = (0, 0),
|
||||||
|
) -> None:
|
||||||
|
"""Draw ``icon`` on ``canvas`` in a ``size`` px square whose corner is ``at``."""
|
||||||
|
ICONS[icon](Pen(canvas, size=size, colour=colour, at=at))
|
||||||
@ -1,5 +1,5 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
"""KeepPanel: the five switches for one day, and the buttons that write them.
|
"""KeepPanel: a switch per locker for one day, and the buttons that write them.
|
||||||
|
|
||||||
The embeddable half of the picker. It is a plain ``tk.Frame``: no root, no
|
The embeddable half of the picker. It is a plain ``tk.Frame``: no root, no
|
||||||
mainloop, no ``bind_all``, no grab, no focus stealing and no ``wm_*`` call,
|
mainloop, no ``bind_all``, no grab, no focus stealing and no ``wm_*`` call,
|
||||||
@ -7,29 +7,34 @@ so screen-locker can drop it onto its fullscreen surface in-process, under
|
|||||||
its own X grab (a separate window could not get input there).
|
its own X grab (a separate window could not get input there).
|
||||||
|
|
||||||
On is "this locker still applies", off is "stands down", all off by default
|
On is "this locker still applies", off is "stands down", all off by default
|
||||||
-- the phone's ``SickDaySheet``. A normal day offers "Mark sick"; a sick day
|
-- the phone's ``SickDaySheet``. A normal day offers "mark sick" (check); a
|
||||||
offers "Save" (a keep-only edit) and "Clear sick day". A past day, a free
|
sick day offers "save" (check, a keep-only edit) and "clear" (red bin). A
|
||||||
day, or a free-day pool that cannot be checked shows why and offers no
|
past day (padlock), a free day (no-entry sign) or a free-day pool that cannot
|
||||||
write. A refused write is shown in the panel, never raised: an exception in a
|
be checked (warning) shows its icon and offers no write. A refused write is
|
||||||
Tk callback only reaches stderr, which nobody sees under a lock.
|
shown in the panel as an icon, never raised: an exception in a Tk callback
|
||||||
|
only reaches stderr, which nobody sees under a lock. The panel shows no
|
||||||
|
words (kuhy's textgate rule): a locker is its pictogram, the day its ISO date.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Final
|
||||||
|
|
||||||
from sickdays._api import add_sick_day, is_sick_day, remove_sick_day
|
from sickdays._api import add_sick_day, is_sick_day, remove_sick_day
|
||||||
from sickdays._constants import LOCKERS
|
from sickdays._constants import LOCKERS
|
||||||
from sickdays._day import today
|
from sickdays._day import to_iso, today
|
||||||
from sickdays._errors import SickDayError
|
from sickdays._errors import SickDayError
|
||||||
from sickdays._keep_api import kept_lockers, set_kept_lockers
|
from sickdays._keep_api import kept_lockers, set_kept_lockers
|
||||||
from sickdays._paths import resolve_paths
|
from sickdays._paths import resolve_paths
|
||||||
from sickdays._sync import sync_quietly
|
from sickdays._sync import sync_quietly
|
||||||
from sickdays.gui import _text
|
from sickdays.gui import _dates
|
||||||
from sickdays.gui import _tokens as tok
|
from sickdays.gui import _tokens as tok
|
||||||
from sickdays.gui import _widgets as ui
|
from sickdays.gui import _widgets as ui
|
||||||
|
from sickdays.gui._controls import IconButton, IconSlot, Toggle
|
||||||
|
from sickdays.gui._icons import LOCKER_ICONS
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
@ -39,6 +44,17 @@ if TYPE_CHECKING:
|
|||||||
from sickdays._paths import Paths
|
from sickdays._paths import Paths
|
||||||
|
|
||||||
|
|
||||||
|
_LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: (icon, colour) for each reason the panel shows, keyed by that reason.
|
||||||
|
STATUS_ICONS: Final = {
|
||||||
|
"past": ("padlock", tok.MUTED_ON_DARK),
|
||||||
|
"free": ("no_entry", tok.DANGER),
|
||||||
|
"unavailable": ("warning", tok.DANGER),
|
||||||
|
"refused": ("warning", tok.DANGER),
|
||||||
|
"busy": ("hourglass", tok.DANGER),
|
||||||
|
}
|
||||||
|
|
||||||
#: Held by every background sync, and tried by every write. ``sync()`` reads
|
#: Held by every background sync, and tried by every write. ``sync()`` reads
|
||||||
#: the pool, waits on the network, then writes its merged copy back, so a
|
#: the pool, waits on the network, then writes its merged copy back, so a
|
||||||
#: write landing in between would be silently overwritten. A write that finds
|
#: write landing in between would be silently overwritten. A write that finds
|
||||||
@ -73,16 +89,17 @@ class KeepPanel(tk.Frame):
|
|||||||
``on_saved`` is called once per successful click, after the write and
|
``on_saved`` is called once per successful click, after the write and
|
||||||
after the background sync has started:
|
after the background sync has started:
|
||||||
|
|
||||||
* "Mark sick" -> the new :class:`~sickdays.SickDay` (``is_sick=True``);
|
* ``actions.mark`` -> the new :class:`~sickdays.SickDay` (``is_sick=True``);
|
||||||
* "Save" with a changed set -> the stored entry (``is_sick=True``);
|
* ``actions.save`` with a changed set -> the stored entry (``is_sick=True``);
|
||||||
* "Save" with nothing changed -> ``None``: nothing written, no sync;
|
* ``actions.save`` with nothing changed -> ``None``: nothing written, no sync;
|
||||||
* "Clear sick day" -> the cleared entry (``is_sick=False``).
|
* ``actions.clear`` -> the cleared entry (``is_sick=False``).
|
||||||
|
|
||||||
A refused write (:class:`~sickdays.SickDayError`, or a click while the
|
A refused write (:class:`~sickdays.SickDayError`, or a click while the
|
||||||
previous write's sync is still running) is shown in the panel and
|
previous write's sync is still running) is shown in the panel's
|
||||||
``on_saved`` is not called. ``on_cancel``, when given, adds a
|
``status`` slot and ``on_saved`` is not called; :attr:`reason` names it
|
||||||
"Cancel" button that calls it. The panel never destroys itself; the
|
(a :data:`STATUS_ICONS` key). ``on_cancel``, when given, adds an
|
||||||
callbacks decide what happens to it.
|
``actions.cancel`` cross that calls it. The panel never destroys itself;
|
||||||
|
the callbacks decide what happens to it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@ -107,12 +124,15 @@ class KeepPanel(tk.Frame):
|
|||||||
}
|
}
|
||||||
#: The last background sync, for callers (and tests) that wait on it.
|
#: The last background sync, for callers (and tests) that wait on it.
|
||||||
self.sync_thread: threading.Thread | None = None
|
self.sync_thread: threading.Thread | None = None
|
||||||
|
#: Why the panel refuses, a :data:`STATUS_ICONS` key, or ``None``.
|
||||||
|
self.reason: str | None = None
|
||||||
blocked = self._blocked_reason()
|
blocked = self._blocked_reason()
|
||||||
self._draw_switches(editable=blocked is None)
|
self._draw_switches(editable=blocked is None)
|
||||||
self._status = ui.label(
|
self._status = IconSlot(self, name="status")
|
||||||
self, blocked or "", "label", fg=tok.DANGER, name="status"
|
self._status.grid(
|
||||||
|
row=len(LOCKERS) + 1, column=0, columnspan=2, sticky="w", pady=(tok.SM, 0)
|
||||||
)
|
)
|
||||||
self._status.grid(row=len(LOCKERS) + 2, column=0, columnspan=2, sticky="w")
|
self._show(blocked)
|
||||||
self._draw_actions(writable=blocked is None, on_cancel=on_cancel)
|
self._draw_actions(writable=blocked is None, on_cancel=on_cancel)
|
||||||
|
|
||||||
def kept(self) -> frozenset[str]:
|
def kept(self) -> frozenset[str]:
|
||||||
@ -121,49 +141,54 @@ class KeepPanel(tk.Frame):
|
|||||||
|
|
||||||
def _blocked_reason(self) -> str | None:
|
def _blocked_reason(self) -> str | None:
|
||||||
if self._day < today():
|
if self._day < today():
|
||||||
return _text.PAST_NOTE
|
return "past"
|
||||||
return None if self._sick else _text.free_day_refusal(self._day)
|
return None if self._sick else _dates.free_day_block(self._day)
|
||||||
|
|
||||||
|
def _show(self, reason: str | None) -> None:
|
||||||
|
self.reason = reason
|
||||||
|
if reason is None:
|
||||||
|
self._status.show(None)
|
||||||
|
return
|
||||||
|
icon, colour = STATUS_ICONS[reason]
|
||||||
|
self._status.show(icon, colour)
|
||||||
|
|
||||||
def _draw_switches(self, *, editable: bool) -> None:
|
def _draw_switches(self, *, editable: bool) -> None:
|
||||||
title = _text.panel_title(self._day, sick=self._sick)
|
head = tk.Frame(self, name="head", bg=self.cget("bg"))
|
||||||
ui.label(self, title, "subtitle", name="title").grid(
|
head.grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, tok.SM))
|
||||||
row=0, column=0, columnspan=2, sticky="w"
|
IconSlot(head, name="state").show(
|
||||||
)
|
"sick" if self._sick else "normal", tok.SICK
|
||||||
ui.label(self, _text.SHEET_HELP, "caption", fg=tok.MUTED_ON_DARK).grid(
|
).pack(side="left", padx=(0, tok.SM))
|
||||||
row=1, column=0, columnspan=2, sticky="w", pady=(tok.XS, tok.SM)
|
ui.label(head, to_iso(self._day), "subtitle", name="title").pack(side="left")
|
||||||
)
|
for row, locker in enumerate(LOCKERS, start=1):
|
||||||
for row, locker in enumerate(LOCKERS, start=2):
|
IconSlot(self, name=f"icon-{locker}").show(LOCKER_ICONS[locker]).grid(
|
||||||
ui.label(self, _text.LOCKER_LABELS[locker], "body").grid(
|
|
||||||
row=row, column=0, sticky="w", pady=tok.XS
|
row=row, column=0, sticky="w", pady=tok.XS
|
||||||
)
|
)
|
||||||
switch = ui.toggle(self, self._vars[locker], name=f"keep-{locker}")
|
switch = Toggle(self, self._vars[locker], name=f"keep-{locker}")
|
||||||
switch.grid(row=row, column=1, sticky="e", padx=(tok.LG, 0), pady=tok.XS)
|
switch.grid(row=row, column=1, sticky="e", padx=(tok.LG, 0), pady=tok.XS)
|
||||||
if not editable:
|
if not editable:
|
||||||
switch.configure(state="disabled")
|
switch.set_enabled(False)
|
||||||
|
|
||||||
def _draw_actions(
|
def _draw_actions(
|
||||||
self, *, writable: bool, on_cancel: Callable[[], None] | None
|
self, *, writable: bool, on_cancel: Callable[[], None] | None
|
||||||
) -> None:
|
) -> None:
|
||||||
actions = tk.Frame(self, name="actions", bg=self.cget("bg"))
|
actions = tk.Frame(self, name="actions", bg=self.cget("bg"))
|
||||||
actions.grid(
|
actions.grid(
|
||||||
row=len(LOCKERS) + 3, column=0, columnspan=2, sticky="we", pady=(tok.MD, 0)
|
row=len(LOCKERS) + 2, column=0, columnspan=2, sticky="we", pady=(tok.MD, 0)
|
||||||
)
|
)
|
||||||
buttons: list[tk.Button] = []
|
buttons: list[IconButton] = []
|
||||||
if writable and self._sick:
|
if writable and self._sick:
|
||||||
buttons.append(
|
buttons.append(
|
||||||
ui.button(actions, "Save", self._save, kind="primary", name="save")
|
IconButton(actions, "check", self._save, kind="primary", name="save")
|
||||||
)
|
)
|
||||||
buttons.append(
|
buttons.append(
|
||||||
ui.button(
|
IconButton(actions, "trash", self._clear, kind="danger", name="clear")
|
||||||
actions, "Clear sick day", self._clear, kind="danger", name="clear"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
elif writable:
|
elif writable:
|
||||||
buttons.append(
|
buttons.append(
|
||||||
ui.button(actions, "Mark sick", self._mark, kind="primary", name="mark")
|
IconButton(actions, "check", self._mark, kind="primary", name="mark")
|
||||||
)
|
)
|
||||||
if on_cancel is not None:
|
if on_cancel is not None:
|
||||||
buttons.append(ui.button(actions, "Cancel", on_cancel, name="cancel"))
|
buttons.append(IconButton(actions, "cross", on_cancel, name="cancel"))
|
||||||
for button in buttons:
|
for button in buttons:
|
||||||
button.pack(side="left", padx=(0, tok.SM))
|
button.pack(side="left", padx=(0, tok.SM))
|
||||||
|
|
||||||
@ -187,12 +212,13 @@ class KeepPanel(tk.Frame):
|
|||||||
# Non-blocking on purpose (no ``with``): waiting would freeze Tk for
|
# Non-blocking on purpose (no ``with``): waiting would freeze Tk for
|
||||||
# the whole network round trip.
|
# the whole network round trip.
|
||||||
if not _SYNC_LOCK.acquire(blocking=False): # pylint: disable=consider-using-with
|
if not _SYNC_LOCK.acquire(blocking=False): # pylint: disable=consider-using-with
|
||||||
self._status.configure(text=_text.BUSY_NOTE, fg=tok.DANGER)
|
self._show("busy")
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
entry = action()
|
entry = action()
|
||||||
except SickDayError as exc:
|
except SickDayError as exc:
|
||||||
self._status.configure(text=str(exc), fg=tok.DANGER)
|
_LOG.warning("sick-day write refused: %s", exc)
|
||||||
|
self._show("refused")
|
||||||
return
|
return
|
||||||
finally:
|
finally:
|
||||||
_SYNC_LOCK.release()
|
_SYNC_LOCK.release()
|
||||||
|
|||||||
@ -1,125 +0,0 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
||||||
"""Wording and calendar arithmetic for the picker -- no Tk in here.
|
|
||||||
|
|
||||||
Every string is the phone's (``wake-alarm/phone_app/lib/screens/
|
|
||||||
sick_day_sheet.dart`` and ``sick_days_body.dart``), so the two pickers read
|
|
||||||
the same. Kept here, apart from the widgets, so it is testable without a
|
|
||||||
display.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import calendar
|
|
||||||
from datetime import date
|
|
||||||
from typing import TYPE_CHECKING, Final
|
|
||||||
|
|
||||||
from sickdays._conflict import free_day_predicate
|
|
||||||
from sickdays._constants import (
|
|
||||||
LOCKER_ALARM,
|
|
||||||
LOCKER_BOOK,
|
|
||||||
LOCKER_DIET,
|
|
||||||
LOCKER_FOCUS,
|
|
||||||
LOCKER_LEETCODE,
|
|
||||||
LOCKER_WORKOUT,
|
|
||||||
)
|
|
||||||
from sickdays._day import to_iso
|
|
||||||
from sickdays._errors import ConflictCheckUnavailableError
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Iterable
|
|
||||||
|
|
||||||
#: What each locker is called on screen, in ``sickdays.LOCKERS`` order.
|
|
||||||
LOCKER_LABELS: Final = {
|
|
||||||
LOCKER_WORKOUT: "Workout lock",
|
|
||||||
LOCKER_LEETCODE: "LeetCode",
|
|
||||||
LOCKER_BOOK: "Book",
|
|
||||||
LOCKER_DIET: "Diet",
|
|
||||||
LOCKER_ALARM: "Wake alarm",
|
|
||||||
LOCKER_FOCUS: "Focus",
|
|
||||||
}
|
|
||||||
|
|
||||||
SHEET_HELP: Final = (
|
|
||||||
"Off = stands down for the day. Switch on any locker that should still "
|
|
||||||
"apply; it then behaves exactly as on a normal day."
|
|
||||||
)
|
|
||||||
INTRO: Final = (
|
|
||||||
"Click a day to mark it sick and choose which lockers still apply; click "
|
|
||||||
"a sick day to change them or clear it. Today or later only. No limit and "
|
|
||||||
"no reason needed."
|
|
||||||
)
|
|
||||||
INTRO_DETAIL: Final = (
|
|
||||||
"On a sick day every locker left off stands down: no alarm, no lock, and "
|
|
||||||
"work left undone is dropped rather than carried forward. A locker "
|
|
||||||
"switched on behaves as on a normal day; a dot marks a sick day that "
|
|
||||||
"keeps one."
|
|
||||||
)
|
|
||||||
PAST_NOTE: Final = "This day has passed; it can no longer be changed."
|
|
||||||
NONE_AHEAD: Final = "None ahead."
|
|
||||||
BUSY_NOTE: Final = "Syncing \u2014 try again in a moment."
|
|
||||||
WEEKDAY_INITIALS: Final = ("M", "T", "W", "T", "F", "S", "S")
|
|
||||||
|
|
||||||
_WEEKDAYS: Final = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
|
|
||||||
#: Tue/Wed/Thu, shaded like the phone's grid (wake-alarm's ``isWorkday``).
|
|
||||||
_WORKDAYS: Final = frozenset({1, 2, 3})
|
|
||||||
|
|
||||||
|
|
||||||
def day_label(day: date) -> str:
|
|
||||||
"""``"Tue 2026-10-20"``."""
|
|
||||||
return f"{_WEEKDAYS[day.weekday()]} {to_iso(day)}"
|
|
||||||
|
|
||||||
|
|
||||||
def kept_label(kept: Iterable[str]) -> str:
|
|
||||||
"""The kept lockers as one line, in :data:`LOCKER_LABELS` order."""
|
|
||||||
chosen = set(kept)
|
|
||||||
return ", ".join(label for key, label in LOCKER_LABELS.items() if key in chosen)
|
|
||||||
|
|
||||||
|
|
||||||
def panel_title(day: date, *, sick: bool) -> str:
|
|
||||||
"""``"Mark sick: Tue 2026-10-20"``, or ``"Sick day: ..."`` when editing."""
|
|
||||||
return f"{'Sick day' if sick else 'Mark sick'}: {day_label(day)}"
|
|
||||||
|
|
||||||
|
|
||||||
def free_day_refusal(day: date) -> str | None:
|
|
||||||
"""Why ``day`` cannot be marked sick for the free-day pool, or None.
|
|
||||||
|
|
||||||
"Could not check" refuses too, with the library's own message: it must
|
|
||||||
never count as "not a free day".
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
is_free = free_day_predicate()
|
|
||||||
except ConflictCheckUnavailableError as exc:
|
|
||||||
return str(exc)
|
|
||||||
if not is_free(day):
|
|
||||||
return None
|
|
||||||
return f"{to_iso(day)} is a free day — release it in Free days first."
|
|
||||||
|
|
||||||
|
|
||||||
def is_workday(day: date) -> bool:
|
|
||||||
"""Whether ``day`` is a weekly workday (Tue/Wed/Thu), shaded on the grid."""
|
|
||||||
return day.weekday() in _WORKDAYS
|
|
||||||
|
|
||||||
|
|
||||||
def month_title(month: date) -> str:
|
|
||||||
"""``"October 2026"``."""
|
|
||||||
return f"{calendar.month_name[month.month]} {month.year}"
|
|
||||||
|
|
||||||
|
|
||||||
def month_weeks(month: date) -> list[list[date | None]]:
|
|
||||||
"""The month as Monday-first weeks, ``None`` padding either end."""
|
|
||||||
return [
|
|
||||||
[None if not cell else date(month.year, month.month, cell) for cell in week]
|
|
||||||
for week in calendar.Calendar(firstweekday=0).monthdayscalendar(
|
|
||||||
month.year, month.month
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def shift_month(month: date, step: int) -> date:
|
|
||||||
"""The first of the month ``step`` months after ``month``."""
|
|
||||||
index = month.year * 12 + month.month - 1 + step
|
|
||||||
return date(index // 12, index % 12 + 1, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def month_bounds(reference: date) -> tuple[date, date]:
|
|
||||||
"""January this year to December next year, the phone's range."""
|
|
||||||
return date(reference.year, 1, 1), date(reference.year + 1, 12, 1)
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user