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
1 change: 1 addition & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
recursive-include skills *
15 changes: 15 additions & 0 deletions doc/agent-skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,21 @@ The `dpdisp-submit` skill is one of the core workflow components described in th

- Mingwei Ding, Chen Huang, Yibo Hu, Yifan Li, Zitian Lu, Xingtai Yu, Duo Zhang, Wenxi Zhai, Tong Zhu, Qiangqiang Gu, and Jinzhe Zeng. [Automating Computational Chemistry Workflows via OpenClaw and Domain-Specific Skills](https://doi.org/10.1021/acs.jctc.6c00622). _J. Chem. Theory Comput._, 2026, DOI: [10.1021/acs.jctc.6c00622](https://doi.org/10.1021/acs.jctc.6c00622).

## Read bundled guides offline

The DPDispatcher package ships a copy of its public workflow guides. Read
the instructions from your installed version without network access:

```sh
dpdisp skills list
dpdisp skills read dpdisp-submit
```

`dpdisp skills read <name> [relative-path]` displays the guide's `SKILL.md`
by default, or a relative text reference within that guide. The command
is read-only: it does not execute the examples, submit jobs, or install the
skills into an agent. Agent installation is described below.

## Install skills

### If you are human beings
Expand Down
31 changes: 26 additions & 5 deletions dpdispatcher/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
"""Public interface for configuring and running DPDispatcher submissions.

The top-level package exports the core configuration and runtime objects. Batch
systems and execution contexts register themselves when the package is imported,
systems and execution contexts register themselves when a runtime object is accessed,
so :class:`Machine` can construct the requested backend from configuration.
"""

from typing import TYPE_CHECKING

if TYPE_CHECKING:
from dpdispatcher.machine import Machine
from dpdispatcher.submission import Job, Resources, Submission, Task

__author__ = "DeepModeling Team"
__copyright__ = "Copyright 2019-2023, DeepModeling"
__status__ = "Production"
Expand All @@ -13,10 +19,6 @@
except ImportError:
__version__ = "unknown"

import dpdispatcher.contexts # noqa: F401
import dpdispatcher.machines # noqa: F401
from dpdispatcher.machine import Machine
from dpdispatcher.submission import Job, Resources, Submission, Task

__all__ = [
"__version__",
Expand All @@ -26,3 +28,22 @@
"Job",
"Resources",
]


def __getattr__(name: str) -> object:
"""Load submission objects only when needed, not while reading CLI guides."""
if name in {"Machine", "Job", "Resources", "Submission", "Task"}:
import dpdispatcher.contexts # noqa: F401
import dpdispatcher.machines # noqa: F401

if name == "Machine":
from dpdispatcher.machine import Machine

value = Machine
else:
from dpdispatcher import submission

value = getattr(submission, name)
globals()[name] = value
return value
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
5 changes: 5 additions & 0 deletions dpdispatcher/base_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,3 +238,8 @@ def machine_subfields(cls) -> list[Argument]:
"""
doc_remote_profile = "The information used to maintain the connection with remote machine. This field is empty for this context."
return [Argument("remote_profile", dict, optional=True, doc=doc_remote_profile)]


# Preserve backend discovery for direct imports without initializing it for the
# documentation-only CLI path.
import dpdispatcher.contexts # noqa: E402, F401
20 changes: 16 additions & 4 deletions dpdispatcher/dpdisp.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,8 @@

import argparse

from dpdispatcher.entrypoints.gui import start_dpgui
from dpdispatcher.entrypoints.run import run
from dpdispatcher.entrypoints.submission import handle_submission
from dpdispatcher.entrypoints.submit import submit
from dpdispatcher.skills import add_parser, workflow_help
from dpdispatcher.skills import run as run_skills


def main_parser() -> argparse.ArgumentParser:
Expand All @@ -26,6 +24,7 @@ def main_parser() -> argparse.ArgumentParser:
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
subparsers = parser.add_subparsers(title="Valid subcommands", dest="command")
add_parser(subparsers)
##########################################
# backward
parser_submission = subparsers.add_parser(
Expand Down Expand Up @@ -145,6 +144,9 @@ def main_parser() -> argparse.ArgumentParser:
"By default the remote directory is removed."
),
)
parser.epilog = "Discover bundled workflow guides: dpdisp skills list."
parser_run.epilog = workflow_help("dpdisp-submit")
parser_submit.epilog = workflow_help("dpdisp-submit")
return parser


Expand All @@ -170,6 +172,8 @@ def main() -> None:
"""Parse command-line arguments and dispatch the selected subcommand."""
args = parse_args()
if args.command == "submission":
from dpdispatcher.entrypoints.submission import handle_submission

handle_submission(
submission_hash=args.SUBMISSION_HASH,
download_terminated_log=args.download_terminated_log,
Expand All @@ -178,13 +182,19 @@ def main() -> None:
reset_fail_count=args.reset_fail_count,
)
elif args.command == "gui":
from dpdispatcher.entrypoints.gui import start_dpgui

start_dpgui(
port=args.port,
bind_all=args.bind_all,
)
elif args.command == "run":
from dpdispatcher.entrypoints.run import run

run(filename=args.filename, allow_ref=args.allow_ref)
elif args.command == "submit":
from dpdispatcher.entrypoints.submit import submit

submit(
filename=args.filename,
dry_run=args.dry_run,
Expand All @@ -195,6 +205,8 @@ def main() -> None:
# submission that explicitly enables continuation remains honored.
continue_on_failure=(True if args.continue_on_failure else None),
)
elif args.command == "skills":
run_skills(args)
elif args.command is None:
pass
else:
Expand Down
5 changes: 5 additions & 0 deletions dpdispatcher/machine.py
Original file line number Diff line number Diff line change
Expand Up @@ -764,3 +764,8 @@ def get_exit_code(self, job: Job) -> int:
raise NotImplementedError(
"abstract method get_exit_code should be implemented by derived class"
)


# Direct imports of this factory must register scheduler implementations too;
# the top-level package intentionally defers runtime imports for offline guides.
import dpdispatcher.machines # noqa: E402, F401
84 changes: 84 additions & 0 deletions dpdispatcher/skills.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""Read the public workflow guides shipped with this version of DPDispatcher."""

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 'dpdisp 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:
print(path.parent.name)
return
content = read_skill(args.name, args.path)
except (ValueError, OSError) as exc:
print(f"dpdisp skills: {exc}", file=sys.stderr)
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): dpdisp skills read {name}"
32 changes: 32 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Bundle the canonical public workflow guides in binary distributions."""

import shutil
from pathlib import Path

from setuptools import setup
from setuptools.command.build_py import build_py


class BuildPy(build_py):
"""Copy public skill resources without maintaining a second source tree."""

def run(self) -> None:
"""Build Python modules and include the version-matched guides."""
super().run()
destination = Path(self.build_lib) / "dpdispatcher" / "_skills"
if destination.exists():
shutil.rmtree(destination)
source = Path(__file__).parent / "skills"
for path in source.rglob("*"):
if path.is_file() and not path.is_symlink():
target = (
Path(self.build_lib)
/ "dpdispatcher"
/ "_skills"
/ path.relative_to(source)
)
self.mkpath(str(target.parent))
self.copy_file(str(path), str(target))


setup(cmdclass={"build_py": BuildPy})
36 changes: 36 additions & 0 deletions tests/test_skill_help.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Discoverable workflow guidance belongs to help, not command output."""

import contextlib
import io
import unittest
from unittest.mock import patch

from dpdispatcher.dpdisp import main_parser
from dpdispatcher.skills import workflow_help


class TestSkillHelp(unittest.TestCase):
def test_contextual_help(self) -> None:
for command, skill in {
"run": "dpdisp-submit",
"submit": "dpdisp-submit",
}.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"dpdisp skills read {skill}", output.getvalue())

def test_missing_guide_has_no_pointer(self) -> None:
self.assertEqual(workflow_help("not-a-bundled-skill"), "")
with patch(
"dpdispatcher.skills.skill_root",
side_effect=ValueError("missing resources"),
):
self.assertEqual(workflow_help("dpdisp-submit"), "")
Loading
Loading