@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 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` available as `python3`
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.0",
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": "./bin/ml-eras-local-runner.js"
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
-
@@ -23,16 +23,47 @@ function _spawn(spawnSync, file, args, opts) {
23
23
  return result;
24
24
  }
25
25
 
26
- function _pythonCandidates(platform = process.platform) {
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
- return [
30
- { file: "py", prefixArgs: ["-3"] },
31
- { file: "python", prefixArgs: [] },
32
- { file: "python3", prefixArgs: [] }
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
- return [{ file: "python3", prefixArgs: [] }, { file: "python", prefixArgs: [] }];
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 "python3 not found. Install Python 3.11+ (e.g. `brew install python@3.11`).";
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
- export function ensurePythonAvailable({ spawnSync = nodeSpawnSync, platform = process.platform } = {}) {
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 chosen = null;
53
- let versionText = null;
103
+ const candidates = _pythonCandidates({ platform, env });
104
+ let bestFound = null;
105
+ let bestVersion = null;
54
106
 
55
107
  for (const c of candidates) {
56
- const result = _spawn(spawnSync, c.file, [...c.prefixArgs, "-c", versionSnippet], { encoding: "utf-8" });
57
- if (result.status === 0) {
58
- chosen = c;
59
- versionText = String(result.stdout || "").trim();
60
- break;
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 (!chosen) {
130
+ if (!bestFound) {
65
131
  return { ok: false, error: _pythonNotFoundMessage(platform) };
66
132
  }
67
- const text = String(versionText || "").trim();
68
- const parts = text.split(".").map((p) => Number(p));
69
- if (parts.length < 2 || !Number.isFinite(parts[0]) || !Number.isFinite(parts[1])) {
70
- return { ok: false, error: `Unable to parse python3 version: ${text}` };
71
- }
72
- const [major, minor] = parts;
73
- if (major < 3 || (major === 3 && minor < 11)) {
74
- return { ok: false, error: `python3 >= 3.11 required (found ${text}).` };
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 python3 ${pythonCheck.version}`);
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("--sync-token", required=True, help="Local sync token (secret)")
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=args.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
-