diff --git a/misc/ploomber-mcp/CHANGELOG.md b/misc/ploomber-mcp/CHANGELOG.md new file mode 100644 index 00000000..e5adb5eb --- /dev/null +++ b/misc/ploomber-mcp/CHANGELOG.md @@ -0,0 +1,3 @@ +# CHANGELOG + +## 0.1dev \ No newline at end of file diff --git a/misc/ploomber-mcp/LICENSE b/misc/ploomber-mcp/LICENSE new file mode 100644 index 00000000..0255deea --- /dev/null +++ b/misc/ploomber-mcp/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Ploomber + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/misc/ploomber-mcp/README.md b/misc/ploomber-mcp/README.md new file mode 100644 index 00000000..aba8ab46 --- /dev/null +++ b/misc/ploomber-mcp/README.md @@ -0,0 +1,89 @@ +# mcp-ploomber + +Setup: + +```sh +# requires conda +pip install invoke +invoke setup +conda activate mcp-ploomber +``` + +If you're not using conda, create a virtual env and do: + +```sh +pip install --editable . +``` + +## Virtual Environment Setup + +You'll need to set up a virtual environment to run the MCP server. We provide setup scripts to make this easy: + +1. Run the setup script to create and configure the virtual environment: + +```sh +# Make the script executable first +chmod +x setup_venv.sh +./setup_venv.sh +``` + +This script will: +- Create a Python virtual environment +- Install all required dependencies +- Set up the package in development mode + +## MCP Configuration + +To configure the MCP server, you need to create an `mcp.json` file: + +1. Run the generation script to create a personalized configuration: + +```sh +# Make the script executable first +chmod +x generate_mcp_json.sh +./generate_mcp_json.sh +``` + +2. Edit the generated `mcp.json` file to add your Ploomber Cloud API key: + +```json +{ + "mcpServers": { + "ploomber-mcp": { + "command": "/path/to/your/venv/bin/python", + "args": [ + "/path/to/your/src/mcp_ploomber/server.py" + ], + "env": { + "_PLOOMBER_CLOUD_ENV": "", + "PLOOMBER_CLOUD_KEY": "YOUR_API_KEY_HERE" + }, + "setup": "/path/to/your/setup_venv.sh" + } + } +} +``` + +## Running the Server + +Once you've completed the setup, you can start the MCP server manually using: + +```sh +cd src/mcp_ploomber +``` + +```sh +mcp run server.py +``` + +To debug, you can also run the MCP server in dev mode: + +```sh +mcp dev server.py +``` + +To add the MCP to your LLM client, simply copy/paste your `mcp.json` where the client expects the MCP config file. + + + + diff --git a/misc/ploomber-mcp/generate_mcp_json.sh b/misc/ploomber-mcp/generate_mcp_json.sh new file mode 100755 index 00000000..7a8fe377 --- /dev/null +++ b/misc/ploomber-mcp/generate_mcp_json.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# generate_mcp_json.sh - Creates a personalized mcp.json file + +# Get the absolute path to the project directory +PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Determine python executable path based on OS +if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "win32" ]]; then + # Windows + PYTHON_PATH="${PROJECT_DIR}/venv/Scripts/python" +else + # Unix-like (macOS, Linux) + PYTHON_PATH="${PROJECT_DIR}/venv/bin/python" +fi + +# Path to the server script +SERVER_SCRIPT="${PROJECT_DIR}/src/mcp_ploomber/server.py" + +# Create the mcp.json file +cat > "${PROJECT_DIR}/mcp.json" << EOF +{ + "mcpServers": { + "ploomber-mcp": { + "command": "${PYTHON_PATH}", + "args": [ + "${SERVER_SCRIPT}" + ], + "env": { + "_PLOOMBER_CLOUD_ENV": "", + "PLOOMBER_CLOUD_KEY": "" + }, + "setup": "${PROJECT_DIR}/setup_venv.sh" + } + } +} +EOF + +echo "Generated mcp.json with your local paths at: ${PROJECT_DIR}/mcp.json" +echo "Please edit the file to add your PLOOMBER_CLOUD_KEY and other environment variables as needed." \ No newline at end of file diff --git a/misc/ploomber-mcp/pyproject.toml b/misc/ploomber-mcp/pyproject.toml new file mode 100644 index 00000000..9958cb1d --- /dev/null +++ b/misc/ploomber-mcp/pyproject.toml @@ -0,0 +1,71 @@ +[tool.pytest.ini_options] +addopts = "--pdbcls=IPython.terminal.debugger:Pdb" + +[tool.nbqa.addopts] +flake8 = [ + # notebooks allow non-top imports + "--extend-ignore=E402", + # jupysql notebooks might have "undefined name" errors + # due to the << operator + # W503, W504 ignore line break after/before + # binary operator since they are conflicting + "--ignore=F821, W503, W504", +] + +[tool.pkgmt] +# used to add links to issue numbers in CHANGELOG.md +github = "ploomber/mcp-ploomber" +# used to check that the package is importable when running pkgmt setup +package_name = "mcp_ploomber" +# defines the conda environment name when using pkgmt setup, if missing, +# package_name is used +env_name = "mcp-ploomber" + +[tool.pkgmt.check_links] +extensions = ["md", "rst", "py", "ipynb"] + + +# build settings +# https://github.com/pypa/sampleproject/blob/main/pyproject.toml + +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "mcp-ploomber" +version = "0.1dev" +description = "A sample Python project" +readme = "README.md" +requires-python = ">=3.9" +license = { file = "LICENSE.txt" } +keywords = ["sample", "setuptools", "development"] +authors = [{ name = "A. Random Developer", email = "author@example.com" }] +maintainers = [ + { name = "A. Great Maintainer", email = "maintainer@example.com" }, +] +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3 :: Only", +] +dependencies = [] +[project.optional-dependencies] +dev = ["pytest", "flake8", "invoke", "twine"] + +[project.urls] +"Homepage" = "https://github.com/pypa/sampleproject" +"Bug Reports" = "https://github.com/pypa/sampleproject/issues" +"Funding" = "https://donate.pypi.org" +"Say Thanks!" = "http://saythanks.io/to/example" +"Source" = "https://github.com/pypa/sampleproject/" + +[project.scripts] +mcp-ploomber = "mcp_ploomber.cli:cli" + +[tool.setuptools] +package-data = { "mcp_ploomber" = ["assets/*", "templates/*", "*.md"] } diff --git a/misc/ploomber-mcp/requirements.txt b/misc/ploomber-mcp/requirements.txt new file mode 100644 index 00000000..00aa3e06 --- /dev/null +++ b/misc/ploomber-mcp/requirements.txt @@ -0,0 +1,2 @@ +ploomber_cloud +mcp[cli] \ No newline at end of file diff --git a/misc/ploomber-mcp/settings.py b/misc/ploomber-mcp/settings.py new file mode 100644 index 00000000..3af58e8e --- /dev/null +++ b/misc/ploomber-mcp/settings.py @@ -0,0 +1,13 @@ +# import os +from pathlib import Path + +# from dotenv import load_dotenv + +# load_dotenv() + + +PATH_TO_PROJECT_ROOT = Path(__file__).parent + +# PATH_TO_DB = PATH_TO_PROJECT_ROOT / "data" / "db.sqlite" +# LOCAL_DB_URI = f"sqlite:///{PATH_TO_DB}" +# OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") diff --git a/misc/ploomber-mcp/setup.cfg b/misc/ploomber-mcp/setup.cfg new file mode 100644 index 00000000..abbc920c --- /dev/null +++ b/misc/ploomber-mcp/setup.cfg @@ -0,0 +1,7 @@ +[metadata] +description-file = README.md + +[flake8] +max-line-length = 88 +extend-ignore = E203 +extend-exclude = build,node_modules diff --git a/misc/ploomber-mcp/setup_venv.sh b/misc/ploomber-mcp/setup_venv.sh new file mode 100755 index 00000000..a51197ef --- /dev/null +++ b/misc/ploomber-mcp/setup_venv.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# setup_venv.sh - Script to set up virtual environment for Ploomber MCP + +# Exit on error +set -e + +echo "Setting up virtual environment for Ploomber MCP..." + +# Create virtual environment +python -m venv venv +echo "Virtual environment created." + +# Determine the correct activation script based on OS +if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "win32" ]]; then + # Windows + ACTIVATE="venv/Scripts/activate" + PIP="venv/Scripts/pip" + PYTHON="venv/Scripts/python" +else + # Unix-like (macOS, Linux) + ACTIVATE="venv/bin/activate" + PIP="venv/bin/pip" + PYTHON="venv/bin/python" +fi + +# Upgrade pip +echo "Upgrading pip..." +$PIP install --upgrade pip + +# Install dependencies +echo "Installing dependencies..." +if [ -f "requirements.txt" ]; then + $PIP install -r requirements.txt +else + echo "Warning: requirements.txt not found. Creating minimal requirements..." + echo "mcp" > requirements.txt + echo "ploomber_cloud" >> requirements.txt + $PIP install -r requirements.txt +fi + +# Install package in development mode +echo "Installing package in development mode..." +$PIP install -e . + +# Verify installation +echo "Verifying installation..." +$PYTHON -c "import sys; print(f'Setup complete! Python version: {sys.version}')" + +echo "" +echo "Setup complete! To activate the virtual environment, run:" +echo "source $ACTIVATE # On Unix-like systems (Linux, macOS)" +echo "or" +echo "$ACTIVATE # On Windows" +echo "" \ No newline at end of file diff --git a/misc/ploomber-mcp/src/mcp_ploomber/__init__.py b/misc/ploomber-mcp/src/mcp_ploomber/__init__.py new file mode 100644 index 00000000..9186402d --- /dev/null +++ b/misc/ploomber-mcp/src/mcp_ploomber/__init__.py @@ -0,0 +1,3 @@ +from mcp_ploomber._settings import Settings + +SETTINGS = Settings() diff --git a/misc/ploomber-mcp/src/mcp_ploomber/_settings.py b/misc/ploomber-mcp/src/mcp_ploomber/_settings.py new file mode 100644 index 00000000..cb626fa9 --- /dev/null +++ b/misc/ploomber-mcp/src/mcp_ploomber/_settings.py @@ -0,0 +1,160 @@ +from pathlib import Path +import sys +from copy import copy +from inspect import getmembers +import importlib +import os +from contextlib import contextmanager + + +@contextmanager +def add_to_sys_path(path): + """Add the given path to sys.path for the duration of the context""" + path = os.path.abspath(path) + sys.path.insert(0, path) + + try: + yield + finally: + sys.path.remove(path) + + +class BaseSchema: + """A base object to define a schema for settings validation""" + + @staticmethod + def _get_public_attributes(obj): + return {k.upper(): v for k, v in obj.__dict__.items() if not k.startswith("_")} + + @classmethod + def _validate(cls, settings): + validators = cls._get_public_attributes(cls) + validators_docs = {k: v.__doc__ for k, v in validators.items()} + + missing = sorted(set(validators) - set(settings)) + + if missing: + missing_with_docs = { + k: v for k, v in validators_docs.items() if k in missing + } + + formatted_error = "\n".join( + [f"{k}: {v}" for k, v in missing_with_docs.items()] + ) + + raise RuntimeError( + f"Error validating settings, missing:\n\n{formatted_error}" + ) + + unexpected = sorted(set(settings) - set(validators)) + + if unexpected: + formatted_error = "\n".join([k for k in unexpected]) + + raise RuntimeError( + f"Error validating settings, unexpected:\n\n{formatted_error}" + ) + + matched = set(validators) & set(settings) + + for match in matched: + try: + validators[match](settings[match]) + except Exception as e: + raise RuntimeError( + f"Error validating settings, the validator for {match} " + f"failed: {str(e)}" + ) from e + + +class Schema(BaseSchema): + """The schema that validates the settings + + Notes + ----- + To register a new setting, add a new method to this class. The method name + must match the setting name in uppercase. The method docstring will be + used to display the error message if the setting is missing or invalid. + The body of the function can raise exceptions to validate the setting. + """ + + @staticmethod + def path_to_project_root(value): + """The path to project root""" + pass + + +class BaseSettings: + """A base object to load settings from a settings.py file""" + + SCHEMA = None + + def __init__(self) -> None: + self._path_to_settings, _ = find_file_recursively("settings.py") + self._load() + + def _load(self): + with add_to_sys_path(self._path_to_settings.parent): + module = importlib.import_module("settings") + + del sys.modules["settings"] + + self._settings = {k: v for k, v in getmembers(module) if k.upper() == k} + self.SCHEMA._validate(self._settings) + + for k, v in self._settings.items(): + setattr(self, k, v) + + def to_dict(self): + return copy(self._settings) + + def to_environ(self): + """Set all settings as environment variables""" + for k, v in self.to_dict().items(): + os.environ[k] = str(v) + + +class Settings(BaseSettings): + """ + The settings object used to load settings from settings.py. It validates + with the Schema class + """ + + SCHEMA = Schema + + +def find_file_recursively(name, max_levels_up=6, starting_dir=None): + """ + Find a file by looking into the current folder and parent folders, + returns None if no file was found otherwise pathlib.Path to the file + + Parameters + ---------- + name : str + Filename + + Returns + ------- + path : str + Absolute path to the file + levels : int + How many levels up the file is located + """ + current_dir = starting_dir or os.getcwd() + current_dir = Path(current_dir).resolve() + path_to_file = None + levels = None + + for levels in range(max_levels_up): + current_path = Path(current_dir, name) + + if current_path.exists(): + path_to_file = current_path.resolve() + break + + current_dir = current_dir.parent + + if not path_to_file: + raise FileNotFoundError(f"File {name} not found") + + return path_to_file, levels diff --git a/misc/ploomber-mcp/src/mcp_ploomber/cli.py b/misc/ploomber-mcp/src/mcp_ploomber/cli.py new file mode 100644 index 00000000..91fe2983 --- /dev/null +++ b/misc/ploomber-mcp/src/mcp_ploomber/cli.py @@ -0,0 +1,87 @@ +""" +Sample CLI (requires click, tested with click==8.1.3) +""" + +import sys + +import click + + +class AliasedGroup(click.Group): + """ + Allow running commands by only typing the first few characters. + https://click.palletsprojects.com/en/8.1.x/advanced/#command-aliases + + To disable, remove the `cls=AliasedGroup` argument from the `@click.group()` decorator. + """ + + def get_command(self, ctx, cmd_name): + rv = click.Group.get_command(self, ctx, cmd_name) + if rv is not None: + return rv + matches = [x for x in self.list_commands(ctx) if x.startswith(cmd_name)] + if not matches: + return None + elif len(matches) == 1: + return click.Group.get_command(self, ctx, matches[0]) + ctx.fail(f"Too many matches: {', '.join(sorted(matches))}") + + def resolve_command(self, ctx, args): + # always return the full command name + _, cmd, args = super().resolve_command(ctx, args) + return cmd.name, cmd, args + + +# NOTE: this requires ipdb +def pdb_option(f): + """Decorator to add --pdb option to any command.""" + + def callback(ctx, param, value): + if value: + + def excepthook(type_, value, traceback): + import ipdb + + ipdb.post_mortem(traceback) + + sys.excepthook = excepthook + + return click.option( + "--pdb", is_flag=True, help="Drop into pdb on exceptions", callback=callback + )(f) + + +@click.group(cls=AliasedGroup) +def cli(): + pass + + +@cli.command() +@pdb_option +def test(pdb: bool): # NOTE: you must add pdb as an argument + """Test command for the pdb option""" + x = 1 + y = 0 + print(x / y) + + +@cli.command() +@click.argument("name") +def hello(name): + """Say hello to someone""" + print(f"Hello, {name}!") + + +@cli.command() +@click.argument("name") +def log(name): + """Log a message""" + from mcp_ploomber.log import configure_file_and_print_logger, get_logger + + configure_file_and_print_logger() + logger = get_logger() + logger.info(f"Hello, {name}!", name=name) + + +if __name__ == "__main__": + cli() diff --git a/misc/ploomber-mcp/src/mcp_ploomber/log.py b/misc/ploomber-mcp/src/mcp_ploomber/log.py new file mode 100644 index 00000000..a42eea8f --- /dev/null +++ b/misc/ploomber-mcp/src/mcp_ploomber/log.py @@ -0,0 +1,124 @@ +""" +A sample logger (requires structlog, tested with structlog==25.1.0) + +Usage +----- + +>>> from mcp_ploomber.log import configure_file_and_print_logger, get_logger +>>> configure_file_and_print_logger("app.log") +>>> # OR +>>> configure_print_logger() +>>> logger = get_logger() +>>> logger.info("Hello, world!") +""" + +import logging +from typing import Any, TextIO +import os +from pathlib import Path + + +import structlog +from structlog import WriteLogger, PrintLogger + + +class CustomLogger: + """ + A custom logger that writes to a file and prints to the console + """ + + def __init__(self, file: TextIO | None = None): + self._file = file + self._write_logger = WriteLogger(self._file) + self._print_logger = PrintLogger() + + def msg(self, message: str) -> None: + self._write_logger.msg(message) + self._print_logger.msg(message) + + log = debug = info = warn = warning = msg + fatal = failure = err = error = critical = exception = msg + + +class CustomLoggerFactory: + def __init__(self, file: TextIO | None = None): + self._file = file + + def __call__(self, *args: Any) -> CustomLogger: + return CustomLogger(self._file) + + +def configure_file_and_print_logger(file_path: str = "app.log") -> None: + structlog.configure( + processors=[ + structlog.contextvars.merge_contextvars, + structlog.processors.add_log_level, + structlog.processors.StackInfoRenderer(), + structlog.dev.set_exc_info, + # to add filename, function name, and line number to the log record + structlog.processors.CallsiteParameterAdder( + [ + structlog.processors.CallsiteParameter.FILENAME, + structlog.processors.CallsiteParameter.FUNC_NAME, + structlog.processors.CallsiteParameter.LINENO, + ], + ), + structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S", utc=False), + structlog.processors.JSONRenderer(), + ], + wrapper_class=structlog.make_filtering_bound_logger(logging.NOTSET), + context_class=dict, + logger_factory=CustomLoggerFactory(open(file_path, "at")), + cache_logger_on_first_use=False, + ) + + +def configure_file_logger(file_path: str = "app.log") -> None: + structlog.configure( + processors=[ + structlog.contextvars.merge_contextvars, + structlog.processors.add_log_level, + structlog.processors.StackInfoRenderer(), + structlog.dev.set_exc_info, + structlog.processors.CallsiteParameterAdder( + [ + structlog.processors.CallsiteParameter.FILENAME, + structlog.processors.CallsiteParameter.FUNC_NAME, + structlog.processors.CallsiteParameter.LINENO, + ], + ), + structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S", utc=False), + structlog.processors.JSONRenderer(), + ], + wrapper_class=structlog.make_filtering_bound_logger(logging.NOTSET), + context_class=dict, + logger_factory=structlog.WriteLoggerFactory(file=Path(file_path).open("at")), + cache_logger_on_first_use=False, + ) + + +def configure_print_logger() -> None: + structlog.configure( + processors=[ + structlog.contextvars.merge_contextvars, + structlog.processors.add_log_level, + structlog.processors.StackInfoRenderer(), + structlog.dev.set_exc_info, + structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S", utc=False), + structlog.dev.ConsoleRenderer(), + ], + wrapper_class=structlog.make_filtering_bound_logger(logging.NOTSET), + context_class=dict, + logger_factory=structlog.PrintLoggerFactory(), + cache_logger_on_first_use=False, + ) + + +def configure_no_logging() -> None: + structlog.configure( + logger_factory=structlog.PrintLoggerFactory(open(os.devnull, "w")), + ) + + +def get_logger(): + return structlog.get_logger() diff --git a/misc/ploomber-mcp/src/mcp_ploomber/server.py b/misc/ploomber-mcp/src/mcp_ploomber/server.py new file mode 100644 index 00000000..d35855b8 --- /dev/null +++ b/misc/ploomber-mcp/src/mcp_ploomber/server.py @@ -0,0 +1,61 @@ +from mcp.server.fastmcp import FastMCP +from ploomber_cloud.api import PloomberCloudClient +import json + +mcp = FastMCP("Ploomber MCP") + +client = PloomberCloudClient() + +@mcp.tool() +def get_me(): + return client.me() + +@mcp.tool() +def get_projects(): + return client.get_projects() + +@mcp.tool() +def get_project_details(project_id: str): + return client.get_project_by_id(project_id) + +@mcp.tool() +def start_job(job_id: str): + return client.start_job(job_id) + +@mcp.tool() +def stop_job(job_id: str): + return client.stop_job(job_id) + +@mcp.tool() +def create_project(project_type: str): + return client.create(project_type) + +@mcp.tool() +def deploy_project( + full_path_to_zip: str, + project_type: str, + project_id: str, + secrets: dict = None, + resources: dict = None, + labels: list = None, +): + if labels: + labels = json.dumps(labels) + + return client.deploy(full_path_to_zip, project_type, project_id, secrets=secrets, resources=resources, labels=labels) + +@mcp.tool() +def delete_project(project_id: str): + return client.delete(project_id) + +@mcp.tool() +def get_job_details(job_id: str): + return client.get_job_by_id(job_id) + +@mcp.tool() +def get_job_logs(job_id: str): + return client.get_job_logs_by_id(job_id) + + +if __name__ == "__main__": + mcp.run() \ No newline at end of file diff --git a/misc/ploomber-mcp/src/mcp_ploomber/templates/__init__.py b/misc/ploomber-mcp/src/mcp_ploomber/templates/__init__.py new file mode 100644 index 00000000..a084e7e6 --- /dev/null +++ b/misc/ploomber-mcp/src/mcp_ploomber/templates/__init__.py @@ -0,0 +1,9 @@ +from jinja2 import Environment, PackageLoader, StrictUndefined + + +env = Environment( + loader=PackageLoader("mcp_ploomber", "templates"), + undefined=StrictUndefined, +) + +env.globals["enumerate"] = enumerate diff --git a/misc/ploomber-mcp/src/mcp_ploomber/templates/template.md b/misc/ploomber-mcp/src/mcp_ploomber/templates/template.md new file mode 100644 index 00000000..c05999ee --- /dev/null +++ b/misc/ploomber-mcp/src/mcp_ploomber/templates/template.md @@ -0,0 +1 @@ +This is a sample jinja2 template. diff --git a/misc/ploomber-mcp/src/mcp_ploomber/utils/__init__.py b/misc/ploomber-mcp/src/mcp_ploomber/utils/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/misc/ploomber-mcp/src/mcp_ploomber/utils/__init__.py @@ -0,0 +1 @@ + diff --git a/misc/ploomber-mcp/src/mcp_ploomber/utils/markdown.py b/misc/ploomber-mcp/src/mcp_ploomber/utils/markdown.py new file mode 100644 index 00000000..3a65a1b8 --- /dev/null +++ b/misc/ploomber-mcp/src/mcp_ploomber/utils/markdown.py @@ -0,0 +1,52 @@ +def remove_code_block_markers(code_string: str, remove_text: bool = True) -> str: + """Remove code block markers from a markdown code block. + + Parameters + ---------- + code_string : str + The input string containing markdown code block(s) + remove_text : bool, default=True + If True, searches for and removes code block markers anywhere in the text. + If False, only removes markers if they are at the start and end of the text. + + Returns + ------- + str + The code string with code block markers removed. + If no code block markers are found, returns the original string. + + Notes + ----- + Code block markers are identified by the ``` sequence. + When remove_text=True, this function will find the first and last occurrences + of code block markers and extract only the code between them. + When remove_text=False, it only removes the markers if they are at the start + and end of the string. + """ + lines = code_string.strip().split("\n") + + if not remove_text: + # Simple check for code blocks at start and end + if lines[0].startswith("```") and lines[-1].startswith("```"): + return "\n".join(lines[1:-1]) + return code_string + + # Find start and end of code block + start_idx = 0 + end_idx = len(lines) + + for i, line in enumerate(lines): + if "```" in line: + start_idx = i + 1 + break + + for i in range(len(lines) - 1, -1, -1): + if "```" in lines[i]: + end_idx = i + break + + # If we found a code block, extract just the code + if start_idx < end_idx: + return "\n".join(lines[start_idx:end_idx]) + + return code_string diff --git a/misc/ploomber-mcp/tasks.py b/misc/ploomber-mcp/tasks.py new file mode 100644 index 00000000..b128abab --- /dev/null +++ b/misc/ploomber-mcp/tasks.py @@ -0,0 +1,36 @@ +from invoke import task + + +@task +def setup(c, version=None): + """ + Setup dev environment, requires conda + """ + version = version or "3.12" + suffix = "" if version == "3.12" else version.replace(".", "") + env_name = f"mcp-ploomber{suffix}" + + c.run(f"conda create --name {env_name} python={version} --yes") + c.run( + 'eval "$(conda shell.bash hook)" ' + f"&& conda activate {env_name} " + "&& pip install --editable .[dev]" + ) + + print(f"Done! Activate your environment with:\nconda activate {env_name}") + + +@task(aliases=["v"]) +def version(c): + """Create a new stable version commit""" + from pkgmt import versioneer + + versioneer.version(project_root=".", tag=True) + + +@task(aliases=["r"]) +def release(c, tag, production=True): + """Upload to PyPI""" + from pkgmt import versioneer + + versioneer.upload(tag, production=production) diff --git a/misc/ploomber-mcp/tests/assets/test_asset.md b/misc/ploomber-mcp/tests/assets/test_asset.md new file mode 100644 index 00000000..b53fbb62 --- /dev/null +++ b/misc/ploomber-mcp/tests/assets/test_asset.md @@ -0,0 +1 @@ +This is some asset used by the test suite. \ No newline at end of file diff --git a/misc/ploomber-mcp/tests/conftest.py b/misc/ploomber-mcp/tests/conftest.py new file mode 100644 index 00000000..040e802c --- /dev/null +++ b/misc/ploomber-mcp/tests/conftest.py @@ -0,0 +1,59 @@ +from functools import wraps +import os +import tempfile +from pathlib import Path +import shutil + + +import pytest + + +def _path_to_tests(): + return Path(__file__).resolve().parent.parent / "tests" + + +def fixture_tmp_dir(source, **kwargs): + """ + A decorator to create fixtures that copy files into a temporary folder + """ + + def decorator(function): + @wraps(function) + def wrapper(): + old = os.getcwd() + tmp_dir = tempfile.mkdtemp() + tmp = Path(tmp_dir, "content") + # we have to add extra folder content/, otherwise copytree + # complains + shutil.copytree(str(source), str(tmp)) + os.chdir(str(tmp)) + yield tmp + + os.chdir(old) + shutil.rmtree(tmp_dir) + + return pytest.fixture(wrapper, **kwargs) + + return decorator + + +@fixture_tmp_dir(_path_to_tests() / "assets") +def tmp_assets(): + pass + + +@pytest.fixture +def tmp_empty(tmp_path): + """ + Create temporary path using pytest native fixture, + them move it, yield, and restore the original path + """ + old = os.getcwd() + os.chdir(str(tmp_path)) + yield str(Path(tmp_path).resolve()) + os.chdir(old) + + +@pytest.fixture +def path_to_test_assets(): + return _path_to_tests() / "assets" diff --git a/misc/ploomber-mcp/tests/test_sample.py b/misc/ploomber-mcp/tests/test_sample.py new file mode 100644 index 00000000..f7241d0b --- /dev/null +++ b/misc/ploomber-mcp/tests/test_sample.py @@ -0,0 +1,8 @@ +def test_example(): + assert 1 + 1 == 2 + + +def test_asset(path_to_test_assets): + assert ( + path_to_test_assets / "test_asset.md" + ).read_text() == "This is some asset used by the test suite."