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:
Ethan Troy 2026-07-20 16:09:07 -04:00 committed by GitHub
commit 2364c9f2fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 719 additions and 157 deletions

View file

@ -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.

View file

@ -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

File diff suppressed because one or more lines are too long

2
package-lock.json generated
View file

@ -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"

View file

@ -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}",
}
)
)

View file

@ -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',
});

View file

@ -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(

View file

@ -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 () => {

View file

@ -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');
}
}

View file

@ -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);