From a68278e480b0448dd8fc8627203c5948c88c86a2 Mon Sep 17 00:00:00 2001 From: rosspeili Date: Sun, 9 Aug 2026 13:00:57 +0300 Subject: [PATCH 1/2] feat(loader): validate manifest requirement version specifiers Extend SkillLoader pre-flight checks: unpinned manifest requirements still require importability; pinned PEP 508 specifiers (e.g. web3>=6.0.0) must match installed distribution versions before skill.py loads. Adds check_manifest_requirements in extras, unit and integration tests, and docs/CHANGELOG updates. Fixes #14. --- CHANGELOG.md | 1 + CONTRIBUTING.md | 4 +- docs/TESTING.md | 2 +- docs/contributing/ai_native_workflow.md | 7 +- docs/security/skill-trust-model.md | 5 +- docs/usage/README.md | 2 + docs/usage/install_extras.md | 11 +- skillware/core/extras.py | 143 +++++++++++++++++++++--- skillware/core/loader.py | 33 +----- templates/python_skill/README.md | 2 +- tests/test_loader.py | 80 +++++++++++++ tests/test_requirements_check.py | 86 ++++++++++++++ 12 files changed, 323 insertions(+), 53 deletions(-) create mode 100644 tests/test_requirements_check.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9372eaf..993b3a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Contributors add user-facing entries under `[Unreleased]` in the same PR. Mainta ### Changed +- **Loader:** `SkillLoader.load_skill()` validates manifest `requirements` version specifiers (for example `web3>=6.0.0`) against installed package versions before loading `skill.py`; unpinned entries still require importability only (#14). - **GitHub**: New Skill Proposal template category dropdown synced with the registry (`creative`, `security`); added `creative` label (#279, #277). ## [0.4.8] - 2026-08-03 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7418085..88bcec2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -229,7 +229,7 @@ Defines the tool interface, safety constitution, dependencies, and issuer attrib - `short_description` — optional one-line summary (~80 chars) shown in `skillware list` when present - `parameters` — valid JSON Schema for LLM tool calling - `constitution` — safety boundaries enforced at the prompt level -- `requirements` — when external packages are needed (for example `requests`, `pandas`) +- `requirements` — when external packages are needed (for example `requests`, `pandas`). Use PEP 508 strings; add version specifiers (for example `web3>=6.0.0`) when the skill depends on a minimum package version — `SkillLoader.load_skill()` validates pins at load time (see [Install extras](docs/usage/install_extras.md#loader-behavior)). **Optional but common:** @@ -308,6 +308,8 @@ Registry skills are shipped inside the `skillware` wheel. Per-skill layout uses **Manifest `requirements` and optional extras** - List runtime packages in the skill's `manifest.yaml` `requirements` (source of truth for loaders and docs). +- **Unpinned** entries (for example `requests`) — loader checks the importable module exists. +- **Pinned** entries (for example `rembg>=2.0.0`) — loader also verifies the installed distribution satisfies the specifier before `skill.py` runs. Pin when API or behavior breaks across versions; unpinned is fine for stable deps. - Run `python scripts/sync_extras.py` after changing manifests — it regenerates category, per-skill, and `[all]` rows in `pyproject.toml` (see [Install extras](docs/usage/install_extras.md)). - Core already includes `requests`, `pyyaml`, and `beautifulsoup4` (manifests may say `bs4`); the sync script omits core packages from extras automatically. - Hand-maintained extras (`dev`, `gemini`, `claude`, `openai`, `agents`) stay above the generated block in `pyproject.toml`. diff --git a/docs/TESTING.md b/docs/TESTING.md index db1b9d5..c580fe8 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -86,7 +86,7 @@ pip install -r requirements.txt | :--- | :--- | :--- | | Manifest + execute contract for one skill | Bundle test | `skills/compliance/tos_evaluator/test_skill.py` | | Loader path + mocked externals (optional depth) | Maintainer test | `tests/skills/compliance/test_tos_evaluator.py` | -| Loader, CLI, registry issuer rules, param validation | Framework test | `tests/test_loader.py`, `tests/test_skill_issuer.py`, `tests/test_validate_params.py`, `tests/test_registry_docs.py`, `tests/test_card_ui_schema.py` | +| Loader, CLI, registry issuer rules, param validation, manifest requirement pins | Framework test | `tests/test_loader.py`, `tests/test_requirements_check.py`, `tests/test_skill_issuer.py`, `tests/test_validate_params.py`, `tests/test_registry_docs.py`, `tests/test_card_ui_schema.py` | | End-to-end provider demo script | Usage example | `examples/gemini_tos_evaluator.py` | **Rule of thumb:** if it ships with the skill and must pass before merge → **bundle test** (CI + local). If it is extra regression depth for clone-repo work → **maintainer test** (optional). If it proves provider integration → **example**, not pytest. diff --git a/docs/contributing/ai_native_workflow.md b/docs/contributing/ai_native_workflow.md index 18f860d..60e6c42 100644 --- a/docs/contributing/ai_native_workflow.md +++ b/docs/contributing/ai_native_workflow.md @@ -236,6 +236,7 @@ These align with [CONTRIBUTING.md](../../CONTRIBUTING.md). Violations block merg - Bundle: `manifest.yaml`, `skill.py`, `instructions.md`, `card.json`, `test_skill.py`, plus catalog docs - `manifest.yaml` is source of truth for schema, constitution, `requirements`, `env_vars`, and `issuer` +- `requirements` — PEP 508 strings; unpinned checks importability only; version specifiers (for example `>=2.0.0`) are validated at load time — pin when the skill is sensitive to package API versions - `manifest.yaml` `name` must equal `category/skill_name` (matches folder path); loader warns on mismatch for registry layout - `issuer.name` and `issuer.email` required; `github` and `org` optional; no template placeholders in registry paths - `card.json` issuer must match manifest `name` and `email` when present @@ -252,7 +253,7 @@ These align with [CONTRIBUTING.md](../../CONTRIBUTING.md). Violations block merg - Require a framework feature issue; add tests under `tests/` - Do not change `loader.py` unless the issue requires it - Issuer metadata is not passed into LLM tool schemas today -- Update `docs/usage/` when adapter behavior changes +- Update `docs/usage/` when loader or adapter behavior changes (for example requirement validation, `registry_id`, identity warnings) ### Documentation @@ -305,8 +306,8 @@ Complete the checklist that matches your issue during Stage 5. ### Core framework - [ ] Framework issue approved -- [ ] Changes in `skillware/` and relevant `tests/` -- [ ] Loader or API docs updated when behavior changes (e.g. `registry_id`, identity warnings, `validate_params`) +- [ ] Changes in `skillware/` and relevant `tests/` (for example `tests/test_loader.py`, `tests/test_requirements_check.py`) +- [ ] Loader or API docs updated when behavior changes (e.g. `registry_id`, identity warnings, manifest requirement validation, `validate_params`) - [ ] `pytest tests/` passes - [ ] Usage docs updated if API changed - [ ] No undeclared breaking changes diff --git a/docs/security/skill-trust-model.md b/docs/security/skill-trust-model.md index 6910cdd..b7dc733 100644 --- a/docs/security/skill-trust-model.md +++ b/docs/security/skill-trust-model.md @@ -8,7 +8,7 @@ This page explains how Skillware resolves and executes skills on disk, and how y When you load a skill, Skillware executes that skill's skill.py in your host process. Import runs the module's top-level code immediately, and from that point the skill's code has the same reach your process has: the full filesystem and every environment variable in os.environ, including secrets and API keys. -There is no isolation, no permission boundary, and no capability restriction around this execution. SkillLoader checks that a skill's declared requirements are importable, but it does not inspect, restrict, or sandbox what the code does. So the real decision each time you load a skill is: am I willing to hand this code my machine and my secrets? +There is no isolation, no permission boundary, and no capability restriction around this execution. SkillLoader checks that a skill's declared requirements are importable and, when a manifest entry includes a version specifier, that the installed distribution satisfies it — but it does not inspect, restrict, or sandbox what the code does. So the real decision each time you load a skill is: am I willing to hand this code my machine and my secrets? The trust tiers in this document describe how much you should trust a skill's origin. They are not isolation levels. To be explicit: none of them are sandboxed today. @@ -65,12 +65,13 @@ A skill's manifest.yaml can declare a constitution — a set of natural-language A constitution guides the agent (the LLM calling the skill). It does not constrain skill.py. Nothing in the loader enforces a constitution at the Python level — code that ignores every rule in the constitution loads and runs exactly the same. The constitution is a prompt-level norm, not a process-level boundary. -The same distinction applies to other manifest fields. Declaring env_vars documents which variables a skill expects; it does not limit what the code can read. The loader's requirements check confirms that declared packages are importable; it does not inspect what the code does with them. In short: +The same distinction applies to other manifest fields. Declaring env_vars documents which variables a skill expects; it does not limit what the code can read. The loader's requirements check confirms that declared packages are importable and, when a manifest entry includes a version specifier, that the installed distribution satisfies it — it does not inspect what the code does with them. In short: | constitution / manifest does | It does not | | :--- | :--- | | Tell the agent how the skill should be used | Sandbox or restrict skill.py | | Document expected env vars and dependencies | Limit which env vars or files the code can access | +| Validate importability (and version pins when declared) before load | Enforce dependency versions by upgrading packages automatically | | Set norms reviewers and agents can rely on | Enforce those norms at runtime | ### Instruction-only content diff --git a/docs/usage/README.md b/docs/usage/README.md index 7f39473..6afcd55 100644 --- a/docs/usage/README.md +++ b/docs/usage/README.md @@ -12,6 +12,8 @@ How to load Skillware skills and connect them to language models. Each guide cov For pip-installed apps, keep project skills in `./skills///` or set `SKILLWARE_SKILL_PATH` to your skills root. +By default, `SkillLoader.load_skill()` validates manifest `requirements` before loading `skill.py`: unpinned deps must be importable; pinned specifiers (for example `web3>=6.0.0`) must match the installed version. See [Install extras — Loader behavior](install_extras.md#loader-behavior). + > **Security:** Loading a skill executes its `skill.py` in your process — there is no sandbox, and the first matching id in the search order wins (a local skill can shadow a bundled one). Only load skills you trust, and see the [skill trust model](../security/skill-trust-model.md) before loading external skills. To list locally available skills, inspect path resolution, or run bundle tests from the terminal, see the [CLI reference](cli.md) (`skillware list`, `skillware paths`, `skillware test`). diff --git a/docs/usage/install_extras.md b/docs/usage/install_extras.md index 856f1bd..b64bdba 100644 --- a/docs/usage/install_extras.md +++ b/docs/usage/install_extras.md @@ -145,12 +145,19 @@ pip install "skillware[gemini]" ## Loader behavior -When `SkillLoader.load_skill(..., check_requirements=True)` (default) finds manifest `requirements` that are not importable, it raises `ImportError` with: +When `SkillLoader.load_skill(..., check_requirements=True)` (default) validates manifest `requirements`: -1. The missing package names +1. **Unpinned** entries (for example `requests`) — the importable module must exist. +2. **Pinned** entries (for example `web3>=6.0.0`) — the module must exist and the installed distribution version must satisfy the specifier. + +On failure it raises `ImportError` with: + +1. Missing packages and/or version mismatches (required spec vs installed) 2. Suggested `pip install "skillware[_]"` 3. Category and `[all]` fallbacks +The loader does not install or upgrade packages — use pip extras or install the requirement strings yourself. + Packaging smoke tests use `check_requirements=False` so a base wheel install can verify bundles without optional extras ([TESTING.md](../TESTING.md#packaging-smoke-test)). ## Contributors diff --git a/skillware/core/extras.py b/skillware/core/extras.py index c1ea371..5221d6b 100644 --- a/skillware/core/extras.py +++ b/skillware/core/extras.py @@ -2,10 +2,13 @@ from __future__ import annotations +import importlib.util +from importlib.metadata import PackageNotFoundError, version from pathlib import Path from typing import Dict, Iterable, List, Mapping, Optional, Sequence, Tuple import yaml +from packaging.requirements import InvalidRequirement, Requirement from skillware.core.discovery import bundled_skills_root, list_registry_skill_ids @@ -28,6 +31,16 @@ "bs4": "beautifulsoup4", } +# PyPI distribution names that differ from their import paths (loader presence checks). +_REQUIREMENT_IMPORT_ALIASES: Mapping[str, str] = { + "google-genai": "google.genai", + "google-generativeai": "google.generativeai", + "pymupdf": "fitz", + "beautifulsoup4": "bs4", + "pyyaml": "yaml", + "pillow": "PIL", +} + GENERATED_BEGIN = "# --- extras: begin generated by scripts/sync_extras.py ---" GENERATED_END = "# --- extras: end generated by scripts/sync_extras.py ---" @@ -47,6 +60,26 @@ def normalize_pkg_name(requirement: str) -> str: return _REQUIREMENT_CORE_ALIASES.get(pkg, pkg) +def parse_requirement(requirement: str) -> Requirement: + """Parse a PEP 508 requirement string from a manifest entry.""" + return Requirement(requirement.split(";")[0].strip()) + + +def requirement_import_module(requirement: str) -> str: + """Return the importable module name used for find_spec presence checks.""" + dist = normalize_pkg_name(requirement) + return _REQUIREMENT_IMPORT_ALIASES.get(dist, dist) + + +def _installed_distribution_version(distribution_name: str) -> Optional[str]: + for candidate in (distribution_name, normalize_pkg_name(distribution_name)): + try: + return version(candidate) + except PackageNotFoundError: + continue + return None + + def is_core_requirement(requirement: str) -> bool: return normalize_pkg_name(requirement) in CORE_DEPENDENCIES @@ -189,6 +222,23 @@ def render_generated_block(skills_root: Optional[Path] = None) -> str: return "\n".join(lines) +def _pip_install_hints(registry_id: Optional[str]) -> List[str]: + if not registry_id: + return [] + skill_extra = registry_id_to_extra(registry_id) + category = registry_id.split("/", 1)[0] + return [ + "Install the skill runtime extra:", + f' pip install "skillware[{skill_extra}]"', + "", + "Or install all skills in the category:", + f' pip install "skillware[{category}]"', + "", + "Or install runtime deps for every bundled skill:", + ' pip install "skillware[all]"', + ] + + def build_missing_requirements_message( manifest: Mapping[str, object], registry_id: Optional[str], @@ -201,28 +251,89 @@ def build_missing_requirements_message( f"Skill '{skill_name}' requires missing packages: {missing_list}.", "", ] - - if registry_id: - skill_extra = registry_id_to_extra(registry_id) - category = registry_id.split("/", 1)[0] - lines.extend( - [ - "Install the skill runtime extra:", - f' pip install "skillware[{skill_extra}]"', - "", - "Or install all skills in the category:", - f' pip install "skillware[{category}]"', - "", - "Or install runtime deps for every bundled skill:", - ' pip install "skillware[all]"', - ] - ) + hints = _pip_install_hints(registry_id) + if hints: + lines.extend(hints) else: lines.append(f"Please run: pip install {' '.join(missing)}") return "\n".join(lines) +def build_version_mismatch_message( + manifest: Mapping[str, object], + registry_id: Optional[str], + mismatches: Sequence[tuple[str, str, str]], +) -> str: + """Build a pip install hint when installed versions fail manifest specifiers.""" + skill_name = str(manifest.get("name") or registry_id or "unknown") + lines = [f"Skill '{skill_name}' has unsatisfied version requirements:", ""] + for requirement, installed, specifier in mismatches: + lines.append(f" - {requirement} (installed: {installed}, need: {specifier})") + lines.append("") + hints = _pip_install_hints(registry_id) + if hints: + lines.extend(hints) + else: + reqs = " ".join(req for req, _, _ in mismatches) + lines.append(f"Please run: pip install '{reqs}'") + return "\n".join(lines) + + +def check_manifest_requirements( + requirements: Sequence[str], + *, + registry_id: Optional[str] = None, + manifest: Optional[Mapping[str, object]] = None, +) -> None: + """ + Validate manifest requirements before loading skill.py. + + Unpinned entries: importable module must exist (find_spec). + Pinned entries: module must exist and installed distribution version must + satisfy the specifier. Does not install or upgrade packages. + """ + manifest = manifest or {} + missing: List[str] = [] + mismatches: List[tuple[str, str, str]] = [] + + for raw in requirements: + requirement = str(raw).strip() + if not requirement: + continue + try: + parsed = parse_requirement(requirement) + except InvalidRequirement as exc: + raise ImportError( + f"Invalid requirement in manifest: {requirement!r} ({exc})" + ) from exc + + import_name = requirement_import_module(requirement) + if importlib.util.find_spec(import_name) is None: + missing.append(requirement) + continue + + if not parsed.specifier: + continue + + installed = _installed_distribution_version(parsed.name) + if installed is None: + missing.append(requirement) + continue + + if not parsed.specifier.contains(installed, prereleases=True): + mismatches.append((requirement, installed, str(parsed.specifier))) + + if missing: + raise ImportError( + build_missing_requirements_message(manifest, registry_id, missing) + ) + if mismatches: + raise ImportError( + build_version_mismatch_message(manifest, registry_id, mismatches) + ) + + def extras_for_registry_id(registry_id: str) -> Tuple[str, str]: """Return (category_extra, skill_extra) names for a registry skill id.""" category = registry_id.split("/", 1)[0] diff --git a/skillware/core/loader.py b/skillware/core/loader.py index 8d0960b..f327407 100644 --- a/skillware/core/loader.py +++ b/skillware/core/loader.py @@ -18,7 +18,7 @@ get_skill_roots, is_skill_dir, ) -from skillware.core.extras import build_missing_requirements_message +from skillware.core.extras import check_manifest_requirements SKILLWARE_SKILL_PATH_ENV = _discovery.SKILLWARE_SKILL_PATH_ENV @@ -27,28 +27,12 @@ class SkillwareIdentityWarning(UserWarning): """Emitted when manifest.name does not match the registry folder path (warn-only in v1).""" -# PyPI distribution names that differ from their import paths. -_REQUIREMENT_IMPORT_ALIASES = { - "google-genai": "google.genai", - "google-generativeai": "google.generativeai", - "pymupdf": "fitz", - "beautifulsoup4": "bs4", - "pyyaml": "yaml", - "pillow": "PIL", -} - - class SkillLoader: """ Utility to load skills dynamically or by path, bundling their manifests, instructions, and logic for LLM usage. """ - @staticmethod - def _requirement_import_name(requirement: str) -> str: - pkg_name = requirement.split(">")[0].split("<")[0].split("=")[0].strip() - return _REQUIREMENT_IMPORT_ALIASES.get(pkg_name, pkg_name) - @staticmethod def _is_skill_dir(path: Path) -> bool: return is_skill_dir(path) @@ -217,16 +201,11 @@ def load_skill( # Check Dependencies if check_requirements and "requirements" in manifest: - missing = [] - for req in manifest["requirements"]: - import_name = SkillLoader._requirement_import_name(req) - if not importlib.util.find_spec(import_name): - missing.append(req) - - if missing: - raise ImportError( - build_missing_requirements_message(manifest, registry_id, missing) - ) + check_manifest_requirements( + manifest["requirements"], + registry_id=registry_id, + manifest=manifest, + ) # Load Instructions instructions = "" diff --git a/templates/python_skill/README.md b/templates/python_skill/README.md index b895368..11755d2 100644 --- a/templates/python_skill/README.md +++ b/templates/python_skill/README.md @@ -5,7 +5,7 @@ Starter bundle under `skills///`. Copy this template from ## Before you submit 1. **Rename** the folder to match your skill ID (e.g. `skills/finance/my_skill`). -2. **Packaging**: Add empty `__init__.py` files in `skills//` (new categories only) and in your skill folder so PyPI wheels include the full bundle. List runtime packages in `manifest.yaml` `requirements`, then run `python scripts/sync_extras.py` to update optional extras in `pyproject.toml` (see [Install extras](../../docs/usage/install_extras.md)). +2. **Packaging**: Add empty `__init__.py` files in `skills//` (new categories only) and in your skill folder so PyPI wheels include the full bundle. List runtime packages in `manifest.yaml` `requirements` (PEP 508; pin with `>=` when the skill needs a minimum version — see [Install extras](../../docs/usage/install_extras.md#loader-behavior)), then run `python scripts/sync_extras.py` to update optional extras in `pyproject.toml`. 3. **`manifest.yaml`**: Set real `name` to the full registry ID (`category/skill_name`, matching the folder path), `version`, `description`, `short_description`, `parameters`, `constitution`, and `issuer` (`name` + `email` required; `github` / `org` optional). `SkillLoader.load_skill()` warns when `name` diverges from the path under registry layout; flat private layouts (`skills//`) are not validated. 4. **`skill.py`**: Implement deterministic logic with exactly one `BaseSkill` subclass (loaded as `bundle["class"]`); no LLM-generated code in the skill body. 5. **`instructions.md`**: Tell the agent when and how to use the tool. diff --git a/tests/test_loader.py b/tests/test_loader.py index 62d899d..2a63125 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -53,6 +53,86 @@ def fake_find_spec(name, package=None): SkillLoader.load_skill("compliance/mica_module") +def test_load_skill_unpinned_requirement_ignores_installed_version( + tmp_path, monkeypatch +): + """Unpinned manifest deps only require importability, not a version match.""" + skill_dir = tmp_path / "skills" / "demo" / "unpinned" + skill_dir.mkdir(parents=True) + (skill_dir / "manifest.yaml").write_text( + "name: demo/unpinned\nversion: 0.1.0\ndescription: test\n" + "parameters:\n type: object\n properties: {}\n" + "requirements:\n - demo_pkg\n", + encoding="utf-8", + ) + (skill_dir / "skill.py").write_text( + "from skillware.core.base_skill import BaseSkill\n" + "class UnpinnedSkill(BaseSkill):\n" + " def execute(self, **kwargs):\n" + " return {}\n", + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + importlib.util, "find_spec", lambda name, package=None: object() + ) + + bundle = SkillLoader.load_skill("demo/unpinned") + assert bundle["manifest"]["name"] == "demo/unpinned" + + +def test_load_skill_rejects_unsatisfied_version_specifier(tmp_path, monkeypatch): + skill_dir = tmp_path / "skills" / "demo" / "pinned" + skill_dir.mkdir(parents=True) + (skill_dir / "manifest.yaml").write_text( + "name: demo/pinned\nversion: 0.1.0\ndescription: test\n" + "parameters:\n type: object\n properties: {}\n" + "requirements:\n - demo_pkg>=2.0.0\n", + encoding="utf-8", + ) + (skill_dir / "skill.py").write_text( + "from skillware.core.base_skill import BaseSkill\n" + "class PinnedSkill(BaseSkill):\n" + " def execute(self, **kwargs):\n" + " return {}\n", + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + importlib.util, "find_spec", lambda name, package=None: object() + ) + monkeypatch.setattr("skillware.core.extras.version", lambda _name: "1.0.0") + + with pytest.raises(ImportError, match="unsatisfied version requirements"): + SkillLoader.load_skill("demo/pinned") + + +def test_load_skill_accepts_satisfied_version_specifier(tmp_path, monkeypatch): + skill_dir = tmp_path / "skills" / "demo" / "pinned_ok" + skill_dir.mkdir(parents=True) + (skill_dir / "manifest.yaml").write_text( + "name: demo/pinned_ok\nversion: 0.1.0\ndescription: test\n" + "parameters:\n type: object\n properties: {}\n" + "requirements:\n - demo_pkg>=2.0.0\n", + encoding="utf-8", + ) + (skill_dir / "skill.py").write_text( + "from skillware.core.base_skill import BaseSkill\n" + "class PinnedOkSkill(BaseSkill):\n" + " def execute(self, **kwargs):\n" + " return {}\n", + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + importlib.util, "find_spec", lambda name, package=None: object() + ) + monkeypatch.setattr("skillware.core.extras.version", lambda _name: "2.1.0") + + bundle = SkillLoader.load_skill("demo/pinned_ok") + assert bundle["class"].__name__ == "PinnedOkSkill" + + def test_load_skill_class_is_instantiable(): bundle = SkillLoader.load_skill("optimization/prompt_rewriter") skill = bundle["class"]() diff --git a/tests/test_requirements_check.py b/tests/test_requirements_check.py new file mode 100644 index 0000000..cdb6002 --- /dev/null +++ b/tests/test_requirements_check.py @@ -0,0 +1,86 @@ +"""Tests for manifest requirement validation (loader pre-flight).""" + +import importlib.util + +import pytest + +from skillware.core.extras import ( + build_version_mismatch_message, + check_manifest_requirements, + parse_requirement, + requirement_import_module, +) + + +def test_parse_requirement_handles_version_specifier(): + parsed = parse_requirement("web3>=6.0.0") + assert parsed.name == "web3" + assert str(parsed.specifier) == ">=6.0.0" + + +def test_requirement_import_module_resolves_aliases(): + assert requirement_import_module("google-genai") == "google.genai" + assert requirement_import_module("pymupdf>=1.0") == "fitz" + + +def test_check_manifest_requirements_unpinned_only_checks_importable(monkeypatch): + monkeypatch.setattr( + importlib.util, "find_spec", lambda name, package=None: object() + ) + + check_manifest_requirements(["requests"], manifest={"name": "demo/skill"}) + + +def test_check_manifest_requirements_missing_package(monkeypatch): + monkeypatch.setattr(importlib.util, "find_spec", lambda name, package=None: None) + + with pytest.raises(ImportError, match="missing packages"): + check_manifest_requirements( + ["missing_pkg"], + registry_id="demo/skill", + manifest={"name": "demo/skill"}, + ) + + +def test_check_manifest_requirements_version_mismatch(monkeypatch): + monkeypatch.setattr( + importlib.util, "find_spec", lambda name, package=None: object() + ) + monkeypatch.setattr("skillware.core.extras.version", lambda _name: "1.0.0") + + with pytest.raises(ImportError, match="unsatisfied version requirements"): + check_manifest_requirements( + ["demo_pkg>=2.0.0"], + registry_id="demo/skill", + manifest={"name": "demo/skill"}, + ) + + +def test_check_manifest_requirements_satisfied_version(monkeypatch): + monkeypatch.setattr( + importlib.util, "find_spec", lambda name, package=None: object() + ) + monkeypatch.setattr("skillware.core.extras.version", lambda _name: "2.5.0") + + check_manifest_requirements( + ["demo_pkg>=2.0.0"], + manifest={"name": "demo/skill"}, + ) + + +def test_check_manifest_requirements_invalid_pep508(): + with pytest.raises(ImportError, match="Invalid requirement"): + check_manifest_requirements( + ["not a valid req !!!"], manifest={"name": "demo/skill"} + ) + + +def test_build_version_mismatch_message_includes_install_hint(): + message = build_version_mismatch_message( + {"name": "defi/evm_tx_handler"}, + "defi/evm_tx_handler", + [("web3>=6.0.0", "5.31.0", ">=6.0.0")], + ) + assert "web3>=6.0.0" in message + assert "5.31.0" in message + assert "skillware[defi_evm_tx_handler]" in message From 461787d40b380466490c662b8a466c6d56a00d6e Mon Sep 17 00:00:00 2001 From: rosspeili Date: Sun, 9 Aug 2026 13:05:34 +0300 Subject: [PATCH 2/2] fix(packaging): wheel smoke uses requirement_import_module from extras Replace removed SkillLoader._requirement_import_name after #14 loader refactor. --- scripts/wheel_smoke_test.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/wheel_smoke_test.py b/scripts/wheel_smoke_test.py index 5c10db6..7e4ad26 100644 --- a/scripts/wheel_smoke_test.py +++ b/scripts/wheel_smoke_test.py @@ -26,6 +26,7 @@ import yaml from skillware.core.discovery import bundled_skills_root, list_registry_skill_ids +from skillware.core.extras import requirement_import_module from skillware.core.loader import SkillLoader BUNDLE_FILES = ( @@ -58,7 +59,7 @@ class SmokeReport: def _missing_manifest_requirements(manifest: dict) -> List[str]: missing: List[str] = [] for req in manifest.get("requirements") or []: - import_name = SkillLoader._requirement_import_name(req) + import_name = requirement_import_module(req) if importlib.util.find_spec(import_name) is None: missing.append(import_name) return missing