diff --git a/.gitignore b/.gitignore index 7e956c3..89e4ce7 100644 --- a/.gitignore +++ b/.gitignore @@ -133,3 +133,4 @@ ckpt/ # ast template ast_index_file.py +modelscope diff --git a/README.md b/README.md index 113bb60..2fb8a7a 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,31 @@ path = api.download_file("Qwen/Qwen3-0.6B", "model", "config.json", local_files_ Downloads validate available size and SHA-256 metadata before replacing files, with safe resume, bounded retries, timeouts, and cancellation. Incomplete snapshots raise `FileDownloadError` (CLI exit code `1`), preserving completed files for subsequent retries. +### Load kernels (`modelscope_hub.kernels`) + +`modelscope_hub.kernels` is a drop-in wrapper around the [`kernels`](https://github.com/huggingface/kernels) library that downloads optimized compute kernels from ModelScope instead of the Hugging Face Hub. A kernel repository is an ordinary ModelScope model repository mirroring the HF kernel layout (`build//...` plus `v1`-style version branches or tags): + +```bash +pip install "modelscope-hub[kernels]" +``` + +```python +from modelscope_hub.kernels import get_kernel + +# Download optimized kernels from the ModelScope hub +activation = get_kernel("kernels-community/activation", version=1) +``` + +Also available: `has_kernel()` (environment compatibility check), `get_kernel_variants()` (build-variant decisions), `get_local_kernel()` (load from a local path, dependencies from ModelScope), `install_kernel()` (download to the local cache without importing — CI pre-installation / offline preparation), `install_kernel_all_variants()` (download every build variant plus the dependency tree), `get_locked_kernel()` (load a kernel pinned by a lockfile, downloading missing pieces), and `load_kernel()` (import an already-installed locked kernel; never downloads). Under `patch_kernels_hub()` every other networked `kernels` entry point (the `layer`/`kernelize` APIs, benchmarks, etc.) is redirected the same way, because the whole library routes through one Hub API factory. + +Notes: + +- The kernel loading, variant resolution, and dependency logic of `kernels` are reused unchanged — only its Hub API factory is swapped for a ModelScope-backed shim while a `modelscope_hub.kernels` call is in flight, so a plain `import kernels` elsewhere keeps using the Hugging Face Hub. `patch_kernels_hub()` / `unpatch_kernels_hub()` / `patch_scope()` switch this explicitly if you need it globally; the `modelscope` package's `patch_hub()` builds on the same implementation. +- `version=N` resolves against the repository's `v` branches/tags. A mirror that only carries `master` exposes it as kernel version `v1` automatically. +- Kernel lockfiles record hub-specific commit hashes: a lockfile created against the Hugging Face Hub cannot resolve on ModelScope (the two hubs have different git histories). Create the lockfile in the ModelScope environment (`get_locked_kernel` / `install_kernel_all_variants`, or the `kernels` CLI under `patch_kernels_hub()`) so it records ModelScope commits; a commit-hash revision that cannot be found raises with a note explaining this. +- ModelScope has no trusted-publisher flag, so untrusted publishers are rejected by default: only `kernels-community` is trusted out of the box. Extend the allowlist with `MS_KERNELS_TRUSTED_PUBLISHERS` (comma-separated), or allow specific repositories / all repositories through the trust parameters of `get_kernel` (its docstring mirrors the kernel library's API). +- The mirror must contain kernels built for your exact Torch version and platform (`kernels` selects `build/torchXY-...--/`); a repository whose builds predate `kernels` 0.17 metadata cannot be loaded until it is rebuilt with a current `kernel-builder`. + ### Continuous dataset downloads For migration jobs, `download_repo_report` continues past isolated file failures and returns an explicit `COMPLETE` or `PARTIAL` report. The original `download_repo` and `snapshot_download` remain strict by default. @@ -1107,6 +1132,12 @@ Capacity thresholds are advisory and emit warnings without blocking upload. Stru | `MODELSCOPE_LOG_LEVEL` | `INFO` | SDK log level (`DEBUG`/`INFO`/`WARNING`/`ERROR`) | | `MODELSCOPE_NO_DEPRECATION_WARNINGS` | — | Suppress deprecation warnings | +**Kernels:** + +| Variable | Default | Description | +|----------|---------|-------------| +| `MS_KERNELS_TRUSTED_PUBLISHERS` | — | Comma-separated kernel publishers trusted in addition to the built-in allowlist (see [Load kernels](#load-kernels-modelscope_hubkernels)) | + > Old variable names (e.g. `API_TIMEOUT`, `DOWNLOAD_RETRY_TIMES`, `UPLOAD_USE_CACHE`) are > deprecated and remain temporarily supported. They emit a `FutureWarning` and will be removed > in a future version. Run `ms-hub list --envs` to see which deprecated names are active in your diff --git a/pyproject.toml b/pyproject.toml index afb9ff3..878aec6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,13 @@ dev = [ "ruff", "mypy", ] +# Runtime extra for the `modelscope_hub.kernels` package: the upstream +# `kernels` library (with huggingface_hub as a transitive dependency) provides +# the kernel loading machinery that `modelscope_hub.kernels` redirects onto +# ModelScope. +kernels = [ + "kernels>=0.17", +] [project.scripts] # This package owns every ModelScope console script. Keeping all four in one @@ -94,5 +101,5 @@ strict = false warn_return_any = false [[tool.mypy.overrides]] -module = ["tqdm.*", "modelscope.*"] +module = ["tqdm.*", "modelscope.*", "kernels.*", "huggingface_hub.*"] ignore_missing_imports = true diff --git a/src/modelscope_hub/constants.py b/src/modelscope_hub/constants.py index 7599c68..2b4be65 100644 --- a/src/modelscope_hub/constants.py +++ b/src/modelscope_hub/constants.py @@ -52,6 +52,7 @@ class EnvVar: "Download", "Upload", "Logging", + "Kernels", "Deprecated", ) @@ -1526,6 +1527,23 @@ def get_upload_ignore_file_pattern() -> str | None: "Core", ) +# --------------------------------------------------------------------------- +# Kernels (``modelscope_hub.kernels``) +# +# Publisher trust for HF-style kernel repositories loaded through +# :mod:`modelscope_hub.kernels`. ModelScope has no trusted-publisher flag, +# so the default trust decision falls back to an allowlist extended by this +# environment variable. +# --------------------------------------------------------------------------- +ENV_KERNELS_TRUSTED_PUBLISHERS: str = "MS_KERNELS_TRUSTED_PUBLISHERS" + +_env_register( + ENV_KERNELS_TRUSTED_PUBLISHERS, + "", + "Comma-separated kernel publishers trusted in addition to the built-in allowlist", + "Kernels", +) + __all__ = [ "AGENT_PLUGIN_TRUSTED_OWNERS", "API_CONNECT_TIMEOUT", @@ -1570,6 +1588,7 @@ def get_upload_ignore_file_pattern() -> str | None: "ENV_INTRA_CLOUD_ACCELERATION", "ENV_INTRA_CLOUD_REGION", "ENV_INTER_CLOUD_REGIONS", + "ENV_KERNELS_TRUSTED_PUBLISHERS", "ENV_MODELSCOPE_DOMAIN", "ENV_PREFER_AI_SITE", "ENV_REGISTRY", diff --git a/src/modelscope_hub/kernels/__init__.py b/src/modelscope_hub/kernels/__init__.py new file mode 100644 index 0000000..68cf8d3 --- /dev/null +++ b/src/modelscope_hub/kernels/__init__.py @@ -0,0 +1,56 @@ +"""ModelScope-flavoured ``kernels``: load HF-style compute kernels from ModelScope. + +``modelscope_hub.kernels`` is a drop-in wrapper around the `kernels +`_ package that redirects every Hub +interaction (revision lookup, metadata download, build snapshot) from the +Hugging Face Hub to the ModelScope Hub. Kernel repositories on ModelScope are +ordinary model repositories (e.g. ``kernels-community/activation``) that +mirror the HF kernel repo layout: ``build//...`` files plus ``v1``-style +version branches or tags. + +The kernel loading, variant resolution, and dependency logic of ``kernels`` +are reused unchanged; only its Hub API factory is swapped for a ModelScope +backed shim while a ``modelscope_hub.kernels`` call is in flight. This is +also the implementation behind the ``kernels`` monkey-patching applied by the +``modelscope`` package's ``patch_hub()``. + +Requires the optional ``kernels`` dependency:: + + pip install "modelscope-hub[kernels]" + +Example: + ```python + from modelscope_hub.kernels import get_kernel + + # Download optimized kernels from the ModelScope hub + activation = get_kernel("kernels-community/activation", version=1) + ``` +""" + +from __future__ import annotations + +from modelscope_hub.kernels._load import ( + get_kernel, + get_kernel_variants, + get_local_kernel, + get_locked_kernel, + has_kernel, + install_kernel, + install_kernel_all_variants, + load_kernel, +) +from modelscope_hub.kernels._patch import patch_kernels_hub, patch_scope, unpatch_kernels_hub + +__all__ = [ + "get_kernel", + "get_kernel_variants", + "get_local_kernel", + "has_kernel", + "install_kernel", + "install_kernel_all_variants", + "get_locked_kernel", + "load_kernel", + "patch_kernels_hub", + "patch_scope", + "unpatch_kernels_hub", +] diff --git a/src/modelscope_hub/kernels/_hub.py b/src/modelscope_hub/kernels/_hub.py new file mode 100644 index 0000000..beededb --- /dev/null +++ b/src/modelscope_hub/kernels/_hub.py @@ -0,0 +1,333 @@ +"""ModelScope-backed stand-in for the small ``HfApi`` surface used by ``kernels``. + +The upstream ``kernels`` library routes every Hub interaction through +``kernels.hf_hub._get_hf_api()``. The patched factory (see +:mod:`modelscope_hub.kernels._patch`) returns :class:`MsKernelApi`: a +duck-typed API client that forwards the handful of methods ``kernels`` +actually calls onto :class:`modelscope_hub.HubApi`, mapping HF kernel +repositories onto ModelScope model repositories. + +Method-to-method mapping: + +- ``snapshot_download`` -> :meth:`modelscope_hub.HubApi.download_repo` +- ``hf_hub_download`` -> :meth:`modelscope_hub.HubApi.download_file` +- ``list_repo_refs`` -> :meth:`modelscope_hub.legacy.list_revisions_detail` +- ``list_repo_tree`` -> :meth:`modelscope_hub.HubApi.list_repo_files` +- ``get_organization_overview`` -> local trusted-publisher allowlist + +``huggingface_hub`` is imported lazily inside the methods that need its +dataclasses/exceptions: it arrives as a transitive dependency of ``kernels`` +and this module must stay importable without it. +""" + +from __future__ import annotations + +import hashlib +import os +import re +from pathlib import Path +from types import SimpleNamespace + +import modelscope_hub +from modelscope_hub.constants import ENV_KERNELS_TRUSTED_PUBLISHERS + +#: Publisher organizations trusted when ``trust_remote_code=False``. ModelScope +#: has no equivalent of the Hub's ``trustedKernelPublisher`` flag, so the +#: default trust decision falls back to this allowlist (extend it via the +#: ``MS_KERNELS_TRUSTED_PUBLISHERS`` environment variable, comma-separated). +TRUSTED_KERNEL_PUBLISHERS: frozenset[str] = frozenset({"kernels-community"}) + +# Kernel-version refs (``v1``, ``v2``, ...) as created by the kernels Hub. +_VERSION_REF_RE = re.compile(r"v(\d+)") + +# Full git commit hashes (SHA-1); lockfiles pin revisions this way. +_COMMIT_HASH_RE = re.compile(r"[0-9a-f]{40}", re.IGNORECASE) + +_LOCKFILE_COMMIT_HINT = ( + "Revision {revision!r} looks like a commit hash. Commit hashes are " + "hub-specific: a kernel lockfile created against the Hugging Face Hub " + "cannot resolve on ModelScope. Re-create the lockfile in the ModelScope " + "environment (get_locked_kernel / install_kernel_all_variants from " + "modelscope_hub.kernels, or the kernels CLI under patch_kernels_hub) so " + "it records ModelScope commits." +) + + +def _note_commit_hash_hint(exc: BaseException, revision: str | None) -> None: + """Append a lockfile hint when a full commit-hash revision is not found. + + PEP 678 notes require Python 3.11+; on older interpreters the hint is + skipped silently (the original exception propagates unchanged either way). + """ + if revision is None or not _COMMIT_HASH_RE.fullmatch(str(revision)): + return + add_note = getattr(exc, "add_note", None) + if add_note is not None: + add_note(_LOCKFILE_COMMIT_HINT.format(revision=revision)) + + +def _ms_revision(revision: str | None) -> str: + """Translate an HF revision string into one ModelScope accepts.""" + return "master" if revision in (None, "main") else revision + + +#: Synthetic OID -> ModelScope branch/tag name, filled by ``list_repo_refs``. +#: ModelScope's revision API does not expose git commit hashes, but kernels +#: 0.18 validates ``Oid.from_str(target_commit)`` (40/64 hex digits) and both +#: kernels 0.17 and 0.18 use ``target_commit`` as the download revision. The +#: shim therefore hands out a deterministic synthetic OID per (repo, ref) and +#: maps it back to the real ref name at every download/list entry point. +_synthetic_oids: dict[str, str] = {} + + +def _synthetic_oid(repo_id: str, ref_name: str) -> str: + """A stable 40-hex stand-in for the commit hash ModelScope does not expose.""" + oid = hashlib.sha1(f"{repo_id}:{ref_name}".encode()).hexdigest() + _synthetic_oids.setdefault(oid, ref_name) + return oid + + +def _resolve_revision(revision: str | None) -> str | None: + """Map a synthetic OID back to its ModelScope branch/tag name.""" + if revision is None: + return None + return _synthetic_oids.get(str(revision), revision) + + +def _ms_repo_type(repo_type: str | None) -> str: + """Map HF repo types onto ModelScope repo types (kernel repos are models).""" + return "model" if repo_type in (None, "kernel", "model") else repo_type + + +def _trusted_publishers() -> frozenset[str]: + publishers = set(TRUSTED_KERNEL_PUBLISHERS) + extra = os.environ.get(ENV_KERNELS_TRUSTED_PUBLISHERS, "") + publishers.update(part.strip() for part in extra.split(",") if part.strip()) + return frozenset(publishers) + + +def _user_agent(extra: str | dict | None) -> str | dict: + """Telemetry info appended to the SDK user agent on every download.""" + if extra: + return extra + return f"ms-kernels; modelscope_hub/{modelscope_hub.__version__}" + + +class MsKernelApi: + """Minimal ``HfApi`` look-alike that forwards to ModelScope. + + ``hub_api`` exists for dependency injection (tests); production callers get + a lazily constructed :class:`modelscope_hub.HubApi`. Only the methods that + ``kernels`` calls are implemented. + """ + + def __init__( + self, + hub_api: modelscope_hub.HubApi | None = None, + user_agent: str | dict | None = None, + ) -> None: + self._hub_api_instance = hub_api + self._user_agent = user_agent + + def _api(self) -> modelscope_hub.HubApi: + if self._hub_api_instance is None: + self._hub_api_instance = modelscope_hub.HubApi() + return self._hub_api_instance + + # ------------------------------------------------------------------ + # Downloads + # ------------------------------------------------------------------ + def snapshot_download( + self, + repo_id: str, + *, + repo_type: str | None = None, + allow_patterns: list[str] | None = None, + ignore_patterns: list[str] | None = None, + cache_dir: str | Path | None = None, + revision: str | None = None, + local_files_only: bool = False, + **kwargs, # HF-signature compatibility + ) -> Path: + """Download a repository snapshot, mirroring ``HfApi.snapshot_download``.""" + from huggingface_hub.errors import LocalEntryNotFoundError + + ms_revision = _ms_revision(_resolve_revision(revision)) + try: + return self._api().download_repo( + repo_id, + _ms_repo_type(repo_type), + revision=ms_revision, + cache_dir=Path(cache_dir) if cache_dir else None, + allow_patterns=allow_patterns, + ignore_patterns=ignore_patterns, + local_files_only=local_files_only, + user_agent=_user_agent(self._user_agent), + ) + except modelscope_hub.CacheNotFound as e: + raise LocalEntryNotFoundError( + f"Cannot find a local snapshot for {repo_id} (revision: {ms_revision}). " + "The kernel must be downloaded while online first." + ) from e + except modelscope_hub.NotExistError as e: + _note_commit_hash_hint(e, revision) + raise + + def hf_hub_download( + self, + repo_id: str | None = None, + *, + repo_type: str | None = None, + filename: str | None = None, + cache_dir: str | Path | None = None, + revision: str | None = None, + local_files_only: bool = False, + **kwargs, # HF-signature compatibility + ) -> Path: + """Download a single repository file, mirroring ``HfApi.hf_hub_download``.""" + from huggingface_hub.errors import EntryNotFoundError, LocalEntryNotFoundError + + if repo_id is None or filename is None: + raise ValueError("Both `repo_id` and `filename` must be provided.") + ms_revision = _ms_revision(_resolve_revision(revision)) + try: + return self._api().download_file( + repo_id, + _ms_repo_type(repo_type), + file_path=filename, + revision=ms_revision, + cache_dir=Path(cache_dir) if cache_dir else None, + local_files_only=local_files_only, + user_agent=_user_agent(self._user_agent), + ) + except modelscope_hub.CacheNotFound as e: + raise LocalEntryNotFoundError( + f"Cannot find the local cache entry for {repo_id}/{filename} " + f"(revision: {ms_revision}). The file must be downloaded while online first." + ) from e + except modelscope_hub.NotExistError as e: + _note_commit_hash_hint(e, revision) + raise EntryNotFoundError(f"File {filename} does not exist in {repo_id} (revision: {ms_revision}).") from e + + # ------------------------------------------------------------------ + # Repository metadata + # ------------------------------------------------------------------ + def list_repo_refs(self, repo_id: str | None = None, *, repo_type: str | None = None, **kwargs): + """List repository branches and tags, mirroring ``HfApi.list_repo_refs``. + + ModelScope's revision API does not expose git commit hashes, so every + ref's ``target_commit`` is a deterministic synthetic OID for the ref + name (see :func:`_synthetic_oid`); the download entry points map it + back to the real name, so both kernels 0.17 (which downloads by + ``target_commit`` verbatim) and kernels 0.18 (which validates it with + ``Oid.from_str`` before fetching) work unchanged. When the repository + carries no ``v`` refs at all (typical for a plain mirror with only + a ``master`` branch), ``master`` is exposed as kernel version ``v1`` + so that ``get_kernel(..., version=1)`` works without the mirror having + to create version branches. + """ + from huggingface_hub.hf_api import GitRefInfo + + if repo_id is None: + raise ValueError("`repo_id` must be provided.") + branches, tags = self._api().legacy.list_revisions_detail(repo_id, _ms_repo_type(repo_type)) + branch_refs = [ + GitRefInfo( + name=ref["Revision"], + ref=f"refs/heads/{ref['Revision']}", + target_commit=_synthetic_oid(repo_id, ref["Revision"]), + ) + for ref in branches or [] + ] + tag_refs = [ + GitRefInfo( + name=ref["Revision"], + ref=f"refs/tags/{ref['Revision']}", + target_commit=_synthetic_oid(repo_id, ref["Revision"]), + ) + for ref in tags or [] + ] + if not any(_VERSION_REF_RE.fullmatch(ref.name) for ref in branch_refs + tag_refs): + branch_refs.append( + GitRefInfo(name="v1", ref="refs/heads/v1", target_commit=_synthetic_oid(repo_id, "master")) + ) + return SimpleNamespace(branches=branch_refs, tags=tag_refs, converts=[]) + + def list_repo_tree( + self, + repo_id: str | None = None, + *, + path_in_repo: str | None = None, + repo_type: str | None = None, + revision: str | None = None, + recursive: bool = False, # ModelScope always lists recursively + **kwargs, # HF-signature compatibility + ): + """List the direct children of a repository folder, mirroring ``HfApi.list_repo_tree``. + + ModelScope cannot list the direct children of a subfolder + non-recursively, so the full recursive tree is fetched and the direct + subfolders derived from it — the only thing ``kernels`` consumes. + """ + from huggingface_hub.hf_api import RepoFolder + + if repo_id is None: + raise ValueError("`repo_id` must be provided.") + root = path_in_repo or "" + prefix = f"{root}/" if root else "" + infos = self._api().list_repo_files( + repo_id, + _ms_repo_type(repo_type), + revision=_ms_revision(_resolve_revision(revision)), + recursive=True, + ) + folders: set[str] = set() + for info in infos: + path = info.path + if prefix and not path.startswith(prefix): + continue + rest = path[len(prefix) :] + if not rest: + continue + first, _, remainder = rest.partition("/") + if remainder or (info.is_dir and not remainder): + # Either the parent of a nested entry, or an explicit + # tree entry at direct-child depth. + folders.add(first) + return [ + RepoFolder(path=f"{root}/{name}" if root else name, oid="", last_commit=None) for name in sorted(folders) + ] + + def file_exists( + self, + repo_id: str | None = None, + filename: str | None = None, + *, + repo_type: str | None = None, + revision: str | None = None, + **kwargs, # HF-signature compatibility + ) -> bool: + """Check whether ``filename`` exists in the repository.""" + if repo_id is None or filename is None: + raise ValueError("Both `repo_id` and `filename` must be provided.") + infos = self._api().list_repo_files( + repo_id, + _ms_repo_type(repo_type), + revision=_ms_revision(_resolve_revision(revision)), + recursive=True, + ) + return any(info.path == filename for info in infos) + + # ------------------------------------------------------------------ + # Publisher trust + # ------------------------------------------------------------------ + def get_organization_overview(self, organization: str): + """Stand-in for the Hub's organization lookup. + + There is no ``trustedKernelPublisher`` equivalent on ModelScope, so + trust is decided against :data:`TRUSTED_KERNEL_PUBLISHERS` (plus the + ``MS_KERNELS_TRUSTED_PUBLISHERS`` environment variable). Untrusted + publishers cause kernels' usual ``ValueError`` with instructions to + pass ``trust_remote_code=True``. + """ + return SimpleNamespace(trustedKernelPublisher=organization in _trusted_publishers()) diff --git a/src/modelscope_hub/kernels/_load.py b/src/modelscope_hub/kernels/_load.py new file mode 100644 index 0000000..530942f --- /dev/null +++ b/src/modelscope_hub/kernels/_load.py @@ -0,0 +1,332 @@ +"""Public entry points: scope-managed wrappers around the ``kernels`` library. + +Each wrapper applies :func:`modelscope_hub.kernels._patch.patch_scope` around +the corresponding ``kernels`` call, so Hub traffic is redirected to ModelScope +for that call only — ``import kernels`` elsewhere keeps using the Hugging +Face Hub. +""" + +from __future__ import annotations + +from pathlib import Path +from types import ModuleType + +from modelscope_hub.kernels._patch import patch_scope + +__all__ = [ + "get_kernel", + "get_kernel_variants", + "get_local_kernel", + "has_kernel", + "install_kernel", + "install_kernel_all_variants", + "get_locked_kernel", + "load_kernel", +] + + +def _require_kernels() -> ModuleType: + """Import ``kernels`` or raise an actionable ``ImportError``.""" + try: + import kernels + except ImportError as e: + raise ImportError( + "The `kernels` package is required by `modelscope_hub.kernels` but is not installed. " + 'Install it with: pip install "modelscope-hub[kernels]".' + ) from e + return kernels + + +def get_kernel( + repo_id: str, + *, + revision: str | None = None, + version: int | None = None, + backend: str | None = None, + user_agent: str | dict | None = None, + trust_remote_code: bool | list[str] = False, + check_arch: bool = True, +) -> ModuleType: + """Load a kernel from the ModelScope Hub. + + This function downloads a kernel to the local ModelScope cache directory + (if it was not downloaded before) and then loads the kernel. The kernel + repository must be a ModelScope model repository mirroring the HF kernel + repo layout (``build//...`` files plus ``v1``-style version + branches or tags). + + Args: + repo_id (`str`): + The ModelScope model repository containing the kernel, + e.g. ``"kernels-community/activation"``. + revision (`str`, *optional*): + The specific revision (branch, tag, or commit) to download. + Cannot be used together with `version`. + version (`int`, *optional*): + The kernel version to download (resolved against the repository's + ``v`` branches/tags). Cannot be used together with `revision`. + Either `version` or `revision` must be specified. + backend (`str`, *optional*): + The backend to load the kernel for. Can only be `cpu` or the + backend that Torch is compiled for. The backend is detected + automatically if not provided. + user_agent (`Union[str, dict]`, *optional*): + Extra telemetry info appended to the ModelScope SDK user agent. + trust_remote_code (`bool | list[str]`, *optional*, defaults to `False`): + Whether to allow loading kernels from untrusted organisations. + ModelScope has no trusted-publisher flag, so trust is decided + against the built-in allowlist (see + :data:`modelscope_hub.kernels._hub.TRUSTED_KERNEL_PUBLISHERS`); a + list of repository IDs allows those repositories in addition. + check_arch (`bool`, *optional*, defaults to `True`): + Whether to check that the kernel build supports the architecture + of the current device. `check_arch=False` skips the check. + + Returns: + `ModuleType`: The imported kernel module. + + Example: + ```python + import torch + from modelscope_hub.kernels import get_kernel + + activation = get_kernel("kernels-community/activation", version=1) + x = torch.randn(10, 20, device="cpu") + out = torch.empty_like(x) + result = activation.relu(out, x) + ``` + """ + kernels = _require_kernels() + with patch_scope(): + return kernels.get_kernel( + repo_id, + revision=revision, + version=version, + backend=backend, + user_agent=user_agent, + trust_remote_code=trust_remote_code, + check_arch=check_arch, + ) + + +def has_kernel( + repo_id: str, + *, + revision: str | None = None, + version: int | None = None, + backend: str | None = None, + trust_remote_code: bool | list[str] = False, + check_arch: bool = True, +) -> bool: + """Check whether a kernel build exists for the current environment. + + Arguments are the same as :func:`get_kernel` (minus ``user_agent``). + + Returns: + `bool`: `True` if a kernel is available for the current environment. + """ + kernels = _require_kernels() + with patch_scope(): + return kernels.has_kernel( + repo_id, + revision=revision, + version=version, + backend=backend, + trust_remote_code=trust_remote_code, + check_arch=check_arch, + ) + + +def get_kernel_variants( + repo_id: str, + *, + revision: str | None = None, + version: int | None = None, + backend: str | None = None, +) -> list: + """Resolve all build variants of a kernel against the current environment. + + Arguments are the same as :func:`get_kernel` (minus ``user_agent``). + + Returns: + `list`: One ``VariantAccepted`` or ``VariantRejected`` per build + variant in the repository, compatible variants first. + """ + kernels = _require_kernels() + with patch_scope(): + return kernels.get_kernel_variants( + repo_id, + revision=revision, + version=version, + backend=backend, + ) + + +def get_local_kernel( + repo_path: Path, + *, + backend: str | None = None, + trust_remote_code: bool | list[str] = False, +) -> ModuleType: + """Import a kernel from a local kernel repository path. + + If the kernel has any (transitive) dependencies, they are downloaded from + the ModelScope Hub. + + Args: + repo_path (`Path`): + The local path to the kernel repository. + backend (`str`, *optional*): + The backend to load the kernel for. + trust_remote_code (`bool | list[str]`, *optional*, defaults to `False`): + Whether to allow loading kernel dependencies from untrusted + organisations. + + Returns: + `ModuleType`: The imported kernel module. + """ + kernels = _require_kernels() + with patch_scope(): + return kernels.get_local_kernel( + repo_path, + backend=backend, + trust_remote_code=trust_remote_code, + ) + + +def install_kernel( + repo_id: str, + *, + revision: str | None = None, + version: int | None = None, + backend: str | None = None, + local_files_only: bool = False, + user_agent: str | dict | None = None, + trust_remote_code: bool | list[str] = False, +) -> Path: + """Download a kernel for the current environment into the local cache. + + Unlike :func:`get_kernel` the kernel is not imported; this is the entry + point for CI pre-installation and offline preparation. Arguments are the + same as :func:`get_kernel` (plus ``local_files_only``). + + Returns: + `Path`: The path to the downloaded variant directory. + """ + kernels = _require_kernels() + with patch_scope(): + return kernels.install_kernel( + repo_id, + revision=revision, + version=version, + backend=backend, + local_files_only=local_files_only, + user_agent=user_agent, + trust_remote_code=trust_remote_code, + ) + + +def install_kernel_all_variants( + repo_id: str, + *, + revision: str | None = None, + version: int | None = None, +) -> Path: + """Download every build variant of a kernel (and its dependencies). + + Uses the locking code path to resolve the full dependency tree, so the + ``revision``/``version`` recorded in the resulting lock state refers to + ModelScope commits. A lockfile produced against the Hugging Face Hub + cannot resolve here: the two hubs have different git histories, and + commit hashes recorded on one are meaningless on the other. + + Returns: + `Path`: The path to the ``build`` directory of the requested kernel. + """ + _require_kernels() + # Not exported on the `kernels` top level (0.17/0.18); live in the + # `kernels.install` submodule, which `kernels` itself imports at load time. + from kernels.install import install_kernel_all_variants as _install_all + + with patch_scope(): + return _install_all( + repo_id, + revision=revision, + version=version, + ) + + +def get_locked_kernel( + repo_id: str, + *, + lockfile: Path | None = None, + trust_remote_code: bool | list[str] = False, + user_agent: str | dict | None = None, +) -> ModuleType: + """Load a kernel pinned by a lockfile, downloading missing pieces. + + Important: kernel lockfiles record hub-specific commit hashes. A lockfile + created against the Hugging Face Hub (e.g. via the ``kernels`` CLI there) + cannot resolve on ModelScope — the recorded commits do not exist on the + ModelScope mirror. Create the lockfile in the ModelScope environment + (``get_locked_kernel`` / ``install_kernel_all_variants`` from this + package, or the ``kernels`` CLI under ``patch_kernels_hub()``) so it + records ModelScope commits. + + Args: + repo_id (`str`): + The ModelScope model repository containing the kernel. + lockfile (`Path`, *optional*): + Path to the lockfile. When not provided, the lockfile is loaded + from the caller's package metadata. + trust_remote_code (`bool | list[str]`, *optional*, defaults to `False`): + Whether to allow loading kernels from untrusted organisations. + user_agent (`Union[str, dict]`, *optional*): + Extra telemetry info appended to the ModelScope SDK user agent. + + Returns: + `ModuleType`: The imported kernel module. + """ + kernels = _require_kernels() + with patch_scope(): + return kernels.get_locked_kernel( + repo_id, + lockfile=lockfile, + trust_remote_code=trust_remote_code, + user_agent=user_agent, + ) + + +def load_kernel( + repo_id: str, + *, + lockfile: Path | None = None, + backend: str | None = None, +) -> ModuleType: + """Import a pre-downloaded, locked kernel from the local cache. + + Never downloads: the kernel must already have been installed locally (via + :func:`install_kernel`, :func:`install_kernel_all_variants`, or + :func:`get_kernel`). Raises when the kernel is not available locally. + The lockfile must have been created in the ModelScope environment (see + :func:`get_locked_kernel`). + + Args: + repo_id (`str`): + The kernel repository the lockfile pins. + lockfile (`Path`, *optional*): + Path to the lockfile. When not provided, the lockfile is loaded + from the caller's package metadata. + backend (`str`, *optional*): + The backend to load the kernel for. + + Returns: + `ModuleType`: The imported kernel module. + """ + kernels = _require_kernels() + with patch_scope(): + return kernels.load_kernel( + repo_id, + lockfile=lockfile, + backend=backend, + ) diff --git a/src/modelscope_hub/kernels/_patch.py b/src/modelscope_hub/kernels/_patch.py new file mode 100644 index 0000000..30fb0a8 --- /dev/null +++ b/src/modelscope_hub/kernels/_patch.py @@ -0,0 +1,115 @@ +"""Monkey-patching of the ``kernels`` library onto the ModelScope Hub. + +Only ``kernels.hf_hub._get_hf_api`` is replaced; every download, file +listing, and revision lookup that ``kernels`` performs goes through +:class:`modelscope_hub.kernels._hub.MsKernelApi`, so the kernel loading, +variant resolution, and dependency logic stay untouched. + +``patch_scope()`` scopes the patch to a single call (used by the public +wrappers in :mod:`modelscope_hub.kernels._load`), so ``from kernels import +get_kernel`` keeps using the Hugging Face Hub unless the user explicitly +applies ``patch_kernels_hub()``. + +Adapted from ``modelscope/utils/hf_util/patcher.py`` (Apache-2.0, Alibaba), +re-targeted from the legacy ``modelscope`` SDK to ``modelscope_hub``. +""" + +from __future__ import annotations + +import contextlib +import sys + +from modelscope_hub.kernels._hub import MsKernelApi + +__all__ = ["patch_kernels_hub", "patch_scope", "unpatch_kernels_hub"] + + +def _import_kernels_api_module(): + """Return the module holding ``_get_hf_api`` in the installed ``kernels``. + + kernels >= 0.17 renamed ``kernels.utils`` to ``kernels.hf_hub`` (with + ``_get_hf_api`` moved along); support both layouts. Returns ``None`` + when ``kernels`` is not installed. + """ + try: + import kernels.hf_hub as kernels_api + except ImportError: + try: + import kernels.utils as kernels_api + except ImportError: + return None + return kernels_api + + +def _ms_api(user_agent=None): + """Replacement for ``kernels.hf_hub._get_hf_api``.""" + return MsKernelApi(user_agent=user_agent) + + +def patch_kernels_hub() -> None: + """Monkey-patch the ``kernels`` library to route Hub API calls to ModelScope. + + Only ``_get_hf_api`` is replaced (both in its home module and in the + submodules that bind it at import time, e.g. ``kernels.load``). Returns + silently when ``kernels`` is not installed or already patched. + """ + kernels_api = _import_kernels_api_module() + if kernels_api is None or hasattr(kernels_api, "_get_hf_api_origin"): + return + + kernels_api._get_hf_api_origin = kernels_api._get_hf_api + kernels_api._get_hf_api = _ms_api + # kernels >= 0.17 binds `_get_hf_api` at import time in submodules + # (e.g. `from kernels.hf_hub import _get_hf_api` in `kernels.load`); + # patch those already-bound references as well. + for name, module in list(sys.modules.items()): + if name != "kernels" and not name.startswith("kernels."): + continue + if module is kernels_api or hasattr(module, "_get_hf_api_origin"): + continue + if getattr(module, "_get_hf_api", None) is not None: + setattr(module, "_get_hf_api_origin", getattr(module, "_get_hf_api")) + setattr(module, "_get_hf_api", _ms_api) + + +def unpatch_kernels_hub() -> None: + """Undo :func:`patch_kernels_hub` and restore the original HfApi factory.""" + kernels_api = _import_kernels_api_module() + if kernels_api is None: + return + origin = getattr(kernels_api, "_get_hf_api_origin", None) + if origin is None: + return + patched = kernels_api._get_hf_api + for name, module in list(sys.modules.items()): + if name != "kernels" and not name.startswith("kernels."): + continue + if module is kernels_api: + continue + module_origin = getattr(module, "_get_hf_api_origin", None) + if module_origin is not None: + setattr(module, "_get_hf_api", module_origin) + delattr(module, "_get_hf_api_origin") + elif getattr(module, "_get_hf_api", None) is patched: + # Imported while the patch was active (kernels >= 0.17). + setattr(module, "_get_hf_api", origin) + kernels_api._get_hf_api = origin + del kernels_api._get_hf_api_origin + + +@contextlib.contextmanager +def patch_scope(): + """Apply :func:`patch_kernels_hub` for the duration of the ``with`` block. + + Nesting is safe: when an outer patch is already in effect the block simply + reuses it, so only the outermost scope restores the original factory. + """ + kernels_api = _import_kernels_api_module() + if kernels_api is None or hasattr(kernels_api, "_get_hf_api_origin"): + yield + return + patch_kernels_hub() + try: + yield + finally: + unpatch_kernels_hub() diff --git a/src/modelscope_hub/version.py b/src/modelscope_hub/version.py index 02d16d8..4f8f459 100644 --- a/src/modelscope_hub/version.py +++ b/src/modelscope_hub/version.py @@ -1,3 +1,3 @@ """Version information for modelscope_hub.""" -__version__ = "0.6.1+main" +__version__ = "0.7.0.dev0" diff --git a/tests/test_kernels.py b/tests/test_kernels.py new file mode 100644 index 0000000..7aca264 --- /dev/null +++ b/tests/test_kernels.py @@ -0,0 +1,594 @@ +"""Network-free tests for the ``modelscope_hub.kernels`` ModelScope-backed shim. + +``MsKernelApi`` is exercised with an injected fake ``HubApi``; the patch +round-trip tests run only when the optional ``kernels`` package (and its +``huggingface_hub`` dependency) is installed. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest import mock + +import pytest + +from modelscope_hub import FileInfo +from modelscope_hub.errors import CacheNotFound, NotExistError +from modelscope_hub.kernels import get_kernel as kernels_get_kernel +from modelscope_hub.kernels._hub import MsKernelApi, _ms_repo_type, _ms_revision +from modelscope_hub.kernels._patch import _ms_api, patch_kernels_hub, patch_scope, unpatch_kernels_hub + + +# --------------------------------------------------------------------------- +# Revision / repo-type mapping +# --------------------------------------------------------------------------- +@pytest.mark.parametrize( + ("revision", "expected"), + [(None, "master"), ("main", "master"), ("master", "master"), ("v1", "v1"), ("deadbeef", "deadbeef")], +) +def test_ms_revision_maps_hf_defaults(revision, expected): + assert _ms_revision(revision) == expected + + +@pytest.mark.parametrize( + ("repo_type", "expected"), + [(None, "model"), ("kernel", "model"), ("model", "model"), ("dataset", "dataset")], +) +def test_ms_repo_type_maps_kernel_to_model(repo_type, expected): + assert _ms_repo_type(repo_type) == expected + + +# --------------------------------------------------------------------------- +# Downloads +# --------------------------------------------------------------------------- +def test_snapshot_download_forwards_to_download_repo(): + fake = mock.MagicMock() + fake.download_repo.return_value = Path("/cache/snapshots/abc") + api = MsKernelApi(hub_api=fake) + + result = api.snapshot_download( + "kernels-community/activation", + repo_type="kernel", + allow_patterns=["build/torch-cpu/*"], + ignore_patterns=["*.pyc"], + cache_dir="/tmp/kernels-cache", + revision="v1", + local_files_only=False, + ) + + assert result == Path("/cache/snapshots/abc") + fake.download_repo.assert_called_once_with( + "kernels-community/activation", + "model", + revision="v1", + cache_dir=Path("/tmp/kernels-cache"), + allow_patterns=["build/torch-cpu/*"], + ignore_patterns=["*.pyc"], + local_files_only=False, + user_agent=mock.ANY, + ) + + +def test_snapshot_download_cache_miss_raises_local_entry_not_found(): + hf_errors = pytest.importorskip("huggingface_hub.errors") + + fake = mock.MagicMock() + fake.download_repo.side_effect = CacheNotFound("no snapshot") + api = MsKernelApi(hub_api=fake) + + with pytest.raises(hf_errors.LocalEntryNotFoundError): + api.snapshot_download("kernels-community/activation", repo_type="kernel", revision="v1") + + +def test_hf_hub_download_forwards_to_download_file(): + fake = mock.MagicMock() + fake.download_file.return_value = Path("/cache/build/torch-cpu/metadata.json") + api = MsKernelApi(hub_api=fake) + + result = api.hf_hub_download( + "kernels-community/activation", + repo_type="kernel", + filename="build/torch-cpu/metadata.json", + cache_dir="/tmp/kernels-cache", + revision="v1", + local_files_only=False, + ) + + assert result == Path("/cache/build/torch-cpu/metadata.json") + fake.download_file.assert_called_once_with( + "kernels-community/activation", + "model", + file_path="build/torch-cpu/metadata.json", + revision="v1", + cache_dir=Path("/tmp/kernels-cache"), + local_files_only=False, + user_agent=mock.ANY, + ) + + +def test_hf_hub_download_missing_file_raises_entry_not_found(): + hf_errors = pytest.importorskip("huggingface_hub.errors") + + fake = mock.MagicMock() + fake.download_file.side_effect = NotExistError("no such file") + api = MsKernelApi(hub_api=fake) + + with pytest.raises(hf_errors.EntryNotFoundError): + api.hf_hub_download( + "kernels-community/activation", + repo_type="kernel", + filename="kernel-status.toml", + revision="v1", + ) + + +def test_hf_hub_download_cache_miss_raises_local_entry_not_found(): + hf_errors = pytest.importorskip("huggingface_hub.errors") + + fake = mock.MagicMock() + fake.download_file.side_effect = CacheNotFound("no cached file") + api = MsKernelApi(hub_api=fake) + + with pytest.raises(hf_errors.LocalEntryNotFoundError): + api.hf_hub_download( + "kernels-community/activation", + repo_type="kernel", + filename="kernel-status.toml", + revision="v1", + local_files_only=True, + ) + + +# --------------------------------------------------------------------------- +# Revision listing +# --------------------------------------------------------------------------- +def test_list_repo_refs_builds_hf_ref_objects(): + hf_api = pytest.importorskip("huggingface_hub.hf_api") + + fake = mock.MagicMock() + fake.legacy.list_revisions_detail.return_value = ( + [{"Revision": "v1"}, {"Revision": "v2"}, {"Revision": "master"}], + [{"Revision": "1.0"}], + ) + api = MsKernelApi(hub_api=fake) + + refs = api.list_repo_refs("kernels-community/activation", repo_type="kernel") + + fake.legacy.list_revisions_detail.assert_called_once_with("kernels-community/activation", "model") + assert [b.name for b in refs.branches] == ["v1", "v2", "master"] + assert [t.name for t in refs.tags] == ["1.0"] + assert all(isinstance(b, hf_api.GitRefInfo) for b in refs.branches) + # target_commit is a synthetic hex OID (kernels 0.18 validates it with + # Oid.from_str); the download entry points map it back to the ref name. + import re as _re + + assert all(_re.fullmatch(r"[0-9a-f]{40}", b.target_commit) for b in refs.branches) + assert refs.converts == [] + + +def test_list_repo_refs_synthesizes_v1_when_no_version_refs(): + """A plain ModelScope mirror with only `master` exposes it as kernel version v1.""" + pytest.importorskip("huggingface_hub.hf_api") + import re as _re + + fake = mock.MagicMock() + fake.legacy.list_revisions_detail.return_value = ([{"Revision": "master"}], []) + api = MsKernelApi(hub_api=fake) + + refs = api.list_repo_refs("kernels-community/activation", repo_type="kernel") + + v1 = [b for b in refs.branches if b.name == "v1"] + assert len(v1) == 1 + # The synthetic v1 ref carries a hex OID that resolves downloads back + # onto `master` (see test_synthetic_oid_resolved_back_to_branch_on_downloads). + assert _re.fullmatch(r"[0-9a-f]{40}", v1[0].target_commit) + + +# --------------------------------------------------------------------------- +# Tree listing +# --------------------------------------------------------------------------- +def test_list_repo_tree_derives_direct_folders_from_blob_paths(): + hf_api = pytest.importorskip("huggingface_hub.hf_api") + + fake = mock.MagicMock() + fake.list_repo_files.return_value = [ + FileInfo(path="README.md", size=10), + FileInfo(path="build/torch-cpu/kernel.py", size=20), + FileInfo(path="build/torch-cpu/sub/deep.py", size=5), + FileInfo(path="build/torch215-cuda124-aarch64-linux/kernel.py", size=30), + ] + api = MsKernelApi(hub_api=fake) + + tree = api.list_repo_tree("kernels-community/activation", path_in_repo="build", repo_type="kernel", revision="v1") + + fake.list_repo_files.assert_called_once_with("kernels-community/activation", "model", revision="v1", recursive=True) + assert [item.path for item in tree] == [ + "build/torch-cpu", + "build/torch215-cuda124-aarch64-linux", + ] + assert all(isinstance(item, hf_api.RepoFolder) for item in tree) + + +def test_list_repo_tree_uses_explicit_tree_entries(): + pytest.importorskip("huggingface_hub.hf_api") + + fake = mock.MagicMock() + fake.list_repo_files.return_value = [ + FileInfo(path="build/torch-cpu", type="tree"), + FileInfo(path="build/torch-cpu/kernel.py", size=20), + ] + api = MsKernelApi(hub_api=fake) + + tree = api.list_repo_tree("kernels-community/activation", path_in_repo="build", revision="v1") + + assert [item.path for item in tree] == ["build/torch-cpu"] + + +def test_file_exists_checks_listing(): + fake = mock.MagicMock() + fake.list_repo_files.return_value = [ + FileInfo(path="README.md", size=10), + FileInfo(path="build/torch-cpu/kernel.py", size=20), + ] + api = MsKernelApi(hub_api=fake) + + assert api.file_exists("kernels-community/activation", "build/torch-cpu/kernel.py", revision="v1") + assert not api.file_exists("kernels-community/activation", "missing.py", revision="v1") + + +# --------------------------------------------------------------------------- +# Publisher trust +# --------------------------------------------------------------------------- +def test_trusted_publisher_allowlist(): + api = MsKernelApi(hub_api=mock.MagicMock()) + + assert api.get_organization_overview("kernels-community").trustedKernelPublisher is True + assert api.get_organization_overview("someone-else").trustedKernelPublisher is False + + +def test_trusted_publisher_allowlist_env_extension(monkeypatch): + monkeypatch.setenv("MS_KERNELS_TRUSTED_PUBLISHERS", "my-org, other-org") + api = MsKernelApi(hub_api=mock.MagicMock()) + + assert api.get_organization_overview("my-org").trustedKernelPublisher is True + assert api.get_organization_overview("other-org").trustedKernelPublisher is True + assert api.get_organization_overview("kernels-community").trustedKernelPublisher is True + + +# --------------------------------------------------------------------------- +# Patch round-trip (requires the optional `kernels` package) +# --------------------------------------------------------------------------- +@pytest.fixture +def kernels_modules(): + kernels = pytest.importorskip("kernels") + kernels_hf_hub = pytest.importorskip("kernels.hf_hub") + kernels_load = pytest.importorskip("kernels.load") + + return kernels, kernels_hf_hub, kernels_load + + +def test_patch_kernels_hub_roundtrip(kernels_modules): + _, kernels_hf_hub, kernels_load = kernels_modules + original = kernels_hf_hub._get_hf_api + original_load = kernels_load._get_hf_api + + patch_kernels_hub() + try: + assert kernels_hf_hub._get_hf_api is _ms_api + assert kernels_load._get_hf_api is _ms_api + assert isinstance(kernels_hf_hub._get_hf_api(), MsKernelApi) + finally: + unpatch_kernels_hub() + + assert kernels_hf_hub._get_hf_api is original + assert kernels_load._get_hf_api is original_load + + +def test_patch_kernels_hub_is_idempotent(kernels_modules): + kernels_hf_hub = kernels_modules[1] + original = kernels_hf_hub._get_hf_api + + patch_kernels_hub() + try: + first = kernels_hf_hub._get_hf_api + patch_kernels_hub() + assert kernels_hf_hub._get_hf_api is first + finally: + unpatch_kernels_hub() + + assert kernels_hf_hub._get_hf_api is original + + +def test_patch_scope_restores_on_exit_and_nests(kernels_modules): + kernels_hf_hub = kernels_modules[1] + original = kernels_hf_hub._get_hf_api + + with patch_scope(): + assert kernels_hf_hub._get_hf_api is _ms_api + with patch_scope(): + assert kernels_hf_hub._get_hf_api is _ms_api + # The inner scope must not undo the outer patch. + assert kernels_hf_hub._get_hf_api is _ms_api + + assert kernels_hf_hub._get_hf_api is original + + +def test_get_kernel_wrapper_scopes_the_patch(kernels_modules, monkeypatch): + kernels, kernels_hf_hub, _ = kernels_modules + original = kernels_hf_hub._get_hf_api + recorded = {} + + def fake_get_kernel(repo_id, **kwargs): + recorded["repo_id"] = repo_id + recorded["kwargs"] = kwargs + recorded["patched"] = kernels_hf_hub._get_hf_api is _ms_api + return "kernel-module" + + monkeypatch.setattr(kernels, "get_kernel", fake_get_kernel) + + result = kernels_get_kernel( + "kernels-community/activation", + version=1, + trust_remote_code=True, + ) + + assert result == "kernel-module" + assert recorded["repo_id"] == "kernels-community/activation" + assert recorded["kwargs"] == { + "revision": None, + "version": 1, + "backend": None, + "user_agent": None, + "trust_remote_code": True, + "check_arch": True, + } + # The patch is active for the duration of the call only. + assert recorded["patched"] is True + assert kernels_hf_hub._get_hf_api is original + + +def test_has_kernel_wrapper_scopes_the_patch(kernels_modules, monkeypatch): + from modelscope_hub.kernels import has_kernel + + kernels, kernels_hf_hub, _ = kernels_modules + original = kernels_hf_hub._get_hf_api + calls = {} + + def fake_has_kernel(repo_id, **kwargs): + calls["patched"] = kernels_hf_hub._get_hf_api is _ms_api + return True + + monkeypatch.setattr(kernels, "has_kernel", fake_has_kernel) + + assert has_kernel("kernels-community/activation", version=1) is True + assert calls["patched"] is True + assert kernels_hf_hub._get_hf_api is original + + +def test_scoped_call_inside_global_patch_keeps_global_patch(kernels_modules, monkeypatch): + """Dual entry points: a scoped wrapper call inside a global patch must not undo it.""" + kernels, kernels_hf_hub, _ = kernels_modules + original = kernels_hf_hub._get_hf_api + recorded = {} + + def fake_get_kernel(repo_id, **kwargs): + recorded["patched"] = kernels_hf_hub._get_hf_api is _ms_api + return "kernel-module" + + monkeypatch.setattr(kernels, "get_kernel", fake_get_kernel) + + patch_kernels_hub() + try: + assert kernels_hf_hub._get_hf_api is _ms_api + result = kernels_get_kernel("kernels-community/activation", version=1) + assert result == "kernel-module" + # The scoped call saw the (already active) patch... + assert recorded["patched"] is True + # ...and did not undo the global patch on exit. + assert kernels_hf_hub._get_hf_api is _ms_api + finally: + unpatch_kernels_hub() + + assert kernels_hf_hub._get_hf_api is original + + +# --------------------------------------------------------------------------- +# New scoped wrappers: install / lock entries +# --------------------------------------------------------------------------- +def test_install_kernel_wrapper_scopes_the_patch(kernels_modules, monkeypatch): + from modelscope_hub.kernels import install_kernel + + kernels, kernels_hf_hub, _ = kernels_modules + original = kernels_hf_hub._get_hf_api + calls = {} + + def fake_install(repo_id, **kwargs): + calls["patched"] = kernels_hf_hub._get_hf_api is _ms_api + calls["repo_id"] = repo_id + calls["kwargs"] = kwargs + return Path("/cache/variants/x") + + monkeypatch.setattr(kernels, "install_kernel", fake_install) + + result = install_kernel( + "kernels-community/activation", + version=1, + local_files_only=False, + trust_remote_code=True, + ) + assert result == Path("/cache/variants/x") + assert calls["patched"] is True + assert calls["repo_id"] == "kernels-community/activation" + assert calls["kwargs"]["version"] == 1 + assert calls["kwargs"]["trust_remote_code"] is True + assert kernels_hf_hub._get_hf_api is original + + +def test_install_kernel_all_variants_wrapper_scopes_the_patch(kernels_modules, monkeypatch): + from modelscope_hub.kernels import install_kernel_all_variants + + kernels, kernels_hf_hub, _ = kernels_modules + original = kernels_hf_hub._get_hf_api + calls = {} + + def fake_install_all(repo_id, **kwargs): + calls["patched"] = kernels_hf_hub._get_hf_api is _ms_api + return Path("/cache/build") + + # `install_kernel_all_variants` lives in the `kernels.install` submodule + # and is not exported on the `kernels` top level. + monkeypatch.setattr(kernels.install, "install_kernel_all_variants", fake_install_all) + + result = install_kernel_all_variants("kernels-community/activation", version=1) + assert result == Path("/cache/build") + assert calls["patched"] is True + assert kernels_hf_hub._get_hf_api is original + + +def test_get_locked_kernel_wrapper_scopes_the_patch(kernels_modules, monkeypatch, tmp_path): + from modelscope_hub.kernels import get_locked_kernel + + kernels, kernels_hf_hub, _ = kernels_modules + original = kernels_hf_hub._get_hf_api + calls = {} + + def fake_get_locked(repo_id, **kwargs): + calls["patched"] = kernels_hf_hub._get_hf_api is _ms_api + calls["kwargs"] = kwargs + return "kernel-module" + + monkeypatch.setattr(kernels, "get_locked_kernel", fake_get_locked) + + lockfile = tmp_path / "kernel-lock.toml" + result = get_locked_kernel("kernels-community/activation", lockfile=lockfile) + assert result == "kernel-module" + assert calls["patched"] is True + assert calls["kwargs"]["lockfile"] == lockfile + assert kernels_hf_hub._get_hf_api is original + + +def test_load_kernel_wrapper_scopes_the_patch(kernels_modules, monkeypatch, tmp_path): + from modelscope_hub.kernels import load_kernel + + kernels, kernels_hf_hub, _ = kernels_modules + original = kernels_hf_hub._get_hf_api + calls = {} + + def fake_load(repo_id, **kwargs): + calls["patched"] = kernels_hf_hub._get_hf_api is _ms_api + return "kernel-module" + + monkeypatch.setattr(kernels, "load_kernel", fake_load) + + lockfile = tmp_path / "kernel-lock.toml" + result = load_kernel("kernels-community/activation", lockfile=lockfile, backend="cpu") + assert result == "kernel-module" + assert calls["patched"] is True + assert kernels_hf_hub._get_hf_api is original + + +# --------------------------------------------------------------------------- +# Lockfile commit-hash hint +# --------------------------------------------------------------------------- +def test_hf_hub_download_commit_hash_failure_gets_lockfile_hint(): + hf_errors = pytest.importorskip("huggingface_hub.errors") + fake = mock.MagicMock() + fake.download_file.side_effect = NotExistError("no such revision") + api = MsKernelApi(hub_api=fake) + + commit = "a" * 40 + with pytest.raises(hf_errors.EntryNotFoundError) as excinfo: + api.hf_hub_download("kernels-community/activation", filename="build/x/metadata.json", revision=commit) + + cause = excinfo.value.__cause__ + assert cause is not None + # PEP 678 notes exist on Python 3.11+; the hint text is in the notes. + notes = list(getattr(cause, "__notes__", ())) + if notes: + assert any("lockfile" in note for note in notes) + assert commit in "".join(notes) + + +def test_snapshot_download_commit_hash_failure_propagates_with_hint(): + fake = mock.MagicMock() + fake.download_repo.side_effect = NotExistError("no such revision") + api = MsKernelApi(hub_api=fake) + + commit = "b" * 40 + with pytest.raises(NotExistError) as excinfo: + api.snapshot_download("kernels-community/activation", repo_type="kernel", revision=commit) + + notes = list(getattr(excinfo.value, "__notes__", ())) + if notes: + assert any("lockfile" in note for note in notes) + + +def test_hf_hub_download_branch_failure_gets_no_lockfile_hint(): + hf_errors = pytest.importorskip("huggingface_hub.errors") + fake = mock.MagicMock() + fake.download_file.side_effect = NotExistError("no such file") + api = MsKernelApi(hub_api=fake) + + with pytest.raises(hf_errors.EntryNotFoundError) as excinfo: + api.hf_hub_download("kernels-community/activation", filename="missing.json", revision="v1") + + assert not list(getattr(excinfo.value.__cause__, "__notes__", ())) + + +# --------------------------------------------------------------------------- +# Synthetic OIDs (kernels 0.18 validates target_commit as a git object id) +# --------------------------------------------------------------------------- +def test_list_repo_refs_returns_hex_target_commits(): + import re as _re + + fake = mock.MagicMock() + fake.legacy.list_revisions_detail.return_value = ([{"Revision": "master"}], []) + api = MsKernelApi(hub_api=fake) + + refs = api.list_repo_refs("kernels-community/activation") + # kernels 0.18 requires Oid.from_str(target_commit): 40/64 hex digits. + for ref in refs.branches + refs.tags: + assert _re.fullmatch(r"[0-9a-f]{40}", ref.target_commit), ref + # Master-only mirror: the synthesized v1 ref also carries a hex OID. + v1 = next(r for r in refs.branches if r.name == "v1") + assert _re.fullmatch(r"[0-9a-f]{40}", v1.target_commit) + # Deterministic: same repo/ref always maps to the same OID. + fake.legacy.list_revisions_detail.return_value = ([{"Revision": "master"}], []) + again = api.list_repo_refs("kernels-community/activation") + assert next(r for r in again.branches if r.name == "v1").target_commit == v1.target_commit + + +def test_synthetic_oid_resolved_back_to_branch_on_downloads(): + fake = mock.MagicMock() + fake.legacy.list_revisions_detail.return_value = ([{"Revision": "master"}], []) + api = MsKernelApi(hub_api=fake) + + refs = api.list_repo_refs("kernels-community/activation") + oid = refs.branches[0].target_commit # master's synthetic OID (0.17-style resolve) + v1_oid = next(r.target_commit for r in refs.branches if r.name == "v1") + + # kernels passes the OID as the download revision (both 0.17 and 0.18); + # the shim maps it back to the real ModelScope ref name. + api.snapshot_download("kernels-community/activation", repo_type="kernel", revision=oid) + assert fake.download_repo.call_args.kwargs["revision"] == "master" + + api.hf_hub_download("kernels-community/activation", filename="README.md", revision=v1_oid) + assert fake.download_file.call_args.kwargs["revision"] == "master" + + api.list_repo_tree("kernels-community/activation", revision=oid) + assert fake.list_repo_files.call_args.kwargs["revision"] == "master" + + api.file_exists("kernels-community/activation", "README.md", revision=oid) + assert fake.list_repo_files.call_args.kwargs["revision"] == "master" + + +def test_plain_revision_not_mapped(): + """A normal branch/tag revision passes through untouched.""" + fake = mock.MagicMock() + api = MsKernelApi(hub_api=fake) + + api.snapshot_download("kernels-community/activation", repo_type="kernel", revision="v2") + assert fake.download_repo.call_args.kwargs["revision"] == "v2" + + api.snapshot_download("kernels-community/activation", repo_type="kernel", revision=None) + assert fake.download_repo.call_args.kwargs["revision"] == "master"