mirror of
https://github.com/ethanolivertroy/obsidian-markitdown.git
synced 2026-07-22 05:41:54 +00:00
Merge pull request #18 from ethanolivertroy/fix/16-detect-pipx-and-versioned-python
Fix markitdown detection for Homebrew/pipx on macOS (#16)
This commit is contained in:
commit
2364c9f2fa
10 changed files with 719 additions and 157 deletions
|
|
@ -1,5 +1,14 @@
|
|||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Markitdown not detected on macOS** (#16) — discovery now probes versioned Homebrew Pythons (e.g. `python3.11`), pipx venvs (`~/.local/pipx/venvs/markitdown`), and always falls back even when an absolute Python path is configured
|
||||
- **Install button fails under PEP 668** (#16) — "Install Markitdown" creates a plugin-local `.venv` instead of `pip install` into Homebrew's externally-managed Python
|
||||
- Obsidian GUI PATH gaps — spawn env prepends `/opt/homebrew/bin`, `/usr/local/bin`, and `~/.local/bin`
|
||||
- Bundled Python scripts are refreshed when their contents change (community plugin updates no longer keep stale `install_package.py`)
|
||||
|
||||
## 2.1.0
|
||||
|
||||
Feature release with 11 new capabilities and a critical Python detection fix.
|
||||
|
|
|
|||
|
|
@ -109,8 +109,8 @@ This plugin acts as a bridge between Obsidian and Microsoft's Markitdown Python
|
|||
|
||||
## Troubleshooting
|
||||
|
||||
- **Python not found**: The plugin searches common paths automatically. If it still can't find Python, set the full path in settings (e.g., `C:\Users\You\AppData\Local\Programs\Python\Python313\python.exe` on Windows or `/Library/Frameworks/Python.framework/Versions/3.11/bin/python3` on macOS). Check the Troubleshooting section in settings for details on which paths were tried.
|
||||
- **Markitdown not installed**: Run `pip install markitdown[all]` in your terminal using the same Python shown in settings. Or click the "Install Markitdown" button in the settings panel.
|
||||
- **Python not found**: The plugin searches common paths automatically (including Homebrew versioned Pythons and pipx). If it still can't find Python, set the full path in settings (e.g., `C:\Users\You\AppData\Local\Programs\Python\Python313\python.exe` on Windows or `/opt/homebrew/bin/python3.11` on macOS). Check the Troubleshooting section in settings for details on which paths were tried.
|
||||
- **Markitdown not installed / shows Not installed on macOS**: Click **Install Markitdown** in settings — this installs into a plugin-local virtualenv and avoids Homebrew's PEP 668 (`externally-managed-environment`) errors. If you already installed via `pipx install markitdown` or into a specific Homebrew Python (e.g. 3.11), click **Refresh status**; the plugin probes pipx and versioned interpreters automatically.
|
||||
- **Conversion errors**: Check the console (Ctrl+Shift+I / Cmd+Opt+I) or the conversion history (command palette → "View conversion history")
|
||||
- **Missing dependencies**: Some file formats may require additional Python packages. The plugin will try to install these automatically.
|
||||
|
||||
|
|
|
|||
237
main.js
237
main.js
File diff suppressed because one or more lines are too long
2
package-lock.json
generated
2
package-lock.json
generated
|
|
@ -14,7 +14,7 @@
|
|||
"builtin-modules": "3.3.0",
|
||||
"esbuild": "0.25.0",
|
||||
"jest": "^30.3.0",
|
||||
"obsidian": "*",
|
||||
"obsidian": "latest",
|
||||
"ts-jest": "^29.4.6",
|
||||
"tslib": "^2.6.0",
|
||||
"typescript": "^5.4.0"
|
||||
|
|
|
|||
|
|
@ -1,51 +1,99 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Install a Python package using pip with progress output.
|
||||
"""Install a Python package into a plugin-managed virtual environment.
|
||||
|
||||
Usage:
|
||||
python install_package.py --package "markitdown[all]"
|
||||
python install_package.py --package "markitdown[all]" --venv-dir /path/to/.venv
|
||||
|
||||
Installing into a dedicated venv avoids PEP 668 externally-managed-environment
|
||||
errors from Homebrew / system Python, and keeps the plugin's dependency isolated.
|
||||
|
||||
Output:
|
||||
Streams pip output line by line to stdout.
|
||||
Final line is JSON: {"success": true} or {"success": false, "error": "message"}
|
||||
Final line is JSON: {"success": true, "python_path": "..."} or
|
||||
{"success": false, "error": "message"}
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def venv_python(venv_dir: str) -> str:
|
||||
if sys.platform == "win32":
|
||||
return os.path.join(venv_dir, "Scripts", "python.exe")
|
||||
return os.path.join(venv_dir, "bin", "python")
|
||||
|
||||
|
||||
def run_pip(python: str, package: str) -> int:
|
||||
process = subprocess.Popen(
|
||||
[python, "-m", "pip", "install", package],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
|
||||
if process.stdout is not None:
|
||||
for line in process.stdout:
|
||||
print(line, end="", flush=True)
|
||||
|
||||
process.wait()
|
||||
return process.returncode or 0
|
||||
|
||||
|
||||
def ensure_venv(venv_dir: str, bootstrap_python: str) -> str:
|
||||
py = venv_python(venv_dir)
|
||||
if os.path.isfile(py):
|
||||
return py
|
||||
|
||||
print(f"Creating virtual environment at {venv_dir}...", flush=True)
|
||||
result = subprocess.run(
|
||||
[bootstrap_python, "-m", "venv", venv_dir],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0 or not os.path.isfile(py):
|
||||
# Partial/failed create can leave a non-empty dir that blocks retries
|
||||
shutil.rmtree(venv_dir, ignore_errors=True)
|
||||
detail = (result.stderr or result.stdout or "").strip()
|
||||
raise RuntimeError(
|
||||
f"Failed to create virtual environment at {venv_dir}: {detail or f'exit {result.returncode}'}"
|
||||
)
|
||||
return py
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Install a Python package via pip")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Install a Python package into a plugin-managed venv"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--package",
|
||||
required=True,
|
||||
help="Package specification (e.g., 'markitdown[all]')",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--venv-dir",
|
||||
required=True,
|
||||
help="Directory for the plugin-managed virtual environment",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
[sys.executable, "-m", "pip", "install", args.package],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
py = ensure_venv(args.venv_dir, sys.executable)
|
||||
print(f"Installing {args.package} into {py}...", flush=True)
|
||||
code = run_pip(py, args.package)
|
||||
|
||||
for line in process.stdout:
|
||||
print(line, end="", flush=True)
|
||||
|
||||
process.wait()
|
||||
|
||||
if process.returncode == 0:
|
||||
print(json.dumps({"success": True}))
|
||||
if code == 0:
|
||||
print(json.dumps({"success": True, "python_path": py}))
|
||||
else:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"pip exited with code {process.returncode}",
|
||||
"error": f"pip exited with code {code}",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export class SetupModal extends Modal {
|
|||
text: 'Markitdown is not installed. Would you like to install it now?',
|
||||
});
|
||||
el.createEl('p', {
|
||||
text: 'This will install the markitdown Python package using pip.',
|
||||
text: 'This installs markitdown into an isolated plugin virtual environment (safe with Homebrew Python).',
|
||||
cls: 'setting-item-description',
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -373,28 +373,38 @@ export class SettingsTab extends PluginSettingTab {
|
|||
const content = details.createDiv('markitdown-troubleshooting-content');
|
||||
|
||||
content.createEl('p', {
|
||||
text: 'Python was found, but the markitdown package is not installed. Run this command to install it:',
|
||||
});
|
||||
|
||||
const cmdContainer = content.createDiv('markitdown-pip-command');
|
||||
const pipCmd = `${this.plugin.resolvedPythonPath} -m pip install "markitdown[all]"`;
|
||||
cmdContainer.createEl('code', { text: pipCmd });
|
||||
|
||||
const copyBtn = cmdContainer.createEl('button', {
|
||||
text: 'Copy',
|
||||
cls: 'markitdown-copy-button',
|
||||
});
|
||||
copyBtn.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(pipCmd).then(() => {
|
||||
copyBtn.setText('Copied!');
|
||||
setTimeout(() => copyBtn.setText('Copy'), 2000);
|
||||
});
|
||||
text: 'Python was found, but the markitdown package is not importable from that interpreter. Prefer the "Install Markitdown" button below — it creates an isolated plugin venv (avoids Homebrew PEP 668 / externally-managed-environment errors).',
|
||||
});
|
||||
|
||||
content.createEl('p', {
|
||||
text: 'Or use the "Install Markitdown" button below.',
|
||||
text: 'Already installed markitdown another way? The plugin also looks for versioned Homebrew Pythons (e.g. python3.11) and pipx at ~/.local/pipx/venvs/markitdown. Click Refresh status after installing.',
|
||||
cls: 'markitdown-troubleshooting-hint',
|
||||
});
|
||||
|
||||
content.createEl('p', {
|
||||
text: 'Manual alternatives:',
|
||||
});
|
||||
|
||||
const pipxCmd = 'pipx install "markitdown[all]"';
|
||||
const pythonPath = this.plugin.resolvedPythonPath;
|
||||
const quotedPython =
|
||||
pythonPath.includes(' ') ? `"${pythonPath}"` : pythonPath;
|
||||
const pipCmd = `${quotedPython} -m pip install "markitdown[all]"`;
|
||||
|
||||
for (const cmd of [pipxCmd, pipCmd]) {
|
||||
const cmdContainer = content.createDiv('markitdown-pip-command');
|
||||
cmdContainer.createEl('code', { text: cmd });
|
||||
const copyBtn = cmdContainer.createEl('button', {
|
||||
text: 'Copy',
|
||||
cls: 'markitdown-copy-button',
|
||||
});
|
||||
copyBtn.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(cmd).then(() => {
|
||||
copyBtn.setText('Copied!');
|
||||
setTimeout(() => copyBtn.setText('Copy'), 2000);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private renderStatusItem(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { EventEmitter } from 'events';
|
||||
import * as path from 'path';
|
||||
import type { ChildProcess } from 'child_process';
|
||||
|
||||
// ---- mock child_process.spawn before importing the module under test ----
|
||||
|
|
@ -7,8 +8,14 @@ jest.mock('child_process', () => ({
|
|||
spawn: spawnMock,
|
||||
}));
|
||||
|
||||
import { checkDependencies, runPythonScript, getPythonScriptPath } from '../python';
|
||||
import type { DependencyStatus } from '../../types/settings';
|
||||
import {
|
||||
buildPythonCandidates,
|
||||
buildSpawnEnv,
|
||||
checkDependencies,
|
||||
getPluginVenvPython,
|
||||
getPythonScriptPath,
|
||||
runPythonScript,
|
||||
} from '../python';
|
||||
|
||||
// Helper: create a fake ChildProcess that emits events
|
||||
function fakeChild(
|
||||
|
|
@ -71,6 +78,96 @@ describe('getPythonScriptPath', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ---------- buildSpawnEnv ----------
|
||||
describe('buildSpawnEnv', () => {
|
||||
it('prepends common user/brew bin dirs to PATH', () => {
|
||||
const env = buildSpawnEnv();
|
||||
expect(env.PYTHONUTF8).toBe('1');
|
||||
expect(env.PATH).toBeTruthy();
|
||||
if (process.platform !== 'win32') {
|
||||
expect(env.PATH).toContain('/opt/homebrew/bin');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- buildPythonCandidates ----------
|
||||
describe('buildPythonCandidates', () => {
|
||||
const pluginDir = '/plugins/markitdown';
|
||||
const originalPlatform = process.platform;
|
||||
const originalHome = process.env.HOME;
|
||||
const originalUserProfile = process.env.USERPROFILE;
|
||||
const originalLocalAppData = process.env.LOCALAPPDATA;
|
||||
const originalPipxHome = process.env.PIPX_HOME;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||
const restore = (key: string, value: string | undefined) => {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
};
|
||||
restore('HOME', originalHome);
|
||||
restore('USERPROFILE', originalUserProfile);
|
||||
restore('LOCALAPPDATA', originalLocalAppData);
|
||||
restore('PIPX_HOME', originalPipxHome);
|
||||
});
|
||||
|
||||
it('includes plugin venv and pipx python on darwin', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
process.env.HOME = '/Users/test';
|
||||
delete process.env.PIPX_HOME;
|
||||
|
||||
const paths = buildPythonCandidates('python', pluginDir);
|
||||
|
||||
expect(paths[0]).toBe('python');
|
||||
expect(paths).toContain(getPluginVenvPython(pluginDir));
|
||||
expect(paths).toContain('/Users/test/.local/pipx/venvs/markitdown/bin/python');
|
||||
expect(paths).toContain(
|
||||
'/Users/test/Library/Application Support/pipx/venvs/markitdown/bin/python'
|
||||
);
|
||||
expect(paths).toContain('/opt/homebrew/bin/python3');
|
||||
expect(paths).toContain('/opt/homebrew/bin/python3.11');
|
||||
expect(paths).toContain('/opt/homebrew/opt/python@3.11/bin/python3');
|
||||
expect(paths).toContain('/usr/local/bin/python3.12');
|
||||
});
|
||||
|
||||
it('still adds fallbacks when configured path is absolute', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
process.env.HOME = '/Users/test';
|
||||
delete process.env.PIPX_HOME;
|
||||
|
||||
const paths = buildPythonCandidates('/opt/homebrew/bin/python3', pluginDir);
|
||||
|
||||
expect(paths[0]).toBe('/opt/homebrew/bin/python3');
|
||||
expect(paths).toContain('/opt/homebrew/bin/python3.11');
|
||||
expect(paths).toContain('/Users/test/.local/pipx/venvs/markitdown/bin/python');
|
||||
});
|
||||
|
||||
it('respects PIPX_HOME override', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
process.env.HOME = '/Users/test';
|
||||
process.env.PIPX_HOME = '/custom/pipx';
|
||||
|
||||
const paths = buildPythonCandidates('python3', pluginDir);
|
||||
expect(paths).toContain('/custom/pipx/venvs/markitdown/bin/python');
|
||||
});
|
||||
|
||||
it('includes Windows pipx and LocalAppData paths', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
process.env.HOME = 'C:\\Users\\test';
|
||||
process.env.USERPROFILE = 'C:\\Users\\test';
|
||||
process.env.LOCALAPPDATA = 'C:\\Users\\test\\AppData\\Local';
|
||||
delete process.env.PIPX_HOME;
|
||||
|
||||
const paths = buildPythonCandidates('python', pluginDir);
|
||||
|
||||
expect(paths).toContain(path.join(pluginDir, '.venv', 'Scripts', 'python.exe'));
|
||||
expect(paths).toContain(
|
||||
path.join('C:\\Users\\test', '.local', 'pipx', 'venvs', 'markitdown', 'Scripts', 'python.exe')
|
||||
);
|
||||
expect(paths.some(p => p.includes('Programs\\Python\\Python311'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- runPythonScript ----------
|
||||
describe('runPythonScript', () => {
|
||||
it('resolves with stdout, stderr, and exitCode on success', async () => {
|
||||
|
|
@ -81,7 +178,10 @@ describe('runPythonScript', () => {
|
|||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'/usr/bin/python3',
|
||||
['script.py', '--flag'],
|
||||
expect.objectContaining({ shell: false }),
|
||||
expect.objectContaining({
|
||||
shell: false,
|
||||
env: expect.objectContaining({ PYTHONUTF8: '1' }),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
stdout: 'hello world',
|
||||
|
|
@ -154,7 +254,7 @@ describe('checkDependencies', () => {
|
|||
|
||||
it('tries fallback paths when first Python lacks markitdown, prefers one with markitdown', async () => {
|
||||
// First call: python found but no markitdown
|
||||
// Second call: python3 found with markitdown
|
||||
// Second call: next candidate found with markitdown
|
||||
let callCount = 0;
|
||||
spawnMock.mockImplementation(() => {
|
||||
callCount++;
|
||||
|
|
@ -176,11 +276,70 @@ describe('checkDependencies', () => {
|
|||
|
||||
expect(status.markitdownInstalled).toBe(true);
|
||||
expect(status.markitdownVersion).toBe('0.2.0');
|
||||
// The resolved path should be the second candidate (python3)
|
||||
expect(resolvedPythonPath).toBe('python3');
|
||||
// Second candidate is the plugin venv path
|
||||
expect(resolvedPythonPath).toBe(getPluginVenvPython(pluginDir));
|
||||
expect(spawnMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('finds markitdown on a versioned Homebrew Python after generic python3 lacks it', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
process.env.HOME = '/Users/test';
|
||||
|
||||
const paths: string[] = [];
|
||||
spawnMock.mockImplementation((...args: unknown[]) => {
|
||||
const tryPath = args[0] as string;
|
||||
paths.push(tryPath);
|
||||
if (tryPath === '/opt/homebrew/bin/python3.11') {
|
||||
return fakeChild(
|
||||
checkOutput({ markitdownInstalled: true, markitdownVersion: '0.1.6' }),
|
||||
'',
|
||||
0,
|
||||
);
|
||||
}
|
||||
return fakeChild(
|
||||
checkOutput({ markitdownInstalled: false }),
|
||||
'',
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
const { status, resolvedPythonPath } = await checkDependencies(
|
||||
'/opt/homebrew/bin/python3',
|
||||
pluginDir,
|
||||
);
|
||||
|
||||
expect(status.markitdownInstalled).toBe(true);
|
||||
expect(resolvedPythonPath).toBe('/opt/homebrew/bin/python3.11');
|
||||
expect(paths).toContain('/opt/homebrew/bin/python3.11');
|
||||
});
|
||||
|
||||
it('finds markitdown in a pipx venv', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
process.env.HOME = '/Users/test';
|
||||
|
||||
const pipxPython = '/Users/test/.local/pipx/venvs/markitdown/bin/python';
|
||||
spawnMock.mockImplementation((...args: unknown[]) => {
|
||||
const tryPath = args[0] as string;
|
||||
if (tryPath === pipxPython) {
|
||||
return fakeChild(
|
||||
checkOutput({ markitdownInstalled: true, markitdownVersion: '0.1.6' }),
|
||||
'',
|
||||
0,
|
||||
);
|
||||
}
|
||||
return fakeChild(
|
||||
checkOutput({ markitdownInstalled: false }),
|
||||
'',
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
const { status, resolvedPythonPath } = await checkDependencies('python3', pluginDir);
|
||||
|
||||
expect(status.markitdownInstalled).toBe(true);
|
||||
expect(resolvedPythonPath).toBe(pipxPython);
|
||||
});
|
||||
|
||||
it('falls back to first working Python when none has markitdown', async () => {
|
||||
// All candidates: Python works but no markitdown
|
||||
spawnMock.mockImplementation(() => {
|
||||
|
|
@ -211,24 +370,7 @@ describe('checkDependencies', () => {
|
|||
expect(resolvedPythonPath).toBe('python');
|
||||
});
|
||||
|
||||
it('deduplicates paths (does not try the same path twice)', async () => {
|
||||
// Use a specific absolute path that won't generate fallbacks
|
||||
spawnMock.mockReturnValue(
|
||||
fakeChild(
|
||||
checkOutput({ markitdownInstalled: true, markitdownVersion: '1.0.0' }),
|
||||
'',
|
||||
0,
|
||||
),
|
||||
);
|
||||
|
||||
await checkDependencies('/usr/bin/python3', pluginDir);
|
||||
|
||||
// Absolute path doesn't match 'python' or 'python3' so no fallbacks are generated
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('deduplicates when configured path equals a platform fallback', async () => {
|
||||
// Spy on calls to count unique candidates
|
||||
const paths: string[] = [];
|
||||
spawnMock.mockImplementation((...args: unknown[]) => {
|
||||
paths.push(args[0] as string);
|
||||
|
|
@ -241,7 +383,6 @@ describe('checkDependencies', () => {
|
|||
|
||||
await checkDependencies('python3', pluginDir);
|
||||
|
||||
// 'python3' is both the configured path and a common fallback — it should appear only once
|
||||
const python3Count = paths.filter(p => p === 'python3').length;
|
||||
expect(python3Count).toBe(1);
|
||||
});
|
||||
|
|
@ -257,6 +398,7 @@ describe('checkDependencies', () => {
|
|||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
const origEnv = process.env.LOCALAPPDATA;
|
||||
process.env.LOCALAPPDATA = 'C:\\Users\\test\\AppData\\Local';
|
||||
process.env.USERPROFILE = 'C:\\Users\\test';
|
||||
|
||||
const paths: string[] = [];
|
||||
spawnMock.mockImplementation((...args: unknown[]) => {
|
||||
|
|
@ -266,7 +408,6 @@ describe('checkDependencies', () => {
|
|||
|
||||
await checkDependencies('python', pluginDir);
|
||||
|
||||
// Should include Windows-style paths
|
||||
expect(paths.some(p => p.includes('Programs\\Python'))).toBe(true);
|
||||
expect(paths.some(p => p.includes('WindowsApps'))).toBe(true);
|
||||
|
||||
|
|
@ -288,11 +429,11 @@ describe('checkDependencies', () => {
|
|||
|
||||
await checkDependencies('python', pluginDir);
|
||||
|
||||
// Should include Homebrew and framework paths
|
||||
expect(paths).toContain('/opt/homebrew/bin/python3');
|
||||
expect(paths).toContain('/usr/local/bin/python3');
|
||||
expect(paths.some(p => p.includes('Python.framework'))).toBe(true);
|
||||
expect(paths).toContain('/usr/bin/python3');
|
||||
expect(paths).toContain('/opt/homebrew/bin/python3.11');
|
||||
});
|
||||
|
||||
it('adds Linux-specific paths when platform is linux', async () => {
|
||||
|
|
@ -330,7 +471,7 @@ describe('checkDependencies', () => {
|
|||
const { status, resolvedPythonPath } = await checkDependencies('python', pluginDir);
|
||||
|
||||
expect(status.markitdownInstalled).toBe(true);
|
||||
expect(resolvedPythonPath).toBe('python3');
|
||||
expect(resolvedPythonPath).toBe(getPluginVenvPython(pluginDir));
|
||||
});
|
||||
|
||||
it('handles invalid JSON output by skipping the candidate', async () => {
|
||||
|
|
|
|||
|
|
@ -53,27 +53,57 @@ if __name__ == "__main__":
|
|||
`;
|
||||
|
||||
const INSTALL_PACKAGE_PY = `#!/usr/bin/env python3
|
||||
"""Install a Python package using pip."""
|
||||
"""Install a Python package into a plugin-managed virtual environment."""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
def venv_python(venv_dir):
|
||||
if sys.platform == "win32":
|
||||
return os.path.join(venv_dir, "Scripts", "python.exe")
|
||||
return os.path.join(venv_dir, "bin", "python")
|
||||
|
||||
def run_pip(python, package):
|
||||
process = subprocess.Popen(
|
||||
[python, "-m", "pip", "install", package],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
|
||||
if process.stdout is not None:
|
||||
for line in process.stdout:
|
||||
print(line, end="", flush=True)
|
||||
process.wait()
|
||||
return process.returncode or 0
|
||||
|
||||
def ensure_venv(venv_dir, bootstrap_python):
|
||||
py = venv_python(venv_dir)
|
||||
if os.path.isfile(py):
|
||||
return py
|
||||
print(f"Creating virtual environment at {venv_dir}...", flush=True)
|
||||
result = subprocess.run(
|
||||
[bootstrap_python, "-m", "venv", venv_dir],
|
||||
capture_output=True, text=True)
|
||||
if result.returncode != 0 or not os.path.isfile(py):
|
||||
shutil.rmtree(venv_dir, ignore_errors=True)
|
||||
detail = (result.stderr or result.stdout or "").strip()
|
||||
raise RuntimeError(
|
||||
f"Failed to create virtual environment at {venv_dir}: {detail or f'exit {result.returncode}'}")
|
||||
return py
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--package", required=True)
|
||||
parser.add_argument("--venv-dir", required=True)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
[sys.executable, "-m", "pip", "install", args.package],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
|
||||
for line in process.stdout:
|
||||
print(line, end="", flush=True)
|
||||
process.wait()
|
||||
if process.returncode == 0:
|
||||
print(json.dumps({"success": True}))
|
||||
py = ensure_venv(args.venv_dir, sys.executable)
|
||||
print(f"Installing {args.package} into {py}...", flush=True)
|
||||
code = run_pip(py, args.package)
|
||||
if code == 0:
|
||||
print(json.dumps({"success": True, "python_path": py}))
|
||||
else:
|
||||
print(json.dumps({"success": False, "error": f"pip exited with code {process.returncode}"}))
|
||||
print(json.dumps({"success": False, "error": f"pip exited with code {code}"}))
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(json.dumps({"success": False, "error": str(e)}))
|
||||
|
|
@ -217,11 +247,13 @@ export async function ensurePythonScripts(pluginDir: string): Promise<void> {
|
|||
for (const [filename, content] of Object.entries(SCRIPTS)) {
|
||||
const filePath = path.join(pythonDir, filename);
|
||||
try {
|
||||
await fs.promises.access(filePath);
|
||||
// File exists — skip
|
||||
const existing = await fs.promises.readFile(filePath, 'utf-8');
|
||||
if (existing === content) {
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist — write it
|
||||
await fs.promises.writeFile(filePath, content, 'utf-8');
|
||||
// File missing — write below
|
||||
}
|
||||
await fs.promises.writeFile(filePath, content, 'utf-8');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,153 @@ interface PythonResult {
|
|||
exitCode: number;
|
||||
}
|
||||
|
||||
/** Plugin-local venv directory name (inside the plugin folder). */
|
||||
export const PLUGIN_VENV_DIR = '.venv';
|
||||
|
||||
const PYTHON_MINOR_VERSIONS = ['3.14', '3.13', '3.12', '3.11', '3.10', '3.9'] as const;
|
||||
|
||||
/**
|
||||
* Build an env object with PATH entries Obsidian (GUI) often lacks, so bare
|
||||
* names like `python3` and tools in ~/.local/bin resolve correctly.
|
||||
*/
|
||||
export function buildSpawnEnv(extra?: Record<string, string>): NodeJS.ProcessEnv {
|
||||
const home = process.env.HOME || process.env.USERPROFILE || '';
|
||||
const isWin = process.platform === 'win32';
|
||||
const sep = isWin ? ';' : ':';
|
||||
|
||||
const prefixes: string[] = [];
|
||||
if (!isWin) {
|
||||
prefixes.push('/opt/homebrew/bin', '/usr/local/bin');
|
||||
}
|
||||
if (home) {
|
||||
prefixes.push(path.join(home, '.local', 'bin'));
|
||||
if (!isWin) {
|
||||
prefixes.push(path.join(home, '.pyenv', 'shims'));
|
||||
}
|
||||
}
|
||||
|
||||
const currentPath = process.env.PATH || '';
|
||||
const pathParts = [...prefixes, currentPath].filter(Boolean);
|
||||
// Deduplicate while preserving order
|
||||
const seen = new Set<string>();
|
||||
const deduped = pathParts.filter(p => {
|
||||
if (seen.has(p)) return false;
|
||||
seen.add(p);
|
||||
return true;
|
||||
});
|
||||
|
||||
return {
|
||||
...process.env,
|
||||
PYTHONUTF8: '1',
|
||||
PATH: deduped.join(sep),
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute path to the plugin-managed virtualenv Python, if the layout matches.
|
||||
*/
|
||||
export function getPluginVenvPython(pluginDir: string): string {
|
||||
if (process.platform === 'win32') {
|
||||
return path.join(pluginDir, PLUGIN_VENV_DIR, 'Scripts', 'python.exe');
|
||||
}
|
||||
return path.join(pluginDir, PLUGIN_VENV_DIR, 'bin', 'python');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the ordered list of Python executables to probe for markitdown.
|
||||
* Always includes platform fallbacks (not only when the setting is `python`/`python3`)
|
||||
* so versioned Homebrew installs and pipx venvs are found under Obsidian's limited PATH.
|
||||
*/
|
||||
export function buildPythonCandidates(pythonPath: string, pluginDir: string): string[] {
|
||||
const trimmed = pythonPath.trim();
|
||||
const pathsToTry: string[] = [];
|
||||
if (trimmed) {
|
||||
pathsToTry.push(trimmed);
|
||||
}
|
||||
|
||||
// Prefer an existing plugin-managed venv (created by Install Markitdown)
|
||||
pathsToTry.push(getPluginVenvPython(pluginDir));
|
||||
|
||||
const home = process.env.HOME || process.env.USERPROFILE || '';
|
||||
const isWin = process.platform === 'win32';
|
||||
|
||||
// pipx-managed markitdown (isolated venv; system python3 cannot import it).
|
||||
// Probe PIPX_HOME plus legacy (~/.local/pipx) and current platformdirs defaults.
|
||||
const pipxHomes: string[] = [];
|
||||
if (process.env.PIPX_HOME) {
|
||||
pipxHomes.push(process.env.PIPX_HOME);
|
||||
}
|
||||
if (home) {
|
||||
pipxHomes.push(path.join(home, '.local', 'pipx'));
|
||||
if (isWin) {
|
||||
const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
||||
pipxHomes.push(path.join(localAppData, 'pipx'));
|
||||
} else if (process.platform === 'darwin') {
|
||||
pipxHomes.push(path.join(home, 'Library', 'Application Support', 'pipx'));
|
||||
} else {
|
||||
pipxHomes.push(path.join(home, '.local', 'share', 'pipx'));
|
||||
}
|
||||
}
|
||||
for (const pipxHome of pipxHomes) {
|
||||
if (isWin) {
|
||||
pathsToTry.push(path.join(pipxHome, 'venvs', 'markitdown', 'Scripts', 'python.exe'));
|
||||
} else {
|
||||
pathsToTry.push(path.join(pipxHome, 'venvs', 'markitdown', 'bin', 'python'));
|
||||
}
|
||||
}
|
||||
|
||||
if (isWin) {
|
||||
if (trimmed === 'python') pathsToTry.push('python3');
|
||||
else if (trimmed === 'python3') pathsToTry.push('python');
|
||||
else {
|
||||
pathsToTry.push('python', 'python3');
|
||||
}
|
||||
|
||||
const localAppData = process.env.LOCALAPPDATA || '';
|
||||
if (localAppData) {
|
||||
for (const ver of ['314', '313', '312', '311', '310', '39']) {
|
||||
pathsToTry.push(`${localAppData}\\Programs\\Python\\Python${ver}\\python.exe`);
|
||||
}
|
||||
pathsToTry.push(`${localAppData}\\Microsoft\\WindowsApps\\python3.exe`);
|
||||
}
|
||||
} else {
|
||||
// macOS / Linux — bare names plus common absolute locations
|
||||
if (trimmed === 'python') pathsToTry.push('python3');
|
||||
else if (trimmed === 'python3') pathsToTry.push('python');
|
||||
else {
|
||||
pathsToTry.push('python3', 'python');
|
||||
}
|
||||
|
||||
// Generic Homebrew / system shims
|
||||
pathsToTry.push('/opt/homebrew/bin/python3');
|
||||
pathsToTry.push('/usr/local/bin/python3');
|
||||
|
||||
// Versioned Homebrew Pythons (markitdown is often installed into one
|
||||
// specific minor version, e.g. python3.11, while `python3` points elsewhere)
|
||||
for (const ver of PYTHON_MINOR_VERSIONS) {
|
||||
pathsToTry.push(`/opt/homebrew/bin/python${ver}`);
|
||||
pathsToTry.push(`/usr/local/bin/python${ver}`);
|
||||
pathsToTry.push(`/opt/homebrew/opt/python@${ver}/bin/python3`);
|
||||
pathsToTry.push(`/usr/local/opt/python@${ver}/bin/python3`);
|
||||
}
|
||||
|
||||
// Framework installs (python.org macOS installer)
|
||||
for (const ver of PYTHON_MINOR_VERSIONS) {
|
||||
pathsToTry.push(`/Library/Frameworks/Python.framework/Versions/${ver}/bin/python3`);
|
||||
}
|
||||
|
||||
pathsToTry.push('/usr/bin/python3');
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
return pathsToTry.filter(p => {
|
||||
if (!p || seen.has(p)) return false;
|
||||
seen.add(p);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a Python script using spawn() with argument arrays.
|
||||
* NEVER uses exec(). NEVER interpolates user data into shell strings.
|
||||
|
|
@ -22,11 +169,7 @@ export function runPythonScript(
|
|||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(pythonPath, [scriptPath, ...args], {
|
||||
shell: false,
|
||||
env: {
|
||||
...process.env,
|
||||
PYTHONUTF8: '1',
|
||||
...env,
|
||||
},
|
||||
env: buildSpawnEnv(env),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
|
|
@ -65,7 +208,7 @@ export function getPythonScriptPath(scriptName: string, pluginDir: string): stri
|
|||
/**
|
||||
* Check Python installation and package versions using check_install.py.
|
||||
* Returns the status and the resolved python path (which may differ from
|
||||
* the configured path if the python3 fallback was used).
|
||||
* the configured path if a fallback with markitdown was found).
|
||||
*/
|
||||
export async function checkDependencies(
|
||||
pythonPath: string,
|
||||
|
|
@ -88,49 +231,7 @@ export async function checkDependencies(
|
|||
}
|
||||
|
||||
const scriptPath = getPythonScriptPath('check_install.py', pluginDir);
|
||||
|
||||
// Build candidate list: configured path first, then platform-specific fallbacks.
|
||||
// GUI apps (Obsidian/Electron) often don't inherit the user's shell PATH,
|
||||
// so we try well-known Python locations to find one with markitdown installed.
|
||||
const pathsToTry = [trimmed];
|
||||
if (trimmed === 'python' || trimmed === 'python3') {
|
||||
// Generic name — add common platform-specific locations
|
||||
const isWin = process.platform === 'win32';
|
||||
if (isWin) {
|
||||
// Windows: try python, python3, and common install locations
|
||||
if (trimmed === 'python') pathsToTry.push('python3');
|
||||
const localAppData = process.env.LOCALAPPDATA || '';
|
||||
if (localAppData) {
|
||||
// Standard Python installer locations
|
||||
for (const ver of ['313', '312', '311', '310', '39']) {
|
||||
pathsToTry.push(`${localAppData}\\Programs\\Python\\Python${ver}\\python.exe`);
|
||||
}
|
||||
// Microsoft Store Python
|
||||
pathsToTry.push(`${localAppData}\\Microsoft\\WindowsApps\\python3.exe`);
|
||||
}
|
||||
} else {
|
||||
// macOS / Linux: try python3, python, and common framework/brew/system locations
|
||||
if (trimmed === 'python') pathsToTry.push('python3');
|
||||
else pathsToTry.push('python');
|
||||
// Homebrew (Apple Silicon + Intel)
|
||||
pathsToTry.push('/opt/homebrew/bin/python3');
|
||||
pathsToTry.push('/usr/local/bin/python3');
|
||||
// Framework installs (python.org macOS installer)
|
||||
for (const ver of ['3.13', '3.12', '3.11', '3.10', '3.9']) {
|
||||
pathsToTry.push(`/Library/Frameworks/Python.framework/Versions/${ver}/bin/python3`);
|
||||
}
|
||||
// Linux common paths
|
||||
pathsToTry.push('/usr/bin/python3');
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate while preserving order
|
||||
const seen = new Set<string>();
|
||||
const uniquePaths = pathsToTry.filter(p => {
|
||||
if (seen.has(p)) return false;
|
||||
seen.add(p);
|
||||
return true;
|
||||
});
|
||||
const uniquePaths = buildPythonCandidates(trimmed, pluginDir);
|
||||
|
||||
// Try each candidate; prefer the first one that has markitdown installed.
|
||||
// If none has markitdown, fall back to the first working Python.
|
||||
|
|
@ -185,7 +286,8 @@ export async function checkDependencies(
|
|||
}
|
||||
|
||||
/**
|
||||
* Install a Python package using install_package.py.
|
||||
* Install a Python package using install_package.py into the plugin-managed venv.
|
||||
* Avoids PEP 668 externally-managed-environment errors from Homebrew Python.
|
||||
*/
|
||||
export async function installPackage(
|
||||
pythonPath: string,
|
||||
|
|
@ -197,22 +299,27 @@ export async function installPackage(
|
|||
|
||||
return new Promise((resolve, reject) => {
|
||||
const scriptPath = getPythonScriptPath('install_package.py', pluginDir);
|
||||
const child = spawn(pythonPath, [scriptPath, '--package', packageSpec], {
|
||||
shell: false,
|
||||
env: {
|
||||
...process.env,
|
||||
PYTHONUTF8: '1',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
const venvDir = path.join(pluginDir, PLUGIN_VENV_DIR);
|
||||
const child = spawn(
|
||||
pythonPath,
|
||||
[scriptPath, '--package', packageSpec, '--venv-dir', venvDir],
|
||||
{
|
||||
shell: false,
|
||||
env: buildSpawnEnv(),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}
|
||||
);
|
||||
|
||||
let lastLine = '';
|
||||
let stdoutBuf = '';
|
||||
let lastCompleteLine = '';
|
||||
|
||||
child.stdout.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n');
|
||||
stdoutBuf += data.toString();
|
||||
const lines = stdoutBuf.split('\n');
|
||||
stdoutBuf = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
lastLine = line.trim();
|
||||
lastCompleteLine = line.trim();
|
||||
onProgress?.(line.trim());
|
||||
}
|
||||
}
|
||||
|
|
@ -232,10 +339,14 @@ export async function installPackage(
|
|||
});
|
||||
|
||||
child.on('close', (code: number | null) => {
|
||||
const trailing = stdoutBuf.trim();
|
||||
if (trailing) {
|
||||
lastCompleteLine = trailing;
|
||||
onProgress?.(trailing);
|
||||
}
|
||||
if (code === 0) {
|
||||
// Check if the last line is a success JSON
|
||||
try {
|
||||
const result = JSON.parse(lastLine);
|
||||
const result = JSON.parse(lastCompleteLine);
|
||||
resolve(result.success === true);
|
||||
} catch {
|
||||
resolve(true);
|
||||
|
|
|
|||
Loading…
Reference in a new issue