Skip to content
Draft
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# Generated copy of skills/ for library-skills discovery (see skills/sync_into_package.py)
cpmpy/.agents/

# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
Expand Down
3 changes: 3 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Hidden package-data dirs are skipped unless listed. Copied at build time
# from skills/ into cpmpy/.agents/skills/ (see skills/sync_into_package.py).
recursive-include cpmpy/.agents *
6 changes: 6 additions & 0 deletions cpmpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@
- `expressions`: all forms of expression objects that allow you to specify constraints and objectives over variables
- `solvers`: CPMpy classes that translate a model into approriate calls of a solver's API
- `transformations`: common methods for transforming expressions into other expressions, used by `solvers` modules to simplify/rewrite expressions

Agent Skills are authored in ``skills/`` and copied into ``cpmpy/.agents/skills/``
at install time. Install them into a project with ``pip install 'cpmpy[skills]'``
then ``cpmpy skills``; refresh after upgrading with ``cpmpy skills update``.
Rebuild generated references from a source checkout with
``cpmpy skills update --regenerate`` (requires ``cpmpy[docs]``).
"""
# Tias Guns, 2019-2026

Expand Down
211 changes: 204 additions & 7 deletions cpmpy/cli.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,229 @@
"""
Command-line interface for CPMpy.

This module provides a simple CLI to interact with CPMpy, primarily to display
version information about CPMpy itself and the available solver backends.

Usage:
cpmpy <COMMAND>

Commands:
version Show the CPMpy library version and the versions of installed solver backends.
skills Install or update Agent Skills for the current project (requires cpmpy[skills]).
"""

from __future__ import annotations

import argparse
import importlib.util
import shutil
import subprocess
import sys
from pathlib import Path

from cpmpy import __version__
import cpmpy as cp

SKILLS_EXTRA_HINT = "cpmpy skills requires optional dependencies. Install with: pip install 'cpmpy[skills]'"
DOCS_EXTRA_HINT = (
"cpmpy skills update --regenerate requires the docs extra. "
"Install with: pip install 'cpmpy[docs]'"
)
REGENERATE_SOURCE_HINT = """\
--regenerate is only available when CPMpy is installed from source.

This install looks like a wheel (e.g. pip install cpmpy). There is no docs/
tree here to rebuild from; generated reference files already ship in the
package. To pick up a newer snapshot:

pip install -U cpmpy
cpmpy skills update

To regenerate from the Sphinx docs, clone the repo and install editable:

git clone https://github.com/CPMpy/cpmpy.git
cd cpmpy
pip install -e '.[docs]'
cpmpy skills update --regenerate
"""

SKILLS_HELP_PREAMBLE = """\
usage: cpmpy skills [-h] [update] [library-skills args ...]

Install or refresh CPMpy Agent Skills in the current project via library-skills.

Commands:
(none) Interactive discover / install / repair
update Refresh project links to the currently installed CPMpy
(library-skills --yes). Does not upgrade the package;
run `pip install -U 'cpmpy[skills]'` first for new content.
"""

SKILLS_HELP_REGENERATE = """\
update --regenerate Rebuild generated reference files from the Sphinx docs,
recopy them into the package, then refresh project links.
Requires pip install 'cpmpy[docs]'.
"""

SKILLS_HELP_EPILOG = """
Any other arguments are forwarded to library-skills, for example:
cpmpy skills install -y --claude
cpmpy skills list
cpmpy skills update --claude --copy
"""


def command_version(args):
print(f"CPMpy version: {__version__}")
cp.SolverLookup().print_version()

def main():

def _library_skills_available() -> bool:
return importlib.util.find_spec("library_skills") is not None


def _source_root() -> Path | None:
"""
Repo root if this is an editable/source install, else None.
"""
candidate = Path(cp.__file__).resolve().parent.parent
if (candidate / "skills" / "generate_references.py").is_file():
return candidate
return None


def _can_regenerate() -> bool:
"""
True when generated refs can be rebuilt (source checkout, not a wheel).
"""
return _source_root() is not None


def skills_help() -> str:
text = SKILLS_HELP_PREAMBLE
if _can_regenerate():
text += SKILLS_HELP_REGENERATE
return text + SKILLS_HELP_EPILOG


def _docs_extra_available() -> bool:
return (
importlib.util.find_spec("sphinx") is not None
and importlib.util.find_spec("sphinx_llm") is not None
)


def run_library_skills(argv: list[str]) -> int:
"""
Run the library-skills CLI with ``argv``. Returns the process exit code.
"""
if not _library_skills_available():
print(SKILLS_EXTRA_HINT, file=sys.stderr)
return 1

executable = shutil.which("library-skills")
if executable:
cmd = [executable, *argv]
else:
cmd = [sys.executable, "-m", "library_skills", *argv]
completed = subprocess.run(cmd)
return completed.returncode


def _load_sync_module(root: Path):
sync_path = root / "skills" / "sync_into_package.py"
spec = importlib.util.spec_from_file_location("cpmpy_sync_into_package", sync_path)
if spec is None or spec.loader is None:
raise ImportError(f"Cannot load skill sync module from {sync_path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module


def regenerate_skill_references() -> int:
"""
Rebuild ``*.generated.md`` from Sphinx docs and recopy skills into the package.
"""
root = _source_root()
if root is None:
print(REGENERATE_SOURCE_HINT, file=sys.stderr)
return 1
if not _docs_extra_available():
print(DOCS_EXTRA_HINT, file=sys.stderr)
return 1

generator = root / "skills" / "generate_references.py"
completed = subprocess.run([sys.executable, str(generator), "--build"])
if completed.returncode:
return completed.returncode

_load_sync_module(root).sync_skills(
root / "skills",
root / "cpmpy" / ".agents" / "skills",
)
return 0


def command_skills_update(argv: list[str]) -> int:
"""
``cpmpy skills update`` → ``library-skills --yes``, optionally regenerating refs.
"""
parser = argparse.ArgumentParser(
prog="cpmpy skills update",
description=(
"Refresh project links to the currently installed CPMpy. "
"Does not run pip; upgrade the package first for newer skill content."
),
)
can_regenerate = _can_regenerate()
if can_regenerate:
parser.add_argument(
"--regenerate",
action="store_true",
help=(
"Rebuild generated skill reference files from the Sphinx docs "
"(requires pip install 'cpmpy[docs]')."
),
)
args, rest = parser.parse_known_args(argv)
if not can_regenerate and "--regenerate" in rest:
print(REGENERATE_SOURCE_HINT, file=sys.stderr)
return 1
if can_regenerate and args.regenerate:
code = regenerate_skill_references()
if code:
return code
return run_library_skills(["--yes", *rest])


def command_skills(argv: list[str]) -> int:
"""
Native ``cpmpy skills`` command. ``update`` maps to ``library-skills --yes``.
"""
if argv and argv[0] in ("-h", "--help"):
print(skills_help(), end="")
return 0
if argv and argv[0] == "update":
return command_skills_update(argv[1:])
return run_library_skills(argv)


def main(argv: list[str] | None = None):
argv = sys.argv[1:] if argv is None else list(argv)
if argv and argv[0] == "skills":
raise SystemExit(command_skills(argv[1:]))

parser = argparse.ArgumentParser(description="CPMpy command line interface")
subparsers = parser.add_subparsers(dest="command", required=True)

# cpmpy version
version_parser = subparsers.add_parser("version", help="Show version information on CPMpy and its solver backends")
version_parser = subparsers.add_parser(
"version", help="Show version information on CPMpy and its solver backends"
)
version_parser.set_defaults(func=command_version)

args = parser.parse_args()
# Registered so `cpmpy --help` lists it; dispatch is handled above so
# library-skills flags like --claude are not swallowed by argparse.
subparsers.add_parser(
"skills",
help="Install or update Agent Skills for the current project (using library-skills)",
)

args = parser.parse_args(argv)
args.func(args)
10 changes: 8 additions & 2 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@
'sphinx_automodapi.smart_resolver',
'sphinx.ext.napoleon',
'sphinx.ext.todo',
'sphinx.ext.autosectionlabel'
'sphinx.ext.autosectionlabel',
'sphinx_llm.txt'
]

myst_enable_extensions = [
Expand Down Expand Up @@ -117,4 +118,9 @@
# -- Options for HTMLHelp output ---------------------------------------------

# Output file base name for HTML help builder.
htmlhelp_basename = 'cpmpy'
htmlhelp_basename = 'cpmpy'

# Config options for sphinx_llm
llms_txt_exclude = [
'docs_todo',
]
3 changes: 2 additions & 1 deletion docs/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ myst-parser
sphinx-automodapi
sphinx>=5.3.0 # cannot be updated due to m2r2 no longer being maintained -> switch to MyST?
sphinx_rtd_theme>=2.0.0
readthedocs-sphinx-search>=0.3.2
readthedocs-sphinx-search>=0.3.2
sphinx_llm
37 changes: 36 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,29 @@
from setuptools import find_packages, setup
from setuptools.command.build_py import build_py as _build_py
import codecs
import importlib.util
import os.path

def _load_sync_skills():
"""
Load skills/sync_into_package.py without importing the cpmpy package.
"""
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "skills", "sync_into_package.py")
spec = importlib.util.spec_from_file_location("cpmpy_sync_skills", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module.sync_skills


class build_py(_build_py):
"""
Copy authored skills/ into cpmpy/.agents/skills/ before packaging.
"""

def run(self):
_load_sync_skills()()
super().run()

def read(rel_path):
here = os.path.abspath(os.path.dirname(__file__))
with codecs.open(os.path.join(here, rel_path), 'r') as fp:
Expand Down Expand Up @@ -55,6 +77,15 @@ def get_version(rel_path):
}
format_dependencies["io.all"] = list({pkg for group in format_dependencies.values() for pkg in group})

docs_require = [
"sphinx>=5.3.0",
"sphinx_rtd_theme>=2.0.0",
"myst_parser",
"sphinx-automodapi",
"readthedocs-sphinx-search>=0.3.2",
"sphinx_llm",
]

setup(
name='cpmpy',
version=get_version("cpmpy/__init__.py"),
Expand Down Expand Up @@ -83,7 +114,11 @@ def get_version(rel_path):
# Other
"test": ["pytest", "pytest-timeout"],
"type": ["mypy", "types-tqdm"],
"docs": ["sphinx>=5.3.0", "sphinx_rtd_theme>=2.0.0", "myst_parser", "sphinx-automodapi", "readthedocs-sphinx-search>=0.3.2"],
"docs": docs_require,
"skills": ["library-skills"],
},
cmdclass={
"build_py": build_py,
},
entry_points={
'console_scripts': [
Expand Down
Loading
Loading