@music-league-eras/local-runner 0.1.0 → 0.1.1
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.
- package/README.md +2 -1
- package/package.json +2 -3
- package/src/python/bootstrap.js +94 -29
- package/vendor/python/app/local_runner_cli.py +8 -3
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ extracted `manifest.json` to the Music League Eras API using your short-lived `s
|
|
|
6
6
|
## Prerequisites (macOS MVP)
|
|
7
7
|
|
|
8
8
|
- Node.js `>=18.17`
|
|
9
|
-
- Python `>=3.11`
|
|
9
|
+
- Python `>=3.11` installed (runner will try `python3.12`, `python3.11`, then `python3`)
|
|
10
10
|
|
|
11
11
|
On first run, Playwright may download Chromium (network required).
|
|
12
12
|
|
|
@@ -67,5 +67,6 @@ By default, the runner stores its Python virtualenv under:
|
|
|
67
67
|
Override with:
|
|
68
68
|
|
|
69
69
|
- `ML_ERAS_LOCAL_RUNNER_HOME=/some/dir`
|
|
70
|
+
- `ML_ERAS_PYTHON=python3.11` (or an absolute path to a Python 3.11+ executable)
|
|
70
71
|
|
|
71
72
|
To reset the runner environment, delete that folder.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@music-league-eras/local-runner",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Music League Eras local runner (npx wrapper around the Python scraper runner).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"node": ">=18.17.0"
|
|
9
9
|
},
|
|
10
10
|
"bin": {
|
|
11
|
-
"ml-eras-local-runner": "
|
|
11
|
+
"ml-eras-local-runner": "bin/ml-eras-local-runner.js"
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
14
|
"bin/",
|
|
@@ -25,4 +25,3 @@
|
|
|
25
25
|
"access": "public"
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
|
-
|
package/src/python/bootstrap.js
CHANGED
|
@@ -23,16 +23,47 @@ function _spawn(spawnSync, file, args, opts) {
|
|
|
23
23
|
return result;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
function
|
|
26
|
+
function _pushCandidate(list, candidate) {
|
|
27
|
+
if (!candidate?.file) return;
|
|
28
|
+
const key = `${candidate.file}::${(candidate.prefixArgs || []).join(" ")}`;
|
|
29
|
+
if (list.some((c) => `${c.file}::${(c.prefixArgs || []).join(" ")}` === key)) return;
|
|
30
|
+
list.push(candidate);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function _pythonCandidates({ platform = process.platform, env = process.env } = {}) {
|
|
34
|
+
const candidates = [];
|
|
35
|
+
const override = env?.ML_ERAS_PYTHON;
|
|
36
|
+
if (override && override.trim()) {
|
|
37
|
+
_pushCandidate(candidates, { file: override.trim(), prefixArgs: [] });
|
|
38
|
+
}
|
|
39
|
+
|
|
27
40
|
if (platform === "win32") {
|
|
28
41
|
// Prefer the official Python Launcher (`py -3`) if present.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
];
|
|
42
|
+
_pushCandidate(candidates, { file: "py", prefixArgs: ["-3"] });
|
|
43
|
+
_pushCandidate(candidates, { file: "python", prefixArgs: [] });
|
|
44
|
+
_pushCandidate(candidates, { file: "python3", prefixArgs: [] });
|
|
45
|
+
return candidates;
|
|
34
46
|
}
|
|
35
|
-
|
|
47
|
+
|
|
48
|
+
// Prefer explicitly versioned executables first, since macOS may have an older system `python3`.
|
|
49
|
+
_pushCandidate(candidates, { file: "python3.12", prefixArgs: [] });
|
|
50
|
+
_pushCandidate(candidates, { file: "python3.11", prefixArgs: [] });
|
|
51
|
+
|
|
52
|
+
// Common Homebrew locations (arm64 + intel).
|
|
53
|
+
_pushCandidate(candidates, { file: "/opt/homebrew/bin/python3.12", prefixArgs: [] });
|
|
54
|
+
_pushCandidate(candidates, { file: "/opt/homebrew/bin/python3.11", prefixArgs: [] });
|
|
55
|
+
_pushCandidate(candidates, { file: "/usr/local/bin/python3.12", prefixArgs: [] });
|
|
56
|
+
_pushCandidate(candidates, { file: "/usr/local/bin/python3.11", prefixArgs: [] });
|
|
57
|
+
|
|
58
|
+
// Keg-only Homebrew formula locations (no PATH changes needed).
|
|
59
|
+
_pushCandidate(candidates, { file: "/opt/homebrew/opt/python@3.12/bin/python3.12", prefixArgs: [] });
|
|
60
|
+
_pushCandidate(candidates, { file: "/opt/homebrew/opt/python@3.11/bin/python3.11", prefixArgs: [] });
|
|
61
|
+
_pushCandidate(candidates, { file: "/usr/local/opt/python@3.12/bin/python3.12", prefixArgs: [] });
|
|
62
|
+
_pushCandidate(candidates, { file: "/usr/local/opt/python@3.11/bin/python3.11", prefixArgs: [] });
|
|
63
|
+
|
|
64
|
+
_pushCandidate(candidates, { file: "python3", prefixArgs: [] });
|
|
65
|
+
_pushCandidate(candidates, { file: "python", prefixArgs: [] });
|
|
66
|
+
return candidates;
|
|
36
67
|
}
|
|
37
68
|
|
|
38
69
|
function _pythonNotFoundMessage(platform = process.platform) {
|
|
@@ -43,37 +74,71 @@ function _pythonNotFoundMessage(platform = process.platform) {
|
|
|
43
74
|
"then re-run: `npx @music-league-eras/local-runner@latest bootstrap`."
|
|
44
75
|
].join(" ");
|
|
45
76
|
}
|
|
46
|
-
return
|
|
77
|
+
return [
|
|
78
|
+
"python not found.",
|
|
79
|
+
"Install Python 3.11+ (e.g. `brew install python@3.11`),",
|
|
80
|
+
"or set ML_ERAS_PYTHON=/path/to/python3.11."
|
|
81
|
+
].join(" ");
|
|
47
82
|
}
|
|
48
83
|
|
|
49
|
-
|
|
84
|
+
function _parsePythonVersion(text) {
|
|
85
|
+
const trimmed = String(text || "").trim();
|
|
86
|
+
const parts = trimmed.split(".").map((p) => Number(p));
|
|
87
|
+
if (parts.length < 2 || !Number.isFinite(parts[0]) || !Number.isFinite(parts[1])) return null;
|
|
88
|
+
return { major: parts[0], minor: parts[1], patch: Number.isFinite(parts[2]) ? parts[2] : null, raw: trimmed };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function _isAtLeast({ major, minor }, requiredMajor, requiredMinor) {
|
|
92
|
+
if (major > requiredMajor) return true;
|
|
93
|
+
if (major < requiredMajor) return false;
|
|
94
|
+
return minor >= requiredMinor;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function ensurePythonAvailable({
|
|
98
|
+
spawnSync = nodeSpawnSync,
|
|
99
|
+
platform = process.platform,
|
|
100
|
+
env = process.env
|
|
101
|
+
} = {}) {
|
|
50
102
|
const versionSnippet = "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}')";
|
|
51
|
-
const candidates = _pythonCandidates(platform);
|
|
52
|
-
let
|
|
53
|
-
let
|
|
103
|
+
const candidates = _pythonCandidates({ platform, env });
|
|
104
|
+
let bestFound = null;
|
|
105
|
+
let bestVersion = null;
|
|
54
106
|
|
|
55
107
|
for (const c of candidates) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
108
|
+
let result;
|
|
109
|
+
try {
|
|
110
|
+
result = _spawn(spawnSync, c.file, [...c.prefixArgs, "-c", versionSnippet], { encoding: "utf-8" });
|
|
111
|
+
} catch {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (result.status !== 0) continue;
|
|
115
|
+
|
|
116
|
+
const parsed = _parsePythonVersion(result.stdout);
|
|
117
|
+
if (!parsed) continue;
|
|
118
|
+
|
|
119
|
+
// Track the best version found for a more helpful error message.
|
|
120
|
+
if (!bestVersion || _isAtLeast(parsed, bestVersion.major, bestVersion.minor)) {
|
|
121
|
+
bestFound = c;
|
|
122
|
+
bestVersion = parsed;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (_isAtLeast(parsed, 3, 11)) {
|
|
126
|
+
return { ok: true, version: parsed.raw, python: c };
|
|
61
127
|
}
|
|
62
128
|
}
|
|
63
129
|
|
|
64
|
-
if (!
|
|
130
|
+
if (!bestFound) {
|
|
65
131
|
return { ok: false, error: _pythonNotFoundMessage(platform) };
|
|
66
132
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
return { ok: true, version: text, python: chosen };
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
error: [
|
|
137
|
+
`python >= 3.11 required (found ${bestVersion.raw} via ${bestFound.file}).`,
|
|
138
|
+
"If you installed Python 3.11+ already, try running `python3.11 --version` and re-running the bootstrap,",
|
|
139
|
+
"or set ML_ERAS_PYTHON=python3.11."
|
|
140
|
+
].join(" ")
|
|
141
|
+
};
|
|
77
142
|
}
|
|
78
143
|
|
|
79
144
|
export function bootstrapPythonRunner({
|
|
@@ -90,7 +155,7 @@ export function bootstrapPythonRunner({
|
|
|
90
155
|
log.error(pythonCheck.error);
|
|
91
156
|
return { ok: false, exitCode: 2 };
|
|
92
157
|
}
|
|
93
|
-
log.info(`Using
|
|
158
|
+
log.info(`Using python ${pythonCheck.version} (${pythonCheck.python.file})`);
|
|
94
159
|
|
|
95
160
|
const venvDir = platform === "win32" ? path.win32.join(runnerHome, "venv") : path.join(runnerHome, "venv");
|
|
96
161
|
const venvPython = resolveVenvPython(runnerHome, platform);
|
|
@@ -7,6 +7,7 @@ from datetime import datetime, timezone
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
9
|
from .local_sync_runner import run_local_sync
|
|
10
|
+
from .sync_token import resolve_sync_token
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
def _env_bool(key: str, default: bool) -> bool:
|
|
@@ -49,7 +50,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
49
50
|
)
|
|
50
51
|
local_sync_parser.add_argument("--api-base-url", required=True, help="API base URL (e.g. http://localhost:8000)")
|
|
51
52
|
local_sync_parser.add_argument("--sync-session-id", required=True, help="Local sync session id")
|
|
52
|
-
local_sync_parser.add_argument(
|
|
53
|
+
local_sync_parser.add_argument(
|
|
54
|
+
"--sync-token",
|
|
55
|
+
required=False,
|
|
56
|
+
help="Local sync token (secret). If omitted, reads ML_ERAS_SYNC_TOKEN or prompts.",
|
|
57
|
+
)
|
|
53
58
|
local_sync_parser.add_argument(
|
|
54
59
|
"--music-league-base-url",
|
|
55
60
|
default=_default_music_league_base_url(),
|
|
@@ -91,11 +96,12 @@ def main(argv: list[str] | None = None) -> None:
|
|
|
91
96
|
raise SystemExit(f"Unknown command: {args.command}")
|
|
92
97
|
|
|
93
98
|
out_dir = args.out_dir or _default_runner_out_dir(args.sync_session_id)
|
|
99
|
+
sync_token = resolve_sync_token(flag_token=args.sync_token)
|
|
94
100
|
result = asyncio.run(
|
|
95
101
|
run_local_sync(
|
|
96
102
|
api_base_url=args.api_base_url,
|
|
97
103
|
sync_session_id=args.sync_session_id,
|
|
98
|
-
sync_token=
|
|
104
|
+
sync_token=sync_token,
|
|
99
105
|
music_league_base_url=args.music_league_base_url,
|
|
100
106
|
capture_headless=args.capture_headless,
|
|
101
107
|
scrape_headless=args.scrape_headless,
|
|
@@ -109,4 +115,3 @@ def main(argv: list[str] | None = None) -> None:
|
|
|
109
115
|
|
|
110
116
|
if __name__ == "__main__":
|
|
111
117
|
main()
|
|
112
|
-
|