diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml new file mode 100644 index 0000000..042f655 --- /dev/null +++ b/.github/workflows/publish-to-pypi.yml @@ -0,0 +1,86 @@ +name: Publish to PyPI + +on: + release: + types: [published] + +jobs: + build: + name: Build distribution + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Extract version from tag + id: get_version + run: | + # Strip 'v' prefix from tag (v0.2.1 -> 0.2.1) + VERSION=${GITHUB_REF_NAME#v} + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + echo "Extracted version: $VERSION" + + - name: Update version in pyproject.toml + run: | + sed -i "s/^version = .*/version = \"${{ steps.get_version.outputs.VERSION }}\"/" pyproject.toml + echo "Updated pyproject.toml:" + grep "^version" pyproject.toml + + - name: Commit version update to repo + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add pyproject.toml + git commit -m "Bump version to ${{ steps.get_version.outputs.VERSION }}" + git push origin HEAD:main + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install build + + - name: Build package + run: python -m build + + - name: Store the distribution packages + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + - name: Upload assets to GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release upload ${{ github.ref_name }} dist/* --clobber + + publish-to-pypi: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/codeplain + + steps: + - name: Download distribution packages + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + - name: Install twine + run: pip install twine + + - name: Publish to PyPI + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + run: twine upload dist/* diff --git a/.gitignore b/.gitignore index 07a72f2..a4af275 100644 --- a/.gitignore +++ b/.gitignore @@ -23,5 +23,8 @@ examples/**/node_plain_modules/ *.log .venv +build +dist +*.egg-info .env \ No newline at end of file diff --git a/README.md b/README.md index 30c96de..46fad81 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Codeplain is a platform that generates software code using large language models Schematic overview of the Codeplain's code generation service - + ### Abstracting Away Code Generation Complexity with ***plain @@ -17,7 +17,7 @@ Schematic overview of the Codeplain's code generation service An example application in ***plain - + ## Getting started diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..08701da --- /dev/null +++ b/config/__init__.py @@ -0,0 +1 @@ +# Configuration files for plain2code diff --git a/system_config.yaml b/config/system_config.yaml similarity index 100% rename from system_config.yaml rename to config/system_config.yaml diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..03edca7 --- /dev/null +++ b/install.sh @@ -0,0 +1,217 @@ +#!/bin/bash + +set -e + +# Brand Colors (True Color / 24-bit) +YELLOW='\033[38;2;224;255;110m' # #E0FF6E +GREEN='\033[38;2;121;252;150m' # #79FC96 +GREEN_LIGHT='\033[38;2;197;220;217m' # #C5DCD9 +GREEN_DARK='\033[38;2;34;57;54m' # #223936 +BLUE='\033[38;2;10;31;212m' # #0A1FD4 +BLACK='\033[38;2;26;26;26m' # #1A1A1A +WHITE='\033[38;2;255;255;255m' # #FFFFFF +RED='\033[38;2;239;68;68m' # #EF4444 +GRAY='\033[38;2;128;128;128m' # #808080 +GRAY_LIGHT='\033[38;2;211;211;211m' # #D3D3D3 +BOLD='\033[1m' +NC='\033[0m' # No Color / Reset + +# Required Python version +REQUIRED_MAJOR=3 +REQUIRED_MINOR=11 + +# Detect OS +detect_os() { + if [[ "$OSTYPE" == "darwin"* ]]; then + echo "macos" + elif [[ -f /etc/debian_version ]]; then + echo "debian" + elif [[ -f /etc/redhat-release ]]; then + echo "redhat" + else + echo "unknown" + fi +} + +echo -e "started ${YELLOW}${BOLD}*codeplain CLI${NC} installation..." + +# Install Python based on OS +install_python() { + local os=$(detect_os) + + case $os in + macos) + if command -v brew &> /dev/null; then + echo -e "installing Python ${REQUIRED_MAJOR}.${REQUIRED_MINOR} via Homebrew..." + brew install python@${REQUIRED_MAJOR}.${REQUIRED_MINOR} + else + echo -e "${RED}Error: Homebrew is not installed.${NC}" + echo "please install Homebrew first: https://brew.sh" + echo "or install Python manually from: https://www.python.org/downloads/" + exit 1 + fi + ;; + debian) + echo -e "installing Python ${REQUIRED_MAJOR}.${REQUIRED_MINOR} via apt..." + sudo apt update + sudo apt install -y python${REQUIRED_MAJOR}.${REQUIRED_MINOR} python${REQUIRED_MAJOR}.${REQUIRED_MINOR}-venv python3-pip + ;; + redhat) + echo -e "installing Python ${REQUIRED_MAJOR}.${REQUIRED_MINOR} via dnf..." + sudo dnf install -y python${REQUIRED_MAJOR}.${REQUIRED_MINOR} + ;; + *) + echo -e "${RED}Error: Automatic installation not supported for your OS.${NC}" + echo "please install Python ${REQUIRED_MAJOR}.${REQUIRED_MINOR} manually from:" + echo " https://www.python.org/downloads/" + exit 1 + ;; + esac +} + +# Prompt user to install Python +prompt_install_python() { + echo "" + read -p "$(echo -e ${YELLOW}would you like to install Python ${REQUIRED_MAJOR}.${REQUIRED_MINOR}? \(Y/n\): ${NC})" response + case "$response" in + [yY][eE][sS]|[yY]|"") + install_python + echo "" + echo -e "${GREEN}✓ python installed.${NC} please restart your terminal and run this script again." + exit 0 + ;; + *) + echo -e "${YELLOW}installation cancelled.${NC}" + exit 1 + ;; + esac +} + +# Check if python3 or python is installed +if command -v python3.11 &> /dev/null; then + PYTHON_CMD="python3.11" +elif command -v python3 &> /dev/null; then + PYTHON_CMD="python3" +elif command -v python &> /dev/null; then + PYTHON_CMD="python" +else + echo -e "${RED}error: Python 3 is not installed.${NC}" + prompt_install_python +fi + +# Get Python version +PYTHON_VERSION=$($PYTHON_CMD -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")') +PYTHON_MAJOR=$(echo "$PYTHON_VERSION" | cut -d. -f1) +PYTHON_MINOR=$(echo "$PYTHON_VERSION" | cut -d. -f2) + +# Check version +if [ "$PYTHON_MAJOR" -lt "$REQUIRED_MAJOR" ] || \ + ([ "$PYTHON_MAJOR" -eq "$REQUIRED_MAJOR" ] && [ "$PYTHON_MINOR" -lt "$REQUIRED_MINOR" ]); then + echo -e "${RED}error: Python ${REQUIRED_MAJOR}.${REQUIRED_MINOR} or greater is required.${NC}" + echo -e "found: python ${YELLOW}${PYTHON_VERSION}${NC}" + prompt_install_python +fi + +echo -e "" +echo -e "${GREEN}✓${NC} python ${BOLD}${PYTHON_VERSION}${NC} detected" +echo -e "" +# Use python -m pip for reliability +PIP_CMD="$PYTHON_CMD -m pip" + +# Install or upgrade codeplain +if $PIP_CMD show codeplain &> /dev/null; then + CURRENT_VERSION=$($PIP_CMD show codeplain | grep "^Version:" | cut -d' ' -f2) + echo -e "${GRAY}codeplain ${CURRENT_VERSION} is already installed.${NC}" + echo -e "upgrading to latest version..." + echo -e "" + $PIP_CMD install --upgrade codeplain &> /dev/null + NEW_VERSION=$($PIP_CMD show codeplain | grep "^Version:" | cut -d' ' -f2) + if [ "$CURRENT_VERSION" = "$NEW_VERSION" ]; then + echo -e "${GREEN}✓${NC} codeplain is already up to date (${NEW_VERSION})" + else + echo -e "${GREEN}✓${NC} codeplain upgraded from ${CURRENT_VERSION} to ${NEW_VERSION}!" + fi +else + echo -e "installing codeplain...${NC}" + echo -e "" + $PIP_CMD install codeplain &> /dev/null + echo -e "${GREEN}✓ codeplain installed successfully!${NC}" +fi + +echo -e "${GREEN}✓${NC} the latest version of *codeplain CLI is now installed." +echo "" +echo -e "go to ${YELLOW}https://platform.codeplain.ai${NC} and sign up to get your API key." +echo "" +read -p "paste your API key here: " API_KEY +echo "" + +if [ -z "$API_KEY" ]; then + echo -e "${GRAY}no API key provided. you can set it later with:${NC}" + echo -e " export CODEPLAIN_API_KEY=\"your_api_key\"" +else + # Export for current session + export CODEPLAIN_API_KEY="$API_KEY" + + # Detect user's default shell from $SHELL (works even when script runs in different shell) + case "$SHELL" in + */zsh) + SHELL_RC="$HOME/.zprofile" + ;; + */bash) + if [[ "$OSTYPE" == "darwin"* ]]; then + # macOS uses .bash_profile for login shells + SHELL_RC="$HOME/.bash_profile" + else + SHELL_RC="$HOME/.bashrc" + fi + ;; + *) + SHELL_RC="$HOME/.profile" + ;; + esac + + # Create the file if it doesn't exist + touch "$SHELL_RC" + + # Add to shell config if not already present + if ! grep -q "CODEPLAIN_API_KEY" "$SHELL_RC" 2>/dev/null; then + echo "" >> "$SHELL_RC" + echo "# codeplain API Key" >> "$SHELL_RC" + echo "export CODEPLAIN_API_KEY=\"$API_KEY\"" >> "$SHELL_RC" + echo -e "${GREEN}✓ API key saved to ${SHELL_RC}${NC}" + else + # Update existing key (different sed syntax for macOS vs Linux) + if [[ "$OSTYPE" == "darwin"* ]]; then + sed -i '' "s|export CODEPLAIN_API_KEY=.*|export CODEPLAIN_API_KEY=\"$API_KEY\"|" "$SHELL_RC" + else + sed -i "s|export CODEPLAIN_API_KEY=.*|export CODEPLAIN_API_KEY=\"$API_KEY\"|" "$SHELL_RC" + fi + echo -e "${GREEN}✓${NC} API key added to ${SHELL_RC}" + fi + +fi + +# ASCII Art Welcome +echo "" +echo -e "${NC}" +echo -e "${GRAY}────────────────────────────────────────────${NC}" +echo -e "" +cat << 'EOF' + _ _ _ + ___ ___ __| | ___ _ __ | | __ _(_)_ __ + / __/ _ \ / _` |/ _ \ '_ \| |/ _` | | '_ \ + | (_| (_) | (_| | __/ |_) | | (_| | | | | | + \___\___/ \__,_|\___| .__/|_|\__,_|_|_| |_| + |_| +EOF +echo "" +echo -e " ${YELLOW}welcome to *codeplain!${NC}" +echo "" +echo -e " spec-driven, production-ready code generation" +echo "" +echo "" +echo -e "${GRAY}────────────────────────────────────────────${NC}" +echo "" +echo -e " thank you for using *codeplain!" +echo "" +echo -e " run '${YELLOW}${BOLD}codeplain ${NC}' to get started." diff --git a/plain2code.py b/plain2code.py index 1cae9a5..b4e2cd7 100644 --- a/plain2code.py +++ b/plain2code.py @@ -1,4 +1,4 @@ -import importlib.util +import importlib.resources import logging import logging.config import os @@ -9,6 +9,7 @@ from liquid2.exceptions import TemplateNotFoundError from requests.exceptions import RequestException +import codeplain_REST_api as codeplain_api import file_utils import plain_file import plain_spec @@ -26,14 +27,12 @@ get_log_file_path, ) from plain2code_state import RunState -from plain2code_utils import print_dry_run_output from system_config import system_config from tui.plain2code_tui import Plain2CodeTUI TEST_SCRIPT_EXECUTION_TIMEOUT = 120 # 120 seconds -LOGGING_CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logging_config.yaml") -DEFAULT_TEMPLATE_DIRS = "standard_template_library" +DEFAULT_TEMPLATE_DIRS = importlib.resources.files("standard_template_library") MAX_UNITTEST_FIX_ATTEMPTS = 20 MAX_CONFORMANCE_TEST_FIX_ATTEMPTS = 20 @@ -89,6 +88,7 @@ def _get_frids_range(plain_source, start, end=None): def setup_logging( + args, event_bus: EventBus, log_to_file: bool, log_file_name: str, @@ -104,32 +104,21 @@ def setup_logging( logging.getLogger("git").setLevel(logging.WARNING) logging.getLogger("anthropic._base_client").setLevel(logging.WARNING) logging.getLogger("services.langsmith.langsmith_service").setLevel(logging.WARNING) - logging.getLogger("transitions").setLevel(logging.WARNING) logging.getLogger("repositories").setLevel(logging.WARNING) + logging.getLogger("transitions").setLevel(logging.ERROR) + logging.getLogger("transitions.extensions.diagrams").setLevel(logging.ERROR) log_file_path = get_log_file_path(plain_file_path, log_file_name) # Try to load logging configuration from YAML file - if os.path.exists(LOGGING_CONFIG_PATH): + if args.logging_config_path and os.path.exists(args.logging_config_path): try: - with open(LOGGING_CONFIG_PATH, "r") as f: + with open(args.logging_config_path, "r") as f: config = yaml.safe_load(f) logging.config.dictConfig(config) - console.info(f"Loaded logging configuration from {LOGGING_CONFIG_PATH}") + console.info(f"Loaded logging configuration from {args.logging_config_path}") except Exception as e: - logging.basicConfig() - console.warning(f"Failed to load logging configuration from {LOGGING_CONFIG_PATH}: {str(e)}") - - # Silence noisy third-party libraries - logging.getLogger("urllib3").setLevel(logging.WARNING) - logging.getLogger("httpx").setLevel(logging.WARNING) - logging.getLogger("httpcore").setLevel(logging.WARNING) - logging.getLogger("anthropic").setLevel(logging.WARNING) - logging.getLogger("langsmith").setLevel(logging.WARNING) - logging.getLogger("git").setLevel(logging.WARNING) - logging.getLogger("openai").setLevel(logging.WARNING) - logging.getLogger("transitions").setLevel(logging.WARNING) - logging.getLogger("google_genai").setLevel(logging.WARNING) + console.warning(f"Failed to load logging configuration from {args.logging_config_path}: {str(e)}") # Allow detailed retry logs for anthropic if needed logging.getLogger("anthropic._base_client").setLevel(logging.DEBUG) @@ -170,29 +159,12 @@ def render(args, run_state: RunState, codeplain_api, event_bus: EventBus): # no template_dirs = file_utils.get_template_directories(args.filename, args.template_dir, DEFAULT_TEMPLATE_DIRS) - _, plain_source, _ = plain_file.plain_file_parser(args.filename, template_dirs) - - if args.render_range is not None: - args.render_range = get_render_range(args.render_range, plain_source) - elif args.render_from is not None: - args.render_range = get_render_range_from(args.render_from, plain_source) - - # Handle dry run and full plain here (outside of state machine) - if args.dry_run: - console.info("Printing dry run output...") - print_dry_run_output(plain_source, args.render_range) - return - - if args.full_plain: - console.info("Printing full plain output...") - console.info(plain_source) - return + console.info(f"Rendering {args.filename} to target code.") codeplainAPI = codeplain_api.CodeplainAPI(args.api_key, console) codeplainAPI.verbose = args.verbose - - if args.api: - codeplainAPI.api_url = args.api + assert args.api is not None and args.api != "", "API URL is required" + codeplainAPI.api_url = args.api module_renderer = ModuleRenderer( codeplainAPI, @@ -223,22 +195,15 @@ def render(args, run_state: RunState, codeplain_api, event_bus: EventBus): # no return -def main(): # noqa: C901 +def main(): args = parse_arguments() event_bus = EventBus() - setup_logging(event_bus, args.log_to_file, args.log_file_name, args.filename) - - codeplain_api_module_name = "codeplain_local_api" + setup_logging(args, event_bus, args.log_to_file, args.log_file_name, args.filename) - codeplain_api_spec = importlib.util.find_spec(codeplain_api_module_name) - if args.api or codeplain_api_spec is None: - if not args.api: - args.api = "https://api.codeplain.ai" - import codeplain_REST_api as codeplain_api - else: - codeplain_api = importlib.import_module(codeplain_api_module_name) + if not args.api: + args.api = "https://api.codeplain.ai" run_state = RunState(spec_filename=args.filename, replay_with=args.replay_with) diff --git a/plain2code_arguments.py b/plain2code_arguments.py index 3fb3bc3..35c7db4 100644 --- a/plain2code_arguments.py +++ b/plain2code_arguments.py @@ -276,6 +276,13 @@ def create_parser(): help="If set, render the state machine graph.", ) + parser.add_argument( + "--logging-config-path", + action="store_true", + default="logging_config.yaml", + help="Path to the logging configuration file.", + ) + return parser diff --git a/pyproject.toml b/pyproject.toml index ff8b442..08dd61a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,93 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "codeplain" +version = "0.1.6" +description = "Transform plain language specifications into working code" +readme = "README.md" +requires-python = ">=3.11" +classifiers = [ + "Environment :: Console", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Topic :: Software Development :: Code Generators", +] +dependencies = [ + "python-liquid2==0.3.0", + "mistletoe==1.3.0", + "requests==2.32.3", + "tiktoken==0.12.0", + "PyYAML==6.0.2", + "gitpython==3.1.42", + "transitions==0.9.3", + "textual==1.0.0", + "rich==14.2.0", + "python-frontmatter==1.1.0", + "networkx==3.6.1" +] + +[project.optional-dependencies] +dev = [ + "pytest==8.3.5", + "flake8==7.0.0", + "black==24.2.0", + "isort==5.13.2", + "mypy==1.11.2", +] + +[project.scripts] +codeplain = "plain2code:main" + +[tool.setuptools] +py-modules = [ + "plain2code", + "plain2code_arguments", + "plain2code_console", + "plain2code_events", + "plain2code_exceptions", + "plain2code_nodes", + "plain2code_read_config", + "plain2code_state", + "plain2code_tui", + "plain2code_utils", + "plain_file", + "plain_spec", + "codeplain_constants", + "codeplain_local_api", + "codeplain_models", + "codeplain_REST_api", + "codeplain_types", + "codeplain_utils", + "codeplain", + "code_complexity", + "config", + "content_extractor", + "event_bus", + "file_utils", + "git_utils", + "hash_key", + "llm_exceptions", + "llm_handler", + "llm_selector", + "llm", + "render_cache", + "spinner", + "system_config", + "tui_components", + "concept_utils", + "module_renderer", + "plain_modules", + "plain2code_logger", +] + +[tool.setuptools.packages.find] +include = ["config*", "patch*", "prompt_templates*", "render_machine*", "repositories*", "services*", "standard_template_library*", "tui*"] + +[tool.setuptools.package-data] +"*" = ["*.yaml", "*.css", "*.plain"] + [tool.black] line-length = 120 target-version = ['py311'] @@ -19,11 +109,6 @@ line_length = 120 skip = [".git", ".venv", "dist", "venv", ".conda", "tests/data"] skip_gitignore = true -[tool.pytest.ini_options] -pythonpath = ["src"] -testpaths = ["tests"] -norecursedirs = ["tests/data"] - [tool.mypy] python_version = "3.11" ignore_missing_imports = true @@ -61,3 +146,8 @@ exclude = [ "^src/services/langsmith/", "^deploy/", ] + +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["tests"] +norecursedirs = ["tests/data"] \ No newline at end of file diff --git a/standard_template_library/__init__.py b/standard_template_library/__init__.py new file mode 100644 index 0000000..35e9cd5 --- /dev/null +++ b/standard_template_library/__init__.py @@ -0,0 +1 @@ +# Standard template library for plain2code diff --git a/system_config.py b/system_config.py index a9a42a2..8af040f 100644 --- a/system_config.py +++ b/system_config.py @@ -1,4 +1,4 @@ -import os +import importlib.resources import shutil import sys @@ -22,9 +22,9 @@ def __init__(self): def _load_config(self): """Load system configuration from YAML file.""" - config_path = os.path.join(os.path.dirname(__file__), "system_config.yaml") + config_path = importlib.resources.files("config").joinpath("system_config.yaml") try: - with open(config_path, "r") as f: + with config_path.open("r") as f: yaml_data = yaml.safe_load(f) return yaml_data except Exception as e: