Skip to content
Draft
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
939dc42
Add an `--index-url` flag for the `piplite` CLI
agriyakhetarpal Feb 13, 2025
bed093c
Add `pipliteInstallDefaultOptions` to schema, types
agriyakhetarpal Feb 13, 2025
5d45a40
Fix a typo
agriyakhetarpal Feb 13, 2025
83757c0
Add effective index URLs to pass to piplite
agriyakhetarpal Feb 13, 2025
a9b539d
Add `--index-url`, `-i`, to CLI flags
agriyakhetarpal Feb 13, 2025
95b4700
Handle index URLs with requirements files
agriyakhetarpal Feb 13, 2025
32e0f51
Fix JS prettier lint errors
agriyakhetarpal Feb 13, 2025
8014816
Now fix Python linter errors
agriyakhetarpal Feb 13, 2025
48d1970
Fix typo
agriyakhetarpal Feb 13, 2025
7207454
Log what index URL is being used if verbose
agriyakhetarpal Feb 13, 2025
ffe3907
Fix, allow adding index URL inside a requirements file
agriyakhetarpal Feb 13, 2025
1c8e574
Mark CLI alias for index_urls in docstring
agriyakhetarpal Feb 14, 2025
e7d3818
Hopefully fix Python linter
agriyakhetarpal Feb 14, 2025
a5e9565
Handle tuple unpacking better
agriyakhetarpal Feb 14, 2025
c04d84f
Try to fix index URLs in requirements file
agriyakhetarpal Feb 14, 2025
23852ca
Rename `indexUrls` to `index_urls`
agriyakhetarpal Feb 14, 2025
b3f7808
Single source of truth for installation defaults
agriyakhetarpal Feb 14, 2025
d0fd31a
Fix Python formatting
agriyakhetarpal Feb 14, 2025
a9a62b3
Revert "Fix Python formatting"
agriyakhetarpal Feb 14, 2025
2d7aed6
Revert "Single source of truth for installation defaults"
agriyakhetarpal Feb 14, 2025
a1bcf66
Reapply "Single source of truth for installation defaults"
agriyakhetarpal Feb 14, 2025
a8cf844
Reapply "Fix Python formatting"
agriyakhetarpal Feb 14, 2025
d2192fe
Fix boolean capitalisation b/w JS/TS and Python
agriyakhetarpal Feb 14, 2025
4a7116c
Add a TS fix
agriyakhetarpal Feb 14, 2025
443c206
Fix index URLs and requirements files again
agriyakhetarpal Feb 14, 2025
e4e7a30
Some more fixes for install order precedence
agriyakhetarpal Feb 14, 2025
fec4e1b
More fixes
agriyakhetarpal Feb 14, 2025
98576dc
Simplify handling yet again
agriyakhetarpal Feb 15, 2025
d694c00
Fix URL handling that can lead to silent failures
agriyakhetarpal Feb 15, 2025
3fc2381
Temporarily remove NumPy, add SPNW index URL
agriyakhetarpal Feb 15, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/pyodide-kernel-extension/schema/kernel.v0.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,21 @@
"default": [],
"format": "uri"
},
"pipliteInstallDefaultOptions": {
"type": "object",
"description": "Default options to pass to piplite.install",
"default": {},
"properties": {
"indexUrls": {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would reckon this should be index_urls, and then wherever possible, the parent object handed around as a blob of JSON-compatible kwargs.

"type": "array",
"items": {
"type": "string",
"format": "uri"
},
"description": "Base URLs of extra indices to use"
}
}
},
"loadPyodideOptions": {
"type": "object",
"description": "additional options to provide to `loadPyodide`, see https://pyodide.org/en/stable/usage/api/js-api.html#globalThis.loadPyodide",
Expand Down
8 changes: 8 additions & 0 deletions packages/pyodide-kernel-extension/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ const kernel: JupyterLiteServerPlugin<void> = {
const pipliteUrls = rawPipUrls.map((pipUrl: string) => URLExt.parse(pipUrl).href);
const disablePyPIFallback = !!config.disablePyPIFallback;
const loadPyodideOptions = config.loadPyodideOptions || {};
const pipliteInstallDefaultOptions = config.pipliteInstallDefaultOptions || {};

// Parse any configured index URLs
const indexUrls = pipliteInstallDefaultOptions.indexUrls || [];

for (const [key, value] of Object.entries(loadPyodideOptions)) {
if (key.endsWith('URL') && typeof value === 'string') {
Expand Down Expand Up @@ -99,6 +103,10 @@ const kernel: JupyterLiteServerPlugin<void> = {
mountDrive,
loadPyodideOptions,
contentsManager,
pipliteInstallDefaultOptions: {
indexUrls,
...pipliteInstallDefaultOptions,
},
});
},
});
Expand Down
126 changes: 99 additions & 27 deletions packages/pyodide-kernel/py/piplite/piplite/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ async def install(
deps: bool = True, # --no-deps
credentials: str | None = None, # no CLI alias
pre: bool = False, # --pre
index_urls: list[str] | str | None = None, # no CLI alias
index_urls: list[str] | str | None = None, # -i and --index-url
*,
verbose: bool | int | None = None,
):
Expand All @@ -26,10 +26,32 @@ async def install(
import re
import sys
import typing
from typing import Optional, List, Tuple
from dataclasses import dataclass

from argparse import ArgumentParser
from pathlib import Path


@dataclass
class RequirementsContext:
"""Track state while parsing requirements files."""

index_url: Optional[str] = None
requirements: List[str] = None

def __post_init__(self):
if self.requirements is None:
self.requirements = []

def add_requirement(self, req: str):
"""Add a requirement with the currently active index URL."""
self.requirements.append((req, self.index_url))


REQ_FILE_PREFIX = r"^(-r|--requirements)\s*=?\s*(.*)\s*"
INDEX_URL_PREFIX = r"^(--index-url|-i)\s*=?\s*(.*)\s*"


__all__ = ["get_transformed_code"]

Expand Down Expand Up @@ -76,6 +98,12 @@ def _get_parser() -> ArgumentParser:
action="store_true",
help="whether pre-release packages should be considered",
)
parser.add_argument(
"--index-url",
"-i",
type=str,
help="the index URL to use for package lookup",
)
parser.add_argument(
"packages",
nargs="*",
Expand Down Expand Up @@ -111,11 +139,44 @@ async def get_action_kwargs(argv: list[str]) -> tuple[typing.Optional[str], dict
return None, {}

kwargs = {}

action = args.action

if action == "install":
kwargs["requirements"] = args.packages
all_requirements = []

if args.packages:
all_requirements.extend((pkg, args.index_url) for pkg in args.packages)

# Process requirements files
for req_file in args.requirements or []:
context = RequirementsContext()

if not Path(req_file).exists():
warn(f"piplite could not find requirements file {req_file}")
continue

for line_no, line in enumerate(
Path(req_file).read_text(encoding="utf-8").splitlines()
):
await _packages_from_requirements_line(
Path(req_file), line_no + 1, line, context
)

all_requirements.extend(context.requirements)

if all_requirements:
kwargs["requirements"] = []
used_index = None

for req, idx in all_requirements:
if idx:
used_index = idx
kwargs["requirements"].append(req)

# Set the index URL if one was found (either passed to the CLI or
# passed within the requirements file)
if used_index:
kwargs["index_urls"] = used_index

if args.pre:
kwargs["pre"] = True
Expand All @@ -126,51 +187,62 @@ async def get_action_kwargs(argv: list[str]) -> tuple[typing.Optional[str], dict
if args.verbose:
kwargs["keep_going"] = True

for req_file in args.requirements or []:
kwargs["requirements"] += await _packages_from_requirements_file(
Path(req_file)
)

return action, kwargs


async def _packages_from_requirements_file(req_path: Path) -> list[str]:
"""Extract (potentially nested) package requirements from a requirements file."""
async def _packages_from_requirements_file(
req_path: Path,
) -> Tuple[List[str], Optional[str]]:
"""Extract (potentially nested) package requirements from a requirements file.

Returns:
Tuple of (list of package requirements, optional index URL)
"""
if not req_path.exists():
warn(f"piplite could not find requirements file {req_path}")
return []

requirements = []
context = RequirementsContext()

for line_no, line in enumerate(req_path.read_text(encoding="utf").splitlines()):
requirements += await _packages_from_requirements_line(
req_path, line_no + 1, line
)
for line_no, line in enumerate(req_path.read_text(encoding="utf-8").splitlines()):
await _packages_from_requirements_line(req_path, line_no + 1, line, context)

return requirements
return context.requirements, context.index_url


async def _packages_from_requirements_line(
req_path: Path, line_no: int, line: str
) -> list[str]:
req_path: Path, line_no: int, line: str, context: RequirementsContext
) -> None:
"""Extract (potentially nested) package requirements from line of a
requirements file.

`micropip` has a sufficient pep508 implementation to handle most cases
"""
req = line.strip().split("#")[0].strip()
# is it another requirement file?
if not req:
return

# Check for nested requirements file
req_file_match = re.match(REQ_FILE_PREFIX, req)
if req_file_match:
if req_file_match[2].startswith("/"):
sub_req = Path(req)
sub_path = req_file_match[2]
if sub_path.startswith("/"):
sub_req = Path(sub_path)
else:
sub_req = req_path.parent / req_file_match[2]
return await _packages_from_requirements_file(sub_req)
sub_req = req_path.parent / sub_path
sub_reqs, _ = await _packages_from_requirements_file(sub_req)
# Use current context's index_url for nested requirements
context.requirements.extend(sub_reqs)
return

# Check for index URL specification
index_match = re.match(INDEX_URL_PREFIX, req)
if index_match:
context.index_url = index_match[2].strip()
return

if req.startswith("-"):
warn(f"{req_path}:{line_no}: unrecognized requirement: {req}")
req = None
if not req:
return []
return [req]
return

context.add_requirement(req)
37 changes: 28 additions & 9 deletions packages/pyodide-kernel/py/piplite/piplite/piplite.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@
#: a well-known file name respected by the rest of the build chain
ALL_JSON = "/all.json"

#: default index URLs to use when no specific index URLs are provided
_PIPLITE_DEFAULT_INDEX_URLS = None


class PiplitePyPIDisabled(ValueError):
"""An error for when PyPI is disabled at the site level, but a download was
Expand Down Expand Up @@ -117,17 +120,33 @@ async def _install(
verbose: bool | int = False,
):
"""Invoke micropip.install with a patch to get data from local indexes"""
with patch("micropip.package_index.query_package", _query_package):
return await micropip.install(
requirements=requirements,
keep_going=keep_going,
deps=deps,
credentials=credentials,
pre=pre,
index_urls=index_urls,
verbose=verbose,

try:
# Use default index URLs if none provided and defaults exist
effective_index_urls = (
index_urls if index_urls is not None else _PIPLITE_DEFAULT_INDEX_URLS
)

if verbose:
logger.info(f"Installing with index URLs: {effective_index_urls}")

with patch("micropip.package_index.query_package", _query_package):
return await micropip.install(
requirements=requirements,
keep_going=keep_going,
deps=deps,
credentials=credentials,
pre=pre,
index_urls=effective_index_urls,
verbose=verbose,
)
except Exception as e:
if effective_index_urls:
logger.error(
f"Failed to install using index URLs {effective_index_urls}: {e}"
)
raise


def install(
requirements: str | list[str],
Expand Down
5 changes: 5 additions & 0 deletions packages/pyodide-kernel/src/kernel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,11 @@ export namespace PyodideKernel {
*/
mountDrive: boolean;

/**
* Default options to pass to piplite.install
*/
pipliteInstallDefaultOptions?: IPyodideWorkerKernel.IPipliteInstallOptions;

/**
* additional options to provide to `loadPyodide`
* @see https://pyodide.org/en/stable/usage/api/js-api.html#globalThis.loadPyodide
Expand Down
22 changes: 21 additions & 1 deletion packages/pyodide-kernel/src/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,24 @@ export interface IPyodideWorkerKernel extends IWorkerKernel {
export type IRemotePyodideWorkerKernel = IPyodideWorkerKernel;

/**
* An namespace for Pyodide workers.
* A namespace for Pyodide workers.
*/
export namespace IPyodideWorkerKernel {
/**
* Options for piplite installation.
*/
export interface IPipliteInstallOptions {
/**
* Base URLs of extra indices to use
*/
indexUrls?: string[];

/**
* Any additional piplite install options
*/
[key: string]: any;
}

/**
* Initialization options for a worker.
*/
Expand Down Expand Up @@ -90,6 +105,11 @@ export namespace IPyodideWorkerKernel {
*/
mountDrive: boolean;

/**
* Default options to pass to piplite.install
*/
pipliteInstallDefaultOptions?: IPyodideWorkerKernel.IPipliteInstallOptions;

/**
* additional options to provide to `loadPyodide`
* @see https://pyodide.org/en/stable/usage/api/js-api.html#globalThis.loadPyodide
Expand Down
27 changes: 20 additions & 7 deletions packages/pyodide-kernel/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,13 @@ export class PyodideRemoteKernel {
throw new Error('Uninitialized');
}

const { pipliteWheelUrl, disablePyPIFallback, pipliteUrls, loadPyodideOptions } =
this._options;
const {
pipliteWheelUrl,
disablePyPIFallback,
pipliteUrls,
loadPyodideOptions,
pipliteInstallDefaultOptions,
} = this._options;

const preloaded = (loadPyodideOptions || {}).packages || [];

Expand All @@ -81,12 +86,20 @@ export class PyodideRemoteKernel {
`);
}

const pythonConfig = [
'import piplite.piplite',
`piplite.piplite._PIPLITE_DISABLE_PYPI = ${disablePyPIFallback ? 'True' : 'False'}`,
`piplite.piplite._PIPLITE_URLS = ${JSON.stringify(pipliteUrls)}`,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this would then become piplite.piplite._PIPLITE_DEFAULT_INSTALL_ARGS

];

if (pipliteInstallDefaultOptions?.indexUrls) {
pythonConfig.push(
`piplite.piplite._PIPLITE_DEFAULT_INDEX_URLS = ${JSON.stringify(pipliteInstallDefaultOptions.indexUrls)}`,
);
}

// get piplite early enough to impact pyodide-kernel dependencies
await this._pyodide.runPythonAsync(`
import piplite.piplite
piplite.piplite._PIPLITE_DISABLE_PYPI = ${disablePyPIFallback ? 'True' : 'False'}
piplite.piplite._PIPLITE_URLS = ${JSON.stringify(pipliteUrls)}
`);
await this._pyodide.runPythonAsync(pythonConfig.join('\n'));
}

protected async initKernel(options: IPyodideWorkerKernel.IOptions): Promise<void> {
Expand Down
Loading