Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 17 additions & 1 deletion deepmd/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@
from deepmd.pretrained.registry import (
available_model_names,
)
from deepmd.skills import (
add_parser,
)
from deepmd.skills import run as run_skills
from deepmd.skills import (
workflow_help,
)

try:
from deepmd._version import version as __version__
Expand Down Expand Up @@ -156,6 +163,7 @@ def main_parser() -> argparse.ArgumentParser:
)

subparsers = parser.add_subparsers(title="Valid subcommands", dest="command")
add_parser(subparsers)

# * logging options parser *********************************************************
# with use of the parent argument this options will be added to every parser
Expand Down Expand Up @@ -1079,6 +1087,10 @@ def main_parser() -> argparse.ArgumentParser:
help="Optional cache directory for pretrained model files",
)

parser.epilog += "\nDiscover bundled workflow guides: dp skills list."
guidance = workflow_help("deepmd-train")
if guidance:
parser_train.epilog = (parser_train.epilog or "") + "\n" + guidance
return parser


Expand All @@ -1100,7 +1112,7 @@ def parse_args(args: list[str] | None = None) -> argparse.Namespace:
parsed_args = parser.parse_args(args=args)
if parsed_args.command is None:
parser.print_help()
else:
elif parsed_args.command != "skills":
parsed_args.log_level = get_ll(parsed_args.log_level)

return parsed_args
Expand All @@ -1122,6 +1134,10 @@ def main(args: list[str] | None = None) -> None:
"""
args = parse_args(args=args)

if args.command == "skills":
run_skills(args)
return

if args.backend not in BACKEND_TABLE:
raise ValueError(f"Unknown backend {args.backend}")

Expand Down
87 changes: 87 additions & 0 deletions deepmd/skills.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# SPDX-License-Identifier: LGPL-3.0-or-later
"""Read the public workflow guides shipped with this version of DeePMD-kit."""

import argparse
import sys
from pathlib import (
Path,
)


def skill_root() -> Path:
"""Locate installed resources or the adjacent development checkout."""
package = Path(__file__).resolve().parent
bundled = package / "_skills"
if bundled.is_dir():
return bundled
checkout = package.parent
if (checkout / "pyproject.toml").is_file() and (checkout / "skills").is_dir():
return checkout / "skills"
raise ValueError("Bundled skills are missing; reinstall the package.")


def add_parser(subparsers: argparse._SubParsersAction) -> None:
"""Register the offline skills commands on the application parser."""
parser = subparsers.add_parser(
"skills", help="Read bundled workflow guides offline."
)
actions = parser.add_subparsers(dest="skills_action", required=True)
actions.add_parser("list", help="List available workflow guides.")
read = actions.add_parser(
"read", help="Read a skill or one of its reference files."
)
read.add_argument("name", help="Skill name from 'dp skills list'.")
read.add_argument(
"path", nargs="?", default="SKILL.md", help="Relative reference path."
)


def read_skill(name: str, path: str = "SKILL.md") -> str:
"""Read a public text resource without executing it or following escaping paths."""
root = skill_root().resolve()
if not name or name in (".", "..") or any(c in name for c in "/\\\0"):
raise ValueError("invalid skill name")
parts = path.split("/")
if (
not path
or any(c in path for c in "\\\0:")
or any(p in ("", ".", "..") for p in parts)
):
raise ValueError("reference path must stay within the skill")
skill = (root / name).resolve()
if skill.parent != root or not (skill / "SKILL.md").is_file():
raise ValueError(f"unknown skill: {name}")
target = skill.joinpath(*parts).resolve()
if not target.is_relative_to(skill) or not target.is_file():
raise ValueError(f"unknown skill reference: {path}")
try:
return target.read_bytes().decode("utf-8")
except UnicodeDecodeError as exc:
raise ValueError("skill reference is not UTF-8 text") from exc


def run(args: argparse.Namespace) -> None:
"""Write the requested guide to stdout; report invalid references as CLI errors."""
try:
if args.skills_action == "list":
root = skill_root().resolve()
for path in sorted(root.glob("*/SKILL.md")):
if path.parent.resolve().parent == root:
sys.stdout.write(path.parent.name + "\n")
return
content = read_skill(args.name, args.path)
except (ValueError, OSError) as exc:
sys.stderr.write(f"dp skills: {exc}\n")
raise SystemExit(2) from exc
sys.stdout.write(content)


def workflow_help(name: str) -> str:
"""Point help readers to an available, version-matched workflow guide."""
try:
available = skill_root().joinpath(name, "SKILL.md").is_file()
except (ValueError, OSError):
return ""
if not available:
return ""
return f"Workflow guide (read first): dp skills read {name}"
16 changes: 16 additions & 0 deletions doc/agent-skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,22 @@ paper:
Skills](https://doi.org/10.1021/acs.jctc.6c00622). *Journal
of Chemical Theory and Computation*, 2026.

## Read bundled guides offline

Installed DeePMD-kit releases include a version-matched copy of the public
workflow guides. Use the `dp` CLI to read them locally:

```sh
dp skills list
dp skills read deepmd-train
```

`dp skills read <name> [relative-path]` reads the named guide's `SKILL.md`
by default, or a referenced text file when a relative path is supplied.
These commands only display documentation; they do not load model backends,
execute examples, or register skills with an AI agent. To install the skills
into an agent, follow the instructions below.

## Install skills

To have an agent install DeePMD-kit itself, send
Expand Down
12 changes: 8 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ minimum-version = "1.0"
cmake.source-dir = "source"
sdist.include = [
"/deepmd/_version.py",
"/skills/**",
]
sdist.exclude = [
"/source/tests",
Expand All @@ -219,10 +220,8 @@ sdist.exclude = [
"/data",
"/.github",
]
wheel.packages = [
"deepmd",
"dpa_adapt",
]
wheel.packages = ["deepmd", "dpa_adapt"]
wheel.force-include = {"skills" = "deepmd/_skills"}
Comment thread
njzjz-bot marked this conversation as resolved.
Resolved
wheel.py-api = "py37"
build-dir = "build/{wheel_tag}"

Expand Down Expand Up @@ -267,6 +266,9 @@ test-command = [
"python -m deepmd -h",
"""python -c "import deepmd.tf;import deepmd.pt;import deepmd.pd" """,
"dp -h",
# Wheel tests run in an installed environment: validate forced-in guide files.
"dp skills list",
"dp skills read deepmd-train",
"dp_ipi",
"pytest {project}/source/tests/common/test_lammps.py"
]
Expand Down Expand Up @@ -326,6 +328,8 @@ test-extras = ["cpu", "torch", "paddle"]
test-command = [
"python -m deepmd -h",
"dp -h",
"dp skills list",
"dp skills read deepmd-train",
]
[tool.cibuildwheel.windows.environment]
PIP_PREFER_BINARY = "1"
Expand Down
42 changes: 42 additions & 0 deletions source/tests/common/test_skill_help.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# SPDX-License-Identifier: LGPL-3.0-or-later
"""Discoverable workflow guidance belongs to help, not command output."""

import contextlib
import io
import unittest
from unittest.mock import (
patch,
)

from deepmd.main import (
main_parser,
)
from deepmd.skills import (
workflow_help,
)


class TestSkillHelp(unittest.TestCase):
def test_contextual_help(self) -> None:
for command, skill in {"train": "deepmd-train"}.items():
with self.subTest(command=command):
output = io.StringIO()
with (
contextlib.redirect_stdout(output),
self.assertRaises(SystemExit) as result,
):
main_parser().parse_args(
([command] if command else []) + ["--help"]
)
self.assertEqual(result.exception.code, 0)
self.assertIn(f"dp skills read {skill}", output.getvalue())
self.assertIn(
"dp train input.json --restart model.ckpt", output.getvalue()
)

def test_missing_guide_has_no_pointer(self) -> None:
self.assertEqual(workflow_help("not-a-bundled-skill"), "")
with patch(
"deepmd.skills.skill_root", side_effect=ValueError("missing resources")
):
self.assertEqual(workflow_help("deepmd-train"), "")
135 changes: 135 additions & 0 deletions source/tests/common/test_skills.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# SPDX-License-Identifier: LGPL-3.0-or-later
"""Tests for offline, version-matched workflow discovery."""

import contextlib
import io
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import (
Path,
)
from unittest.mock import (
patch,
)

from deepmd.main import (
main_parser,
parse_args,
)
from deepmd.skills import (
read_skill,
run,
skill_root,
)


class TestSkills(unittest.TestCase):
def test_list_and_read(self) -> None:
output = io.StringIO()
with contextlib.redirect_stdout(output):
run(parse_args(["skills", "list"]))
self.assertIn("deepmd-train", output.getvalue().splitlines())
expected = (skill_root() / "deepmd-train" / "SKILL.md").read_bytes()
self.assertEqual(read_skill("deepmd-train").encode(), expected)

def test_help_and_legacy_parser(self) -> None:
self.assertIn("skills", main_parser().format_help())
args = parse_args(["train", "input.json"])
self.assertEqual(args.INPUT, "input.json")

def test_invalid_paths(self) -> None:
for name, path in [
("../deepmd-train", "SKILL.md"),
("missing", "SKILL.md"),
("deepmd-train", "../README.md"),
("deepmd-train", "/etc/passwd"),
("deepmd-train", "..\\README.md"),
("deepmd-train", "C:/file"),
("deepmd-train", "SKILL.md\0"),
]:
with self.subTest(name=name, path=path), self.assertRaises(ValueError):
read_skill(name, path)

def test_symlink_escape(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
skill = root / "skills" / "guide"
skill.mkdir(parents=True)
(skill / "SKILL.md").write_text("guide", encoding="utf-8")
outside = root / "private.txt"
outside.write_text("private", encoding="utf-8")
(skill / "escape.md").symlink_to(outside)
with patch("deepmd.skills.skill_root", return_value=root / "skills"):
with self.assertRaises(ValueError):
read_skill("guide", "escape.md")

def test_cli_leaves_home_untouched(self) -> None:
with tempfile.TemporaryDirectory() as home:
result = subprocess.run(
[sys.executable, "-m", "deepmd", "skills", "list"],
env={**os.environ, "HOME": home},
capture_output=True,
text=True,
check=True,
)
self.assertIn("deepmd-train", result.stdout)
self.assertEqual(list(Path(home).iterdir()), [])

def test_no_backend_import(self) -> None:
code = (
"import sys; "
"sys.modules.update({name: None for name in "
"('tensorflow', 'torch', 'jax', 'paddle')}); "
"from deepmd.main import main; main(['skills', 'list'])"
)
subprocess.run([sys.executable, "-c", code], check=True, capture_output=True)

def test_missing_resources_are_cli_errors(self) -> None:
for command in [["skills", "list"], ["skills", "read", "deepmd-train"]]:
args = parse_args(command)
for error in [ValueError("missing guides"), OSError("guides inaccessible")]:
output, errors = io.StringIO(), io.StringIO()
with (
self.subTest(command=command, error=error),
patch("deepmd.skills.skill_root", side_effect=error),
contextlib.redirect_stdout(output),
contextlib.redirect_stderr(errors),
self.assertRaises(SystemExit) as raised,
):
run(args)
self.assertEqual(raised.exception.code, 2)
self.assertEqual(output.getvalue(), "")
self.assertIn(f"dp skills: {error}", errors.getvalue())
self.assertNotIn("Traceback", errors.getvalue())

def test_resource_locations(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
package = root / "deepmd"
package.mkdir()
with patch("deepmd.skills.__file__", str(package / "skills.py")):
with self.assertRaisesRegex(ValueError, "reinstall"):
skill_root()
(root / "pyproject.toml").touch()
with self.assertRaisesRegex(ValueError, "reinstall"):
skill_root()
(root / "skills").mkdir()
self.assertEqual(skill_root(), (root / "skills").resolve())
bundled = package / "_skills"
bundled.mkdir()
self.assertEqual(skill_root(), bundled.resolve())

def test_resource_locations_through_symlink(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
actual, alias = root / "actual", root / "alias"
actual.mkdir()
try:
alias.symlink_to(actual, target_is_directory=True)
except (OSError, NotImplementedError) as exc:
self.skipTest(f"Directory symlinks are unavailable: {exc}")
with patch("tempfile.tempdir", str(alias)):
self.test_resource_locations()
Loading