lc-crusher 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lc_crusher-0.1.0/.github/workflows/publish.yml +85 -0
- lc_crusher-0.1.0/.gitignore +5 -0
- lc_crusher-0.1.0/.pre-commit-config.yaml +8 -0
- lc_crusher-0.1.0/PKG-INFO +52 -0
- lc_crusher-0.1.0/README.md +37 -0
- lc_crusher-0.1.0/pyproject.toml +35 -0
- lc_crusher-0.1.0/src/lc/__init__.py +1 -0
- lc_crusher-0.1.0/src/lc/auth.py +92 -0
- lc_crusher-0.1.0/src/lc/cli.py +325 -0
- lc_crusher-0.1.0/src/lc/client.py +170 -0
- lc_crusher-0.1.0/src/lc/config.py +43 -0
- lc_crusher-0.1.0/src/lc/crusher.py +88 -0
- lc_crusher-0.1.0/src/lc/schedule.py +71 -0
- lc_crusher-0.1.0/src/lc/sheet.py +44 -0
- lc_crusher-0.1.0/src/lc/solutions.py +42 -0
- lc_crusher-0.1.0/uv.lock +885 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
# A docs-only change is not a release.
|
|
7
|
+
paths-ignore: [README.md]
|
|
8
|
+
workflow_dispatch:
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
lint:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: astral-sh/setup-uv@v4
|
|
16
|
+
- name: Check formatting
|
|
17
|
+
run: uv run --only-group dev black --check src/
|
|
18
|
+
|
|
19
|
+
publish:
|
|
20
|
+
needs: lint
|
|
21
|
+
runs-on: ubuntu-latest
|
|
22
|
+
permissions:
|
|
23
|
+
contents: write # needed to push git tags
|
|
24
|
+
|
|
25
|
+
steps:
|
|
26
|
+
- uses: actions/checkout@v4
|
|
27
|
+
with:
|
|
28
|
+
fetch-depth: 0 # full history for tag detection
|
|
29
|
+
|
|
30
|
+
- uses: astral-sh/setup-uv@v4
|
|
31
|
+
|
|
32
|
+
- name: Get next patch version
|
|
33
|
+
id: version
|
|
34
|
+
run: |
|
|
35
|
+
# Fetch latest published version from PyPI
|
|
36
|
+
LATEST=$(python3 -c "
|
|
37
|
+
import urllib.request, json
|
|
38
|
+
try:
|
|
39
|
+
with urllib.request.urlopen('https://pypi.org/pypi/lc-crusher/json') as r:
|
|
40
|
+
print(json.loads(r.read())['info']['version'])
|
|
41
|
+
except Exception:
|
|
42
|
+
print('')
|
|
43
|
+
")
|
|
44
|
+
echo "Latest on PyPI: ${LATEST:-none}"
|
|
45
|
+
|
|
46
|
+
if [ -z "$LATEST" ]; then
|
|
47
|
+
# First release: publish the version declared in pyproject.toml.
|
|
48
|
+
NEW=$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml)
|
|
49
|
+
else
|
|
50
|
+
IFS='.' read -ra V <<< "$LATEST"
|
|
51
|
+
NEW="${V[0]}.${V[1]}.$((V[2] + 1))"
|
|
52
|
+
fi
|
|
53
|
+
echo "Next version: $NEW"
|
|
54
|
+
echo "version=$NEW" >> "$GITHUB_OUTPUT"
|
|
55
|
+
|
|
56
|
+
- name: Bump version
|
|
57
|
+
run: |
|
|
58
|
+
VERSION="${{ steps.version.outputs.version }}"
|
|
59
|
+
sed -i "s/^version = \".*\"/version = \"${VERSION}\"/" pyproject.toml
|
|
60
|
+
sed -i "s/^__version__ = \".*\"/__version__ = \"${VERSION}\"/" src/lc/__init__.py
|
|
61
|
+
grep "^version" pyproject.toml
|
|
62
|
+
|
|
63
|
+
- name: Build
|
|
64
|
+
run: uv build
|
|
65
|
+
|
|
66
|
+
- name: Publish
|
|
67
|
+
run: uv publish
|
|
68
|
+
env:
|
|
69
|
+
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
|
|
70
|
+
|
|
71
|
+
- name: Tag release
|
|
72
|
+
run: |
|
|
73
|
+
git config user.name "github-actions[bot]"
|
|
74
|
+
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
75
|
+
git tag "v${{ steps.version.outputs.version }}"
|
|
76
|
+
git push origin "v${{ steps.version.outputs.version }}"
|
|
77
|
+
|
|
78
|
+
- name: Create GitHub Release
|
|
79
|
+
env:
|
|
80
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
81
|
+
run: |
|
|
82
|
+
VERSION="${{ steps.version.outputs.version }}"
|
|
83
|
+
gh release create "v${VERSION}" dist/*.whl dist/*.tar.gz \
|
|
84
|
+
--title "lc-crusher v${VERSION}" \
|
|
85
|
+
--notes "Install: pipx install lc-crusher==${VERSION} | https://pypi.org/project/lc-crusher/${VERSION}/"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: lc-crusher
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: lc (LeetCode Crusher): pick an unsolved problem, borrow a community Python solution, verify it, submit it.
|
|
5
|
+
Classifier: Environment :: Console
|
|
6
|
+
Classifier: Operating System :: MacOS
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: curl-cffi>=0.7
|
|
10
|
+
Requires-Dist: openpyxl>=3.1
|
|
11
|
+
Requires-Dist: playwright>=1.45
|
|
12
|
+
Requires-Dist: rich>=13.7
|
|
13
|
+
Requires-Dist: typer>=0.12
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# lc — LeetCode Crusher
|
|
17
|
+
|
|
18
|
+
A CLI that picks unsolved LeetCode problems, finds a top-voted community Python
|
|
19
|
+
solution, verifies it against the examples with Run, and submits it.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pipx install lc-crusher
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Requires Python 3.10+ and Google Chrome (used only for signing in).
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
lc login # sign in in a Chrome window; session saved to ~/.config/lc
|
|
33
|
+
lc whoami # signed-in user and solved counts
|
|
34
|
+
lc pick -n 10 # next unsolved problems
|
|
35
|
+
lc crush --dry-run # verify the next problem without submitting
|
|
36
|
+
lc crush -n 5 -d easy # solve 5 unsolved easy problems
|
|
37
|
+
lc now # run the scheduled job immediately (50 problems)
|
|
38
|
+
lc schedule install daily:23:00 # run every day at 23:00 (macOS launchd)
|
|
39
|
+
lc schedule status | remove
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Every accepted problem is appended to `~/Documents/lc-solved.xlsx`, one tab per
|
|
43
|
+
date. Problems a previous run solved or gave up on are never retried unless you
|
|
44
|
+
pass `--retry-failed`.
|
|
45
|
+
|
|
46
|
+
LeetCode allows one Run/Submit at a time per account, so problems are solved
|
|
47
|
+
sequentially.
|
|
48
|
+
|
|
49
|
+
## Disclaimer
|
|
50
|
+
|
|
51
|
+
Automated submissions violate LeetCode's Terms of Service and can get an account
|
|
52
|
+
flagged or banned. Use at your own risk.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# lc — LeetCode Crusher
|
|
2
|
+
|
|
3
|
+
A CLI that picks unsolved LeetCode problems, finds a top-voted community Python
|
|
4
|
+
solution, verifies it against the examples with Run, and submits it.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pipx install lc-crusher
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Requires Python 3.10+ and Google Chrome (used only for signing in).
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
lc login # sign in in a Chrome window; session saved to ~/.config/lc
|
|
18
|
+
lc whoami # signed-in user and solved counts
|
|
19
|
+
lc pick -n 10 # next unsolved problems
|
|
20
|
+
lc crush --dry-run # verify the next problem without submitting
|
|
21
|
+
lc crush -n 5 -d easy # solve 5 unsolved easy problems
|
|
22
|
+
lc now # run the scheduled job immediately (50 problems)
|
|
23
|
+
lc schedule install daily:23:00 # run every day at 23:00 (macOS launchd)
|
|
24
|
+
lc schedule status | remove
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Every accepted problem is appended to `~/Documents/lc-solved.xlsx`, one tab per
|
|
28
|
+
date. Problems a previous run solved or gave up on are never retried unless you
|
|
29
|
+
pass `--retry-failed`.
|
|
30
|
+
|
|
31
|
+
LeetCode allows one Run/Submit at a time per account, so problems are solved
|
|
32
|
+
sequentially.
|
|
33
|
+
|
|
34
|
+
## Disclaimer
|
|
35
|
+
|
|
36
|
+
Automated submissions violate LeetCode's Terms of Service and can get an account
|
|
37
|
+
flagged or banned. Use at your own risk.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "lc-crusher"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "lc (LeetCode Crusher): pick an unsolved problem, borrow a community Python solution, verify it, submit it."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
classifiers = [
|
|
8
|
+
"Environment :: Console",
|
|
9
|
+
"Operating System :: MacOS",
|
|
10
|
+
"Programming Language :: Python :: 3",
|
|
11
|
+
]
|
|
12
|
+
dependencies = [
|
|
13
|
+
"curl_cffi>=0.7",
|
|
14
|
+
"openpyxl>=3.1",
|
|
15
|
+
"playwright>=1.45",
|
|
16
|
+
"rich>=13.7",
|
|
17
|
+
"typer>=0.12",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
lc = "lc.cli:app"
|
|
22
|
+
|
|
23
|
+
[dependency-groups]
|
|
24
|
+
dev = ["black>=24.0", "pre-commit>=3.7"]
|
|
25
|
+
|
|
26
|
+
[tool.black]
|
|
27
|
+
line-length = 120
|
|
28
|
+
target-version = ["py310"]
|
|
29
|
+
|
|
30
|
+
[build-system]
|
|
31
|
+
requires = ["hatchling"]
|
|
32
|
+
build-backend = "hatchling.build"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["src/lc"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import shutil
|
|
3
|
+
import socket
|
|
4
|
+
import subprocess
|
|
5
|
+
import time
|
|
6
|
+
import urllib.request
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .client import BASE, LeetCode
|
|
10
|
+
from .config import CONFIG_DIR
|
|
11
|
+
|
|
12
|
+
LOGIN_URL = f"{BASE}/accounts/login/"
|
|
13
|
+
REQUIRED = ("LEETCODE_SESSION", "csrftoken")
|
|
14
|
+
PROFILE_DIR = CONFIG_DIR / "chrome-profile"
|
|
15
|
+
CHROME_PATHS = [
|
|
16
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
17
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _chrome() -> str:
|
|
22
|
+
for path in CHROME_PATHS:
|
|
23
|
+
if Path(path).exists():
|
|
24
|
+
return path
|
|
25
|
+
for name in ("google-chrome", "chromium", "chromium-browser"):
|
|
26
|
+
if found := shutil.which(name):
|
|
27
|
+
return found
|
|
28
|
+
raise RuntimeError("Google Chrome not found")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _free_port() -> int:
|
|
32
|
+
with socket.socket() as s:
|
|
33
|
+
s.bind(("127.0.0.1", 0))
|
|
34
|
+
return s.getsockname()[1]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _tab_urls(port: int) -> list[str]:
|
|
38
|
+
try:
|
|
39
|
+
with urllib.request.urlopen(f"http://127.0.0.1:{port}/json/list", timeout=2) as r:
|
|
40
|
+
return [t.get("url", "") for t in json.load(r) if t.get("type") == "page"]
|
|
41
|
+
except OSError:
|
|
42
|
+
return []
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _signed_in(cookies: dict[str, str]) -> bool:
|
|
46
|
+
try:
|
|
47
|
+
return LeetCode(cookies).whoami()["isSignedIn"]
|
|
48
|
+
except Exception:
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def browser_login(timeout: int = 300) -> dict[str, str]:
|
|
53
|
+
"""Open an ordinary Chrome window and wait for the user to sign in by hand.
|
|
54
|
+
|
|
55
|
+
Chrome is launched as a plain process (no automation flags) with a dedicated
|
|
56
|
+
profile, and nothing attaches to it until a tab sits on leetcode.com outside
|
|
57
|
+
the login pages. Google refuses sign-in in automation-controlled browsers,
|
|
58
|
+
so the login itself must be untouched. The profile persists, so later
|
|
59
|
+
logins are usually instant.
|
|
60
|
+
"""
|
|
61
|
+
from playwright.sync_api import sync_playwright
|
|
62
|
+
|
|
63
|
+
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
port = _free_port()
|
|
65
|
+
proc = subprocess.Popen(
|
|
66
|
+
[
|
|
67
|
+
_chrome(),
|
|
68
|
+
f"--remote-debugging-port={port}",
|
|
69
|
+
f"--user-data-dir={PROFILE_DIR}",
|
|
70
|
+
"--no-first-run",
|
|
71
|
+
"--no-default-browser-check",
|
|
72
|
+
LOGIN_URL,
|
|
73
|
+
],
|
|
74
|
+
stdout=subprocess.DEVNULL,
|
|
75
|
+
stderr=subprocess.DEVNULL,
|
|
76
|
+
)
|
|
77
|
+
try:
|
|
78
|
+
deadline = time.monotonic() + timeout
|
|
79
|
+
while time.monotonic() < deadline:
|
|
80
|
+
time.sleep(2)
|
|
81
|
+
urls = _tab_urls(port)
|
|
82
|
+
if not any(u.startswith(BASE) and "/accounts/" not in u for u in urls):
|
|
83
|
+
continue
|
|
84
|
+
with sync_playwright() as p:
|
|
85
|
+
browser = p.chromium.connect_over_cdp(f"http://127.0.0.1:{port}")
|
|
86
|
+
cookies = {c["name"]: c["value"] for c in browser.contexts[0].cookies(BASE)}
|
|
87
|
+
found = {k: cookies.get(k, "") for k in REQUIRED}
|
|
88
|
+
if all(found.values()) and _signed_in(found):
|
|
89
|
+
return found
|
|
90
|
+
finally:
|
|
91
|
+
proc.terminate()
|
|
92
|
+
raise TimeoutError(f"No signed-in LeetCode session after {timeout}s")
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import fcntl
|
|
2
|
+
import getpass
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import time
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.syntax import Syntax
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
from . import config
|
|
16
|
+
from .client import LeetCode
|
|
17
|
+
from .crusher import crush
|
|
18
|
+
from .sheet import append_solved
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(help="lc — LeetCode Crusher", no_args_is_help=True, add_completion=False)
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _client(auth: bool = True) -> LeetCode:
|
|
25
|
+
cookies = config.load_session()
|
|
26
|
+
if auth and not cookies:
|
|
27
|
+
console.print("[red]Not logged in.[/] Run [bold]lc login[/] first.")
|
|
28
|
+
raise typer.Exit(1)
|
|
29
|
+
return LeetCode(cookies)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _signed_in(lc: LeetCode) -> dict:
|
|
33
|
+
me = lc.whoami()
|
|
34
|
+
if not me["isSignedIn"]:
|
|
35
|
+
console.print("[red]Session expired.[/] Run [bold]lc login[/] again.")
|
|
36
|
+
raise typer.Exit(1)
|
|
37
|
+
return me
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.command()
|
|
41
|
+
def login(
|
|
42
|
+
manual: bool = typer.Option(False, "--manual", help="Paste cookies instead of opening a browser."),
|
|
43
|
+
timeout: int = typer.Option(300, help="Seconds to wait for you to finish signing in."),
|
|
44
|
+
):
|
|
45
|
+
"""Sign in to LeetCode and save the session locally."""
|
|
46
|
+
if manual:
|
|
47
|
+
cookies = {
|
|
48
|
+
"LEETCODE_SESSION": getpass.getpass("LEETCODE_SESSION: ").strip(),
|
|
49
|
+
"csrftoken": getpass.getpass("csrftoken: ").strip(),
|
|
50
|
+
}
|
|
51
|
+
else:
|
|
52
|
+
from .auth import browser_login
|
|
53
|
+
|
|
54
|
+
console.print("Opening LeetCode — sign in in the browser window; it closes by itself.")
|
|
55
|
+
cookies = browser_login(timeout)
|
|
56
|
+
|
|
57
|
+
me = LeetCode(cookies).whoami()
|
|
58
|
+
if not me["isSignedIn"]:
|
|
59
|
+
console.print("[red]Those cookies are not signed in.[/]")
|
|
60
|
+
raise typer.Exit(1)
|
|
61
|
+
config.save_session(cookies)
|
|
62
|
+
console.print(f"[green]Logged in as[/] [bold]{me['username']}[/]")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.command()
|
|
66
|
+
def logout():
|
|
67
|
+
"""Forget the saved session."""
|
|
68
|
+
config.clear_session()
|
|
69
|
+
console.print("Logged out.")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.command()
|
|
73
|
+
def whoami():
|
|
74
|
+
"""Show the signed-in user and solved counts."""
|
|
75
|
+
lc = _client()
|
|
76
|
+
me = _signed_in(lc)
|
|
77
|
+
counts = lc.solved_counts(me["username"])
|
|
78
|
+
premium = " (premium)" if me["isPremium"] else ""
|
|
79
|
+
console.print(f"[bold]{me['username']}[/]{premium} solved: " + " ".join(f"{k}={v}" for k, v in counts.items()))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.command()
|
|
83
|
+
def pick(
|
|
84
|
+
difficulty: Optional[str] = typer.Option(None, "-d", "--difficulty", help="easy | medium | hard"),
|
|
85
|
+
count: int = typer.Option(10, "-n", help="How many to list."),
|
|
86
|
+
):
|
|
87
|
+
"""List the next unsolved problems."""
|
|
88
|
+
lc = _client()
|
|
89
|
+
me = _signed_in(lc)
|
|
90
|
+
table = Table("#", "title", "difficulty")
|
|
91
|
+
for i, q in enumerate(lc.unsolved(difficulty, include_paid=me["isPremium"])):
|
|
92
|
+
if i >= count:
|
|
93
|
+
break
|
|
94
|
+
table.add_row(q["questionFrontendId"], f"{q['title']} [dim]({q['titleSlug']})[/]", q["difficulty"])
|
|
95
|
+
console.print(table)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@app.command("crush")
|
|
99
|
+
def crush_cmd(
|
|
100
|
+
slug: Optional[str] = typer.Argument(None, help="Problem slug (e.g. two-sum). Default: next unsolved."),
|
|
101
|
+
count: int = typer.Option(1, "-n", "--count", help="How many unsolved problems to crush."),
|
|
102
|
+
difficulty: Optional[str] = typer.Option(None, "-d", "--difficulty", help="easy | medium | hard"),
|
|
103
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Verify with Run but do not Submit."),
|
|
104
|
+
show_code: bool = typer.Option(False, "--show-code", help="Print the code that was used."),
|
|
105
|
+
delay: float = typer.Option(10.0, help="Seconds to wait between problems."),
|
|
106
|
+
max_articles: int = typer.Option(30, help="Solution articles to scan per problem."),
|
|
107
|
+
max_submits: int = typer.Option(3, help="Submit attempts per problem before skipping."),
|
|
108
|
+
sheet: Path = typer.Option(config.SHEET_FILE, help="Excel workbook to log solved problems to (one tab per date)."),
|
|
109
|
+
retry_failed: bool = typer.Option(False, help="Retry problems a previous run gave up on."),
|
|
110
|
+
notify: bool = typer.Option(False, help="Show a macOS notification when finished."),
|
|
111
|
+
):
|
|
112
|
+
"""Pick unsolved problem(s), find a working Python solution, run it, submit it."""
|
|
113
|
+
run_started = datetime.now()
|
|
114
|
+
if not _acquire_run_lock():
|
|
115
|
+
console.print("[yellow]Another lc crush is already running.[/] LeetCode allows one at a time.")
|
|
116
|
+
raise typer.Exit(1)
|
|
117
|
+
try:
|
|
118
|
+
lc = _client()
|
|
119
|
+
me = _signed_in(lc)
|
|
120
|
+
except typer.Exit:
|
|
121
|
+
if notify:
|
|
122
|
+
_notify("lc crush failed", "Not logged in or session expired. Run lc login.")
|
|
123
|
+
raise
|
|
124
|
+
|
|
125
|
+
attempted = set() if (slug or retry_failed) else config.attempted_slugs()
|
|
126
|
+
slugs = (
|
|
127
|
+
[slug]
|
|
128
|
+
if slug
|
|
129
|
+
else (
|
|
130
|
+
q["titleSlug"]
|
|
131
|
+
for q in lc.unsolved(difficulty, include_paid=me["isPremium"])
|
|
132
|
+
if q["titleSlug"] not in attempted
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
target = 1 if slug else count
|
|
136
|
+
done = failed = 0
|
|
137
|
+
console.print(f"[dim]{run_started:%Y-%m-%d %H:%M} — target {target}, logging to {sheet}[/]")
|
|
138
|
+
|
|
139
|
+
for s in slugs:
|
|
140
|
+
if done >= target:
|
|
141
|
+
break
|
|
142
|
+
try:
|
|
143
|
+
p = lc.problem(s)
|
|
144
|
+
except Exception as e:
|
|
145
|
+
console.print(f"[yellow]skip {s}[/]: {e}")
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
console.rule(f"[cyan]\\[{done}/{target}][/] [bold]{p.frontend_id}. {p.title}[/] [dim]{p.difficulty}[/]")
|
|
149
|
+
try:
|
|
150
|
+
out = crush(lc, p, console.print, max_articles=max_articles, max_submits=max_submits, dry_run=dry_run)
|
|
151
|
+
except Exception as e:
|
|
152
|
+
console.print(f"[red]error[/]: {e}")
|
|
153
|
+
failed += 1
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
if out.code and show_code:
|
|
157
|
+
console.print(Syntax(out.code, "python", theme="ansi_dark"))
|
|
158
|
+
if out.accepted:
|
|
159
|
+
r = out.result
|
|
160
|
+
done += 1
|
|
161
|
+
console.print(
|
|
162
|
+
f"[bold green]ACCEPTED[/] [cyan]{done}/{target}[/] runtime {r.get('status_runtime')} "
|
|
163
|
+
f"(beats {r.get('runtime_percentile') or 0:.1f}%), memory {r.get('status_memory')}"
|
|
164
|
+
)
|
|
165
|
+
try:
|
|
166
|
+
append_solved(
|
|
167
|
+
sheet,
|
|
168
|
+
run_started,
|
|
169
|
+
datetime.now(),
|
|
170
|
+
{
|
|
171
|
+
"frontend_id": p.frontend_id,
|
|
172
|
+
"title": p.title,
|
|
173
|
+
"slug": p.slug,
|
|
174
|
+
"difficulty": p.difficulty,
|
|
175
|
+
"runtime": r.get("status_runtime", ""),
|
|
176
|
+
"memory": r.get("status_memory", ""),
|
|
177
|
+
},
|
|
178
|
+
)
|
|
179
|
+
except OSError as e:
|
|
180
|
+
console.print(f"[yellow]could not write sheet[/]: {e}")
|
|
181
|
+
elif out.verified and dry_run:
|
|
182
|
+
done += 1
|
|
183
|
+
console.print(f"[cyan]verified {done}/{target} (dry run, not submitted)[/]")
|
|
184
|
+
else:
|
|
185
|
+
console.print(f"[red]skipped[/]: {out.reason} [cyan]({done}/{target})[/]")
|
|
186
|
+
failed += 1
|
|
187
|
+
|
|
188
|
+
config.append_history(
|
|
189
|
+
{
|
|
190
|
+
"at": datetime.now(timezone.utc).isoformat(),
|
|
191
|
+
"slug": p.slug,
|
|
192
|
+
"frontend_id": p.frontend_id,
|
|
193
|
+
"title": p.title,
|
|
194
|
+
"difficulty": p.difficulty,
|
|
195
|
+
"accepted": out.accepted,
|
|
196
|
+
"dry_run": dry_run,
|
|
197
|
+
"runs": out.runs,
|
|
198
|
+
"submits": out.submits,
|
|
199
|
+
"reason": out.reason,
|
|
200
|
+
}
|
|
201
|
+
)
|
|
202
|
+
if slug is None and done < target:
|
|
203
|
+
time.sleep(delay)
|
|
204
|
+
|
|
205
|
+
console.rule()
|
|
206
|
+
console.print(f"crushed [green]{done}[/], skipped [red]{failed}[/]")
|
|
207
|
+
if notify:
|
|
208
|
+
_notify("lc crush finished", f"Solved {done}/{target}, skipped {failed}")
|
|
209
|
+
if done < target:
|
|
210
|
+
raise typer.Exit(1)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
_run_lock = None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _acquire_run_lock() -> bool:
|
|
217
|
+
"""Hold an exclusive flock for the life of the process; the OS releases it on exit."""
|
|
218
|
+
global _run_lock
|
|
219
|
+
config.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
220
|
+
_run_lock = (config.CONFIG_DIR / "crush.lock").open("w")
|
|
221
|
+
try:
|
|
222
|
+
fcntl.flock(_run_lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
223
|
+
return True
|
|
224
|
+
except BlockingIOError:
|
|
225
|
+
return False
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@app.command()
|
|
229
|
+
def now(
|
|
230
|
+
background: bool = typer.Option(
|
|
231
|
+
False, "--bg", help="Run through launchd in the background; output goes to the log."
|
|
232
|
+
),
|
|
233
|
+
):
|
|
234
|
+
"""Run the scheduled crush right now, with the schedule's count and sheet."""
|
|
235
|
+
from . import schedule
|
|
236
|
+
|
|
237
|
+
st = schedule.status()
|
|
238
|
+
if background:
|
|
239
|
+
if st is None:
|
|
240
|
+
console.print("No schedule installed. Run [bold]lc schedule install[/] first.")
|
|
241
|
+
raise typer.Exit(1)
|
|
242
|
+
schedule.run_now()
|
|
243
|
+
console.print(f"Started in background. Follow with: tail -f {config.LOG_DIR / 'crush.log'}")
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
args = st["plist"]["ProgramArguments"] if st else []
|
|
247
|
+
count = int(args[args.index("-n") + 1]) if "-n" in args else 50
|
|
248
|
+
sheet = Path(args[args.index("--sheet") + 1]) if "--sheet" in args else config.SHEET_FILE
|
|
249
|
+
crush_cmd(
|
|
250
|
+
slug=None,
|
|
251
|
+
count=count,
|
|
252
|
+
difficulty=None,
|
|
253
|
+
dry_run=False,
|
|
254
|
+
show_code=False,
|
|
255
|
+
delay=10.0,
|
|
256
|
+
max_articles=30,
|
|
257
|
+
max_submits=3,
|
|
258
|
+
sheet=sheet,
|
|
259
|
+
retry_failed=False,
|
|
260
|
+
notify=True,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _notify(title: str, message: str) -> None:
|
|
265
|
+
script = f"display notification {json.dumps(message)} with title {json.dumps(title)}"
|
|
266
|
+
subprocess.run(["osascript", "-e", script], capture_output=True)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
schedule_app = typer.Typer(help="Run lc crush automatically every day.", no_args_is_help=True)
|
|
270
|
+
app.add_typer(schedule_app, name="schedule")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@schedule_app.command("install")
|
|
274
|
+
def schedule_install(
|
|
275
|
+
spec: str = typer.Argument("daily:23:00", help="When to run: daily:HH:MM (24h). Several times: daily:09:00,21:00"),
|
|
276
|
+
count: int = typer.Option(50, "-n", "--count", help="Unique problems to solve per run."),
|
|
277
|
+
sheet: Path = typer.Option(config.SHEET_FILE, help="Excel workbook to log solved problems to."),
|
|
278
|
+
):
|
|
279
|
+
"""Install (or replace) the schedule, e.g. `lc schedule install daily:23:00`."""
|
|
280
|
+
from . import schedule
|
|
281
|
+
|
|
282
|
+
kind, _, times = spec.partition(":")
|
|
283
|
+
if kind != "daily" or not times:
|
|
284
|
+
console.print(f"[red]Bad schedule[/] {spec!r}. Use daily:HH:MM, e.g. daily:23:00")
|
|
285
|
+
raise typer.Exit(1)
|
|
286
|
+
parsed = []
|
|
287
|
+
for t in times.split(","):
|
|
288
|
+
try:
|
|
289
|
+
h, m = (int(x) for x in t.strip().split(":"))
|
|
290
|
+
except ValueError:
|
|
291
|
+
h = m = -1
|
|
292
|
+
if not (0 <= h < 24 and 0 <= m < 60):
|
|
293
|
+
console.print(f"[red]Bad time[/] {t!r}. Use 24h HH:MM, e.g. 23:00")
|
|
294
|
+
raise typer.Exit(1)
|
|
295
|
+
parsed.append((h, m))
|
|
296
|
+
path = schedule.install(parsed, count, sheet.expanduser().resolve())
|
|
297
|
+
console.print(f"[green]Scheduled[/] {count} problems daily at {times} ({path})")
|
|
298
|
+
console.print(f"Logs: {config.LOG_DIR / 'crush.log'}")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@schedule_app.command("status")
|
|
302
|
+
def schedule_status():
|
|
303
|
+
"""Show the installed schedule."""
|
|
304
|
+
from . import schedule
|
|
305
|
+
|
|
306
|
+
st = schedule.status()
|
|
307
|
+
if st is None:
|
|
308
|
+
console.print("No schedule installed.")
|
|
309
|
+
return
|
|
310
|
+
p = st["plist"]
|
|
311
|
+
times = ", ".join(f"{t['Hour']:02d}:{t['Minute']:02d}" for t in p["StartCalendarInterval"])
|
|
312
|
+
console.print(f"{'[green]loaded[/]' if st['loaded'] else '[red]not loaded[/]'} at {times}")
|
|
313
|
+
console.print(" " + " ".join(p["ProgramArguments"]))
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@schedule_app.command("remove")
|
|
317
|
+
def schedule_remove():
|
|
318
|
+
"""Remove the daily schedule."""
|
|
319
|
+
from . import schedule
|
|
320
|
+
|
|
321
|
+
console.print("Removed." if schedule.remove() else "No schedule installed.")
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
if __name__ == "__main__":
|
|
325
|
+
app()
|